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'{html.escape(line)}') svg=f''' {html.escape(title[:36])} {''.join(texts)}''' 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'{html.escape(label)}'); 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'{html.escape(t)}') if j') y+=boxh+gap svg=f'''{''.join(arrows)}{''.join(nodes)}''' 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=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'', '', '', f'{html.escape(title[:32])}', ''] y=94 for ri,row in enumerate(rows): if ri>0: out.append(f'') for ci,t in enumerate(row): x=margin+ci*(col_w+24); fill='#F2E6D4' if ri%2==0 else '#FFFFFF' out.append(f'') for k,tx in enumerate(wrap(t,max(12,int(col_w/19)))[:3]): out.append(f'{html.escape(tx)}') y+=card_h+gap out.append(''); 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'): vals=[] while i'): 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)