239 lines
16 KiB
Python
239 lines
16 KiB
Python
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)
|