Files
guangdong-red-base/build_technical_docx.py

239 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from pathlib import Path
import re, os, subprocess, html
from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.section import WD_SECTION_START
from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from PIL import Image, ImageDraw, ImageFont
ROOT=Path(__file__).parent
SRC=ROOT/'用户交流技术实现方案.md'
OUT=ROOT/'延安精神(大湾区)学习展示中心—技术实现方案.docx'
AS=ROOT/'docx_assets'; AS.mkdir(exist_ok=True)
RED='9E1B1B'; DARK='4A0F12'; GOLD='C59B53'; INK='252525'; MUTED='666666'; PALE='F7F2EC'; GRID='D8CCC0'
def shade(cell, fill):
pr=cell._tc.get_or_add_tcPr(); x=pr.find(qn('w:shd'))
if x is None: x=OxmlElement('w:shd'); pr.append(x)
x.set(qn('w:fill'),fill)
def margins(cell,t=90,b=90,s=120,e=120):
pr=cell._tc.get_or_add_tcPr(); tc=pr.first_child_found_in('w:tcMar')
if tc is None: tc=OxmlElement('w:tcMar'); pr.append(tc)
for k,v in [('top',t),('bottom',b),('start',s),('end',e)]:
n=tc.find(qn('w:'+k))
if n is None: n=OxmlElement('w:'+k); tc.append(n)
n.set(qn('w:w'),str(v)); n.set(qn('w:type'),'dxa')
def set_repeat(row):
trPr=row._tr.get_or_add_trPr(); e=OxmlElement('w:tblHeader'); e.set(qn('w:val'),'true'); trPr.append(e)
def cant_split(row):
trPr=row._tr.get_or_add_trPr(); trPr.append(OxmlElement('w:cantSplit'))
def set_cell_width(cell,dxa):
tcPr=cell._tc.get_or_add_tcPr(); tcW=tcPr.find(qn('w:tcW'))
if tcW is None: tcW=OxmlElement('w:tcW'); tcPr.append(tcW)
tcW.set(qn('w:w'),str(dxa)); tcW.set(qn('w:type'),'dxa')
def font(run,name='Arial Unicode MS',size=None,bold=None,color=None):
run.font.name=name
rf=run._element.get_or_add_rPr().rFonts
for key in ('ascii','hAnsi','eastAsia','cs'): rf.set(qn('w:'+key),name)
if size: run.font.size=Pt(size)
if bold is not None: run.bold=bold
if color: run.font.color.rgb=RGBColor.from_string(color)
def keep(p,next_=False):
pr=p._p.get_or_add_pPr(); pr.append(OxmlElement('w:keepNext' if next_ else 'w:keepLines'))
def add_runs(p,text,base_size=10.5,color=INK):
parts=re.split(r'(\*\*.*?\*\*)',text)
for x in parts:
if not x: continue
bold=x.startswith('**') and x.endswith('**'); val=x[2:-2] if bold else x
r=p.add_run(val); font(r,size=base_size,bold=bold,color=(RED if bold else color))
def border_bottom(p,color=GOLD,size='16'):
pPr=p._p.get_or_add_pPr(); pbdr=OxmlElement('w:pBdr'); b=OxmlElement('w:bottom')
for k,v in [('val','single'),('sz',size),('space','5'),('color',color)]: b.set(qn('w:'+k),v)
pbdr.append(b); pPr.append(pbdr)
def diagram(lines,idx):
items=[]
raw='\n'.join(lines).strip()
if any(ch in raw for ch in '┌┐└┘├┤│─'):
clean=[x.rstrip() for x in lines]
maxw=max((len(x) for x in clean),default=40)
W=min(1900,max(1100,maxw*21+140)); H=max(300,len(clean)*34+120)
title=next((re.sub(r'[┌┐─│├┤└┘]','',x).strip() for x in clean if re.sub(r'[┌┐─│├┤└┘]','',x).strip()),'系统架构图')
texts=[]
for n,line in enumerate(clean):
texts.append(f'<text x="70" y="{105+n*31}" class="mono">{html.escape(line)}</text>')
svg=f'''<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">
<rect width="100%" height="100%" rx="24" fill="#FBF9F6"/>
<rect x="0" y="0" width="100%" height="64" rx="24" fill="#8F181B"/>
<rect x="0" y="40" width="100%" height="24" fill="#8F181B"/>
<text x="42" y="42" class="title">{html.escape(title[:36])}</text>
<line x1="42" y1="78" x2="{W-42}" y2="78" stroke="#C59B53" stroke-width="3"/>
<style>.title{{font-family:'PingFang SC','Microsoft YaHei',sans-serif;font-size:25px;font-weight:700;fill:white}} .mono{{font-family:'Arial Unicode MS','PingFang SC',monospace;font-size:20px;fill:#2B2B2B;white-space:pre}}</style>
{''.join(texts)}</svg>'''
sp=AS/f'diagram_{idx}.svg'; pp=AS/f'diagram_{idx}.png'; path=AS/f'diagram_{idx}.jpg'
sp.write_text(svg,encoding='utf-8')
subprocess.run(['rsvg-convert','-w',str(W*2),'-h',str(H*2),'-o',str(pp),str(sp)],check=True)
subprocess.run(['convert',str(pp),'-quality','94',str(path)],check=True)
return path
if '' in raw and '' in raw and '\n' in raw:
for ln in lines:
if '' in ln: items.append((ln.split('',1)[0], [x.strip() for x in ln.split('',1)[1].split('')]))
else:
toks=[x.strip() for x in re.split(r'→|↓|\n\s*\+\s*|\n',raw) if x.strip() and x.strip()!='+']
items=[('',toks)]
W=1400; pad=70; boxh=74; gap=30; titleh=38
H=pad*2+sum((titleh if a else 0)+len(b)*(boxh+gap) for a,b in items)
y=pad; nodes=[]; arrows=[]
for label,seq in items:
if label: nodes.append(f'<text x="{pad}" y="{y+28}" class="label">{html.escape(label)}</text>'); y+=titleh
n=len(seq); bw=min(1120,max(400,1000)); x=(W-bw)//2
for j,t in enumerate(seq):
fill='#9E1B1B' if j==0 else ('#4A0F12' if j==n-1 else '#F7F2EC')
tc='white' if j in (0,n-1) else '#252525'
nodes.append(f'<rect x="{x}" y="{y}" width="{bw}" height="{boxh}" rx="16" fill="{fill}" stroke="#C59B53" stroke-width="3"/><text x="{W/2}" y="{y+46}" text-anchor="middle" class="node" fill="{tc}">{html.escape(t)}</text>')
if j<n-1:
cx=W//2; arrows.append(f'<line x1="{cx}" y1="{y+boxh}" x2="{cx}" y2="{y+boxh+gap-5}" stroke="#C59B53" stroke-width="4" marker-end="url(#a)"/>')
y+=boxh+gap
svg=f'''<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"><defs><marker id="a" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 Z" fill="#C59B53"/></marker></defs><rect width="100%" height="100%" rx="24" fill="#FBF9F6"/><style>.label{{font-family:'PingFang SC',sans-serif;font-size:28px;font-weight:700;fill:#9E1B1B}} .node{{font-family:'PingFang SC',sans-serif;font-size:24px;font-weight:600}}</style>{''.join(arrows)}{''.join(nodes)}</svg>'''
sp=AS/f'diagram_{idx}.svg'; pp=AS/f'diagram_{idx}.png'; path=AS/f'diagram_{idx}.jpg'; sp.write_text(svg,encoding='utf-8')
subprocess.run(['rsvg-convert','-w',str(W*2),'-h',str(H*2),'-o',str(pp),str(sp)],check=True)
subprocess.run(['convert',str(pp),'-quality','94',str(path)],check=True)
return path
def diagram2(lines,idx):
raw='\n'.join(lines).strip()
def clean(s):
s=re.sub(r'[┌┐└┘├┤┬┴┼│─╭╮╰╯]', ' ', s)
return re.sub(r'\s+', ' ', s).strip(' -+→↓')
rows=[]
for ln in lines:
if not ln.strip(): continue
s=clean(ln)
if not s: continue
parts=[clean(x) for x in re.split(r'\s{2,}|\s*\|\s*|\s*→\s*|\s*↓\s*',s) if clean(x)]
if parts: rows.append(parts)
if not rows: rows=[['技术架构']]
is_flow=('' in raw or '' in raw) and len(rows)>=3
if not is_flow and len(rows)>6:
merged=[]; k=0
while k<len(rows):
if k+1<len(rows) and len(rows[k])==1 and len(rows[k+1])>=1 and len(rows[k][0])<=18:
merged.append([rows[k][0]+''+''.join(rows[k+1])]); k+=2
else:
merged.append(rows[k]); k+=1
rows=merged
if is_flow: rows=[[x] for row in rows for x in row if x not in ('+','-')]
title=next((x for row in rows for x in row if len(x)>2), '技术架构')
W=1500; margin=70; card_h=86; gap=28; maxcols=max(len(r) for r in rows)
col_w=(W-2*margin-(maxcols-1)*24)//maxcols; H=112+len(rows)*(card_h+gap)
def wrap(t,n=18): return [t[i:i+n] for i in range(0,len(t),n)] or ['']
out=[f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">', '<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="5" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 Z" fill="#B78B3D"/></marker></defs>', '<rect width="100%" height="100%" rx="28" fill="#FBF8F3"/><rect width="100%" height="64" rx="28" fill="#891A1C"/><rect y="38" width="100%" height="26" fill="#891A1C"/>', f'<text x="44" y="42" class="title">{html.escape(title[:32])}</text>', '<style>.title{font-family:"PingFang SC",sans-serif;font-size:26px;font-weight:700;fill:#fff}.txt{font-family:"PingFang SC",sans-serif;font-size:19px;font-weight:600;fill:#2B2520}</style>']
y=94
for ri,row in enumerate(rows):
if ri>0: out.append(f'<line x1="{W/2}" y1="{y-18}" x2="{W/2}" y2="{y-4}" stroke="#B78B3D" stroke-width="4" marker-end="url(#arrow)"/>')
for ci,t in enumerate(row):
x=margin+ci*(col_w+24); fill='#F2E6D4' if ri%2==0 else '#FFFFFF'
out.append(f'<rect x="{x}" y="{y}" width="{col_w}" height="{card_h}" rx="14" fill="{fill}" stroke="#B78B3D" stroke-width="2"/>')
for k,tx in enumerate(wrap(t,max(12,int(col_w/19)))[:3]): out.append(f'<text x="{x+col_w/2}" y="{y+32+k*22}" text-anchor="middle" class="txt">{html.escape(tx)}</text>')
y+=card_h+gap
out.append('</svg>'); sp=AS/f'diagram_{idx}.svg'; pp=AS/f'diagram_{idx}.png'; path=AS/f'diagram_{idx}.jpg'; sp.write_text(''.join(out),encoding='utf-8')
subprocess.run(['rsvg-convert','-w',str(W*2),'-h',str(H*2),'-o',str(pp),str(sp)],check=True); subprocess.run(['convert',str(pp),'-quality','94',str(path)],check=True); return path
doc=Document(); sec=doc.sections[0]
sec.page_width=Inches(8.27); sec.page_height=Inches(11.69)
sec.top_margin=Inches(.78); sec.bottom_margin=Inches(.72); sec.left_margin=Inches(.85); sec.right_margin=Inches(.75)
sec.header_distance=Inches(.35); sec.footer_distance=Inches(.35)
styles=doc.styles
normal=styles['Normal']; normal.font.name='Arial Unicode MS'; normal._element.rPr.rFonts.set(qn('w:eastAsia'),'Arial Unicode MS'); normal.font.size=Pt(10.5)
normal.paragraph_format.space_after=Pt(6); normal.paragraph_format.line_spacing=1.35
for nm,sz,col,bef,aft in [('Title',25,DARK,0,14),('Heading 1',17,RED,18,9),('Heading 2',13.5,DARK,13,6),('Heading 3',11.5,RED,9,4),('Heading 4',10.5,DARK,7,3)]:
s=styles[nm]; s.font.name='Arial Unicode MS'; s._element.rPr.rFonts.set(qn('w:eastAsia'),'Arial Unicode MS'); s.font.size=Pt(sz); s.font.bold=True; s.font.color.rgb=RGBColor.from_string(col)
s.paragraph_format.space_before=Pt(bef); s.paragraph_format.space_after=Pt(aft); s.paragraph_format.keep_with_next=True
# Cover
p=doc.add_paragraph(); p.paragraph_format.space_before=Pt(72); p.alignment=WD_ALIGN_PARAGRAPH.CENTER
r=p.add_run('延安精神(大湾区)\n学习展示中心'); font(r,size=29,bold=True,color=DARK)
p=doc.add_paragraph(); p.alignment=WD_ALIGN_PARAGRAPH.CENTER; border_bottom(p,GOLD,'22')
r=p.add_run('技术实现方案'); font(r,size=23,bold=True,color=RED)
p.paragraph_format.space_before=Pt(22); p.paragraph_format.space_after=Pt(22)
p=doc.add_paragraph(); p.alignment=WD_ALIGN_PARAGRAPH.CENTER
r=p.add_run('业务旅程 · AI能力 · 平台架构 · VR/XR · 安全与实施'); font(r,size=12,color=MUTED)
p=doc.add_paragraph(); p.paragraph_format.space_before=Pt(155); p.alignment=WD_ALIGN_PARAGRAPH.CENTER
r=p.add_run('专业方案文本 · 2026年7月'); font(r,size=10.5,color=MUTED)
doc.add_page_break()
# contents
p=doc.add_paragraph('目录',style='Heading 1'); border_bottom(p)
for n,t in [('','系统总体架构'),('','AI可信知识引擎'),('','三引擎技术架构'),('','八个旗舰产品技术架构与场景'),('','VR/XR沉浸体验技术架构'),('','公共平台技术架构'),('','安全与隐私保护'),('','最小可行原型'),('','技术风险与应对'),('','技术团队与外部协作'),('十一','总结')]:
p=doc.add_paragraph(); p.paragraph_format.space_after=Pt(5); r=p.add_run(f'{n} {t}'); font(r,size=11,bold=True if n in ('','','') else False,color=DARK)
doc.add_page_break()
lines=SRC.read_text(encoding='utf-8').splitlines(); i=0; dnum=0; first_title=True
while i<len(lines):
ln=lines[i].rstrip()
if first_title and ln.startswith('# '): first_title=False; i+=1; continue
if ln.strip() in ('---',''): i+=1; continue
if ln.startswith('```'):
block=[]; i+=1
while i<len(lines) and not lines[i].startswith('```'): block.append(lines[i]); i+=1
dnum+=1; path=diagram2(block,dnum); p=doc.add_paragraph(); p.alignment=WD_ALIGN_PARAGRAPH.CENTER; keep(p)
p.add_run().add_picture(str(path),width=Inches(5.65)); i+=1; continue
if ln.startswith('|'):
rows=[]
while i<len(lines) and lines[i].startswith('|'):
cells=[x.strip() for x in lines[i].strip().strip('|').split('|')]
if not all(re.fullmatch(r':?-+:?',x) for x in cells): rows.append(cells)
i+=1
cols=max(map(len,rows)); tbl=doc.add_table(rows=0,cols=cols); tbl.alignment=WD_TABLE_ALIGNMENT.LEFT; tbl.autofit=False
# narrative columns get more width
lens=[max(len(r[c]) if c<len(r) else 0 for r in rows) for c in range(cols)]; total=sum(max(6,x) for x in lens)
widths=[int(9360*max(6,x)/total) for x in lens]; widths[-1]+=9360-sum(widths)
for ri,row in enumerate(rows):
cells=tbl.add_row().cells; cant_split(tbl.rows[-1])
if ri==0: set_repeat(tbl.rows[-1])
for c in range(cols):
set_cell_width(cells[c],widths[c]); margins(cells[c]); cells[c].vertical_alignment=WD_CELL_VERTICAL_ALIGNMENT.CENTER
if ri==0: shade(cells[c],RED)
elif ri%2==0: shade(cells[c],PALE)
p=cells[c].paragraphs[0]; p.paragraph_format.space_after=Pt(0); p.paragraph_format.line_spacing=1.15
add_runs(p,row[c] if c<len(row) else '',9.2,'FFFFFF' if ri==0 else INK)
if ri==0:
for rr in p.runs: rr.bold=True
doc.add_paragraph().paragraph_format.space_after=Pt(1); continue
m=re.match(r'^(#{2,5})\s+(.*)',ln)
if m:
level=len(m.group(1))-1; text=m.group(2)
if level==1 and doc.paragraphs[-1].text: doc.add_page_break()
p=doc.add_paragraph(style=f'Heading {min(level,4)}'); add_runs(p,text,styles[f'Heading {min(level,4)}'].font.size.pt)
if level==1: border_bottom(p)
i+=1; continue
if ln.startswith('>'):
vals=[]
while i<len(lines) and lines[i].startswith('>'): vals.append(lines[i].lstrip('> ').strip()); i+=1
t=doc.add_table(rows=1,cols=1); t.alignment=WD_TABLE_ALIGNMENT.LEFT; t.autofit=False; set_cell_width(t.cell(0,0),9360); margins(t.cell(0,0),170,170,220,220); shade(t.cell(0,0),PALE)
p=t.cell(0,0).paragraphs[0]; p.paragraph_format.space_after=Pt(0); p.paragraph_format.line_spacing=1.3; add_runs(p,' '.join(vals),11,DARK)
for r in p.runs: r.bold=True
continue
lm=re.match(r'^\s*(-|\d+\.)\s+(.*)',ln)
if lm:
style='List Bullet' if lm.group(1)=='-' else 'List Number'; p=doc.add_paragraph(style=style); add_runs(p,lm.group(2)); p.paragraph_format.left_indent=Inches(.28); p.paragraph_format.first_line_indent=Inches(-.18); p.paragraph_format.space_after=Pt(3); i+=1; continue
p=doc.add_paragraph(); p.alignment=WD_ALIGN_PARAGRAPH.JUSTIFY; add_runs(p,ln); keep(p); i+=1
# header/footer
for s in doc.sections:
hp=s.header.paragraphs[0]; hp.alignment=WD_ALIGN_PARAGRAPH.RIGHT; r=hp.add_run('延安精神(大湾区)学习展示中心 · 创新升级方案'); font(r,size=8.5,color=MUTED); border_bottom(hp,GRID,'6')
fp=s.footer.paragraphs[0]; fp.alignment=WD_ALIGN_PARAGRAPH.CENTER
r=fp.add_run(''); font(r,size=8,color=MUTED)
fld=OxmlElement('w:fldSimple'); fld.set(qn('w:instr'),'PAGE'); fp._p.append(fld)
r=fp.add_run(''); font(r,size=8,color=MUTED)
# cover no header
doc.sections[0].different_first_page_header_footer=True
doc.core_properties.title='延安精神(大湾区)学习展示中心—技术实现方案'
doc.core_properties.subject='专业技术实现方案'
doc.save(OUT)
print(OUT)