export_service.py 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464
  1. """export_service.py — 将 Blocks 转换为 .doc 文件并返回永久下载链接"""
  2. import base64
  3. import io
  4. import json
  5. import time
  6. import unicodedata
  7. from pathlib import Path
  8. from typing import Optional
  9. import platform
  10. import os
  11. from docx import Document
  12. from docx.enum.table import WD_TABLE_ALIGNMENT, WD_CELL_VERTICAL_ALIGNMENT
  13. from docx.enum.text import WD_ALIGN_PARAGRAPH
  14. from docx.oxml import OxmlElement
  15. from docx.oxml.ns import qn
  16. from docx.shared import Pt, RGBColor, Inches
  17. from docx.enum.section import WD_ORIENT
  18. from lxml import etree
  19. from app.config import settings
  20. from app.core.exceptions import ExportError
  21. # ------------------------------------------------------------------ #
  22. # TOC 更新服务(使用 WPS/Word COM API)
  23. # ------------------------------------------------------------------ #
  24. def update_document_fields(file_path: str) -> bool:
  25. """使用 WPS/Word COM API 更新文档域(目录、页码等),支持 .docx/.doc,仅 Windows 可用"""
  26. # 仅在 Windows 平台尝试更新
  27. if platform.system() != 'Windows':
  28. print(f"提示: 非 Windows 平台,跳过域更新(文档可在打开时自动更新)")
  29. return False
  30. # 检查文件是否存在
  31. if not os.path.exists(file_path):
  32. print(f"警告: 文件不存在: {file_path}")
  33. return False
  34. try:
  35. import win32com.client
  36. except ImportError:
  37. print(f"提示: 未安装 pywin32,跳过域更新(文档可在打开时自动更新)")
  38. return False
  39. try:
  40. print(f"正在启动 WPS/Word 后台进程更新域...")
  41. # 转换为绝对路径(COM API 需要)
  42. abs_file_path = os.path.abspath(file_path)
  43. # 尝试 WPS
  44. try:
  45. app = win32com.client.Dispatch("Kwps.Application")
  46. app_name = "WPS"
  47. except Exception:
  48. # 如果 WPS 不可用,尝试 Microsoft Word
  49. try:
  50. app = win32com.client.Dispatch("Word.Application")
  51. app_name = "Microsoft Word"
  52. except Exception:
  53. print(f"提示: 未找到 WPS 或 Word,跳过域更新(文档可在打开时自动更新)")
  54. return False
  55. app.Visible = False
  56. app.DisplayAlerts = False
  57. doc = None
  58. try:
  59. # 打开文档(使用绝对路径)
  60. doc = app.Documents.Open(abs_file_path)
  61. # 更新所有域(目录 + 页码)
  62. doc.Fields.Update()
  63. # 再次更新目录(部分版本需要调用两次才能正确填充页码)
  64. for field in doc.Fields:
  65. if field.Type == 13: # wdFieldTOC = 13
  66. field.Update()
  67. # 保存并覆盖原文件
  68. doc.Save()
  69. print(f"✓ 使用 {app_name} 成功更新文档域")
  70. return True
  71. except Exception as e:
  72. print(f"警告: 更新文档域时出错: {e}")
  73. return False
  74. finally:
  75. if doc:
  76. try:
  77. doc.Close(SaveChanges=False)
  78. except:
  79. pass
  80. try:
  81. app.Quit()
  82. except:
  83. pass
  84. except Exception as e:
  85. print(f"警告: 启动 WPS/Word 失败: {e}")
  86. return False
  87. # ------------------------------------------------------------------ #
  88. # 样式文件加载
  89. # ------------------------------------------------------------------ #
  90. def load_style_file(style_id: Optional[str] = None) -> dict:
  91. """加载样式 JSON;style_id=None 时使用默认样式文件"""
  92. if style_id is not None:
  93. # 阶段 1 占位
  94. raise ExportError(f"样式 ID 暂不支持: {style_id}(阶段 1 功能)")
  95. path = Path(settings.default_style_file)
  96. if not path.exists():
  97. raise ExportError(f"默认样式文件不存在: {path}")
  98. try:
  99. with open(path, encoding="utf-8") as f:
  100. return json.load(f)
  101. except (OSError, json.JSONDecodeError) as exc:
  102. raise ExportError(f"样式文件解析失败: {exc}") from exc
  103. def build_style_map(style_data: dict) -> dict[str, dict]:
  104. """将样式列表转为双键映射(style_id 和 name 均可命中)"""
  105. mapping: dict[str, dict] = {}
  106. for s in style_data.get("styles", []):
  107. if s.get("style_id"):
  108. mapping[s["style_id"]] = s
  109. if s.get("name"):
  110. mapping[s["name"]] = s
  111. return mapping
  112. # ------------------------------------------------------------------ #
  113. # JSON ↔ lxml 互转
  114. # ------------------------------------------------------------------ #
  115. def dict_to_element(d: dict) -> etree._Element:
  116. """递归将字典转为 lxml Element"""
  117. elem = etree.Element(d["@tag"], attrib=dict(d.get("@attrib", {})))
  118. if d.get("#text"):
  119. elem.text = d["#text"]
  120. if d.get("#tail"):
  121. elem.tail = d["#tail"]
  122. for child_tag, child_val in d.get("@children", {}).items():
  123. items = child_val if isinstance(child_val, list) else [child_val]
  124. for item in items:
  125. if isinstance(item, dict):
  126. elem.append(dict_to_element(item))
  127. return elem
  128. def inject_styles_from_json(doc: Document, style_data: dict) -> None:
  129. """将 JSON 中所有样式的 full_xml_definition upsert 到文档 <w:styles> 节点"""
  130. styles_element = doc.styles.element
  131. for style_entry in style_data.get("styles", []):
  132. xml_def = style_entry.get("full_xml_definition")
  133. if not xml_def:
  134. continue
  135. try:
  136. new_elem = dict_to_element(xml_def)
  137. except Exception:
  138. continue
  139. style_id_key = qn("w:styleId")
  140. new_style_id = new_elem.get(style_id_key)
  141. if new_style_id:
  142. existing = styles_element.find(
  143. f'.//{qn("w:style")}[@{qn("w:styleId")}="{new_style_id}"]'
  144. )
  145. if existing is not None:
  146. styles_element.remove(existing)
  147. styles_element.append(new_elem)
  148. # 注入编号格式定义
  149. inject_numbering_from_json(doc, style_data)
  150. # 强制清除 python-docx 的样式缓存,确保后续使用的是新注入的样式
  151. try:
  152. # 清除样式字典缓存,强制重新从 XML 读取
  153. if hasattr(doc.styles, '_element'):
  154. # 触发样式重新加载
  155. doc.styles._element = styles_element
  156. except Exception:
  157. pass
  158. def inject_numbering_from_json(doc: Document, style_data: dict) -> None:
  159. """注入编号格式到文档 numbering.xml(python-docx 不支持,通过修改 ZIP 实现)"""
  160. # 暂时不在这里注入,而是在生成文档后通过 ZIP 修改
  161. pass
  162. def _resolve_style_id(style_map: dict, *keys: str) -> Optional[str]:
  163. """解析样式名称(优先返回 name,兼容旧的 style_id)"""
  164. for key in keys:
  165. entry = style_map.get(key)
  166. if entry:
  167. # 优先返回样式名称(推荐方式)
  168. if entry.get("name"):
  169. return entry["name"]
  170. # 兼容:如果没有 name,返回 style_id
  171. if entry.get("style_id"):
  172. return entry["style_id"]
  173. return None
  174. def _apply_paragraph_style(para, style: dict):
  175. """应用段落级样式(对齐方式、行距、缩进等)"""
  176. # 对齐方式
  177. align = style.get('align')
  178. if align == 'center':
  179. para.alignment = WD_ALIGN_PARAGRAPH.CENTER
  180. elif align == 'right':
  181. para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
  182. elif align == 'justify':
  183. para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
  184. elif align == 'left':
  185. para.alignment = WD_ALIGN_PARAGRAPH.LEFT
  186. # 段落格式(行距、缩进等)- 如果需要的话
  187. pf = para.paragraph_format
  188. # 行距
  189. if style.get('line_spacing'):
  190. try:
  191. pf.line_spacing = style['line_spacing']
  192. except Exception:
  193. pass
  194. # 段前间距
  195. if style.get('space_before'):
  196. try:
  197. pf.space_before = Pt(style['space_before'])
  198. except Exception:
  199. pass
  200. # 段后间距
  201. if style.get('space_after'):
  202. try:
  203. pf.space_after = Pt(style['space_after'])
  204. except Exception:
  205. pass
  206. def _apply_run_style(run, style: dict):
  207. """应用 run 级样式(字符级格式)- 增强版"""
  208. # 字体名称(支持中文字体 eastAsia)- 优先处理字体
  209. if style.get('font_name'):
  210. font_name = style['font_name']
  211. run.font.name = font_name
  212. # 对于中文字体,需要设置 eastAsia 属性(关键!)
  213. try:
  214. r = run._element
  215. rPr = r.get_or_add_rPr()
  216. rFonts = rPr.get_or_add_rFonts()
  217. # 设置所有字体属性,确保中文字体正确应用
  218. rFonts.set(qn('w:ascii'), font_name)
  219. rFonts.set(qn('w:hAnsi'), font_name)
  220. rFonts.set(qn('w:eastAsia'), font_name)
  221. rFonts.set(qn('w:cs'), font_name) # 复杂文字
  222. except Exception:
  223. pass
  224. # 字号(优先处理)
  225. if style.get('font_size'):
  226. try:
  227. size_pt = float(style['font_size'])
  228. run.font.size = Pt(size_pt)
  229. # 确保字号正确应用到 XML
  230. r = run._element
  231. rPr = r.get_or_add_rPr()
  232. # 移除旧的字号元素
  233. for sz in rPr.findall(qn('w:sz')):
  234. rPr.remove(sz)
  235. for szCs in rPr.findall(qn('w:szCs')):
  236. rPr.remove(szCs)
  237. # 添加新的字号元素(Word 使用半磅单位)
  238. sz = OxmlElement('w:sz')
  239. sz.set(qn('w:val'), str(int(size_pt * 2)))
  240. rPr.append(sz)
  241. szCs = OxmlElement('w:szCs')
  242. szCs.set(qn('w:val'), str(int(size_pt * 2)))
  243. rPr.append(szCs)
  244. except Exception:
  245. pass
  246. # 粗体
  247. if style.get('bold'):
  248. run.bold = True
  249. # 确保粗体正确应用
  250. try:
  251. r = run._element
  252. rPr = r.get_or_add_rPr()
  253. # 移除旧的粗体元素
  254. for b in rPr.findall(qn('w:b')):
  255. rPr.remove(b)
  256. for bCs in rPr.findall(qn('w:bCs')):
  257. rPr.remove(bCs)
  258. # 添加新的粗体元素
  259. b = OxmlElement('w:b')
  260. rPr.append(b)
  261. bCs = OxmlElement('w:bCs')
  262. rPr.append(bCs)
  263. except Exception:
  264. pass
  265. # 斜体
  266. if style.get('italic'):
  267. run.italic = True
  268. try:
  269. r = run._element
  270. rPr = r.get_or_add_rPr()
  271. for i in rPr.findall(qn('w:i')):
  272. rPr.remove(i)
  273. for iCs in rPr.findall(qn('w:iCs')):
  274. rPr.remove(iCs)
  275. i = OxmlElement('w:i')
  276. rPr.append(i)
  277. iCs = OxmlElement('w:iCs')
  278. rPr.append(iCs)
  279. except Exception:
  280. pass
  281. # 下划线
  282. if style.get('underline'):
  283. run.underline = True
  284. # 删除线
  285. if style.get('strike'):
  286. run.font.strike = True
  287. # 颜色
  288. if style.get('color'):
  289. try:
  290. # 移除可能的 # 前缀
  291. color = style['color'].lstrip('#')
  292. if len(color) == 6:
  293. run.font.color.rgb = RGBColor(
  294. int(color[0:2], 16),
  295. int(color[2:4], 16),
  296. int(color[4:6], 16)
  297. )
  298. except (ValueError, AttributeError):
  299. pass
  300. # ------------------------------------------------------------------ #
  301. # 页面设置应用
  302. # ------------------------------------------------------------------ #
  303. def twips_to_emu(twips: int) -> int:
  304. """twips 转 EMU (1 twips = 635 EMU)"""
  305. if twips is None:
  306. return None
  307. return int(twips * 635)
  308. def apply_page_setup(doc: Document, style_data: dict) -> None:
  309. """应用页面设置到文档第一个 section (doc: Document, style_data: dict)"""
  310. page_setup = style_data.get("page_setup")
  311. if not page_setup:
  312. return
  313. sections = page_setup.get("sections", [])
  314. if not sections:
  315. return
  316. # 应用第一节的设置
  317. section_data = sections[0]
  318. # 检查文档是否有 section
  319. if not doc.sections:
  320. return
  321. section = doc.sections[0]
  322. try:
  323. # 页边距(twips → EMU)
  324. top_margin = section_data.get("top_margin")
  325. if top_margin is not None:
  326. section.top_margin = twips_to_emu(top_margin)
  327. bottom_margin = section_data.get("bottom_margin")
  328. if bottom_margin is not None:
  329. section.bottom_margin = twips_to_emu(bottom_margin)
  330. left_margin = section_data.get("left_margin")
  331. if left_margin is not None:
  332. section.left_margin = twips_to_emu(left_margin)
  333. right_margin = section_data.get("right_margin")
  334. if right_margin is not None:
  335. section.right_margin = twips_to_emu(right_margin)
  336. gutter = section_data.get("gutter")
  337. if gutter is not None and gutter > 0:
  338. section.gutter = twips_to_emu(gutter)
  339. # 纸张尺寸(twips → EMU)
  340. page_width = section_data.get("page_width")
  341. if page_width is not None:
  342. section.page_width = twips_to_emu(page_width)
  343. page_height = section_data.get("page_height")
  344. if page_height is not None:
  345. section.page_height = twips_to_emu(page_height)
  346. # 方向
  347. orientation = section_data.get("orientation")
  348. if orientation == "landscape":
  349. section.orientation = WD_ORIENT.LANDSCAPE
  350. elif orientation == "portrait":
  351. section.orientation = WD_ORIENT.PORTRAIT
  352. # 页眉页脚距离(twips → EMU)
  353. header_distance = section_data.get("header_distance")
  354. if header_distance is not None:
  355. section.header_distance = twips_to_emu(header_distance)
  356. footer_distance = section_data.get("footer_distance")
  357. if footer_distance is not None:
  358. section.footer_distance = twips_to_emu(footer_distance)
  359. # 首页页眉页脚不同
  360. different_first_page = section_data.get("different_first_page")
  361. if different_first_page is not None:
  362. section.different_first_page_header_footer = different_first_page
  363. # 文档网格(需要通过 XML 操作)
  364. grid_type = section_data.get("grid_type")
  365. chars_per_line = section_data.get("chars_per_line")
  366. lines_per_page = section_data.get("lines_per_page")
  367. if grid_type or chars_per_line or lines_per_page:
  368. _apply_document_grid(section, grid_type, chars_per_line, lines_per_page)
  369. except Exception as e:
  370. # 如果应用页面设置失败,不影响文档生成,只是可能使用默认设置
  371. print(f"警告: 应用页面设置失败: {e}")
  372. pass
  373. def _apply_document_grid(section, grid_type: str = None, chars_per_line: int = None, lines_per_page: int = None) -> None:
  374. """应用文档网格设置到 section(grid_type/chars_per_line/lines_per_page,通过 XML 操作)"""
  375. try:
  376. # 获取 section 的 XML 元素
  377. sectPr = None
  378. if hasattr(section, '_sectPr'):
  379. sectPr = section._sectPr
  380. elif hasattr(section, '_element'):
  381. sectPr = section._element
  382. if sectPr is None:
  383. return
  384. # 查找或创建 docGrid 元素
  385. docGrid = sectPr.find(qn('w:docGrid'))
  386. if docGrid is None:
  387. # 如果不存在,创建新的 docGrid 元素
  388. docGrid = OxmlElement('w:docGrid')
  389. # 插入到合适的位置(在 sectPr 的子元素中)
  390. sectPr.append(docGrid)
  391. # 设置网格类型
  392. if grid_type:
  393. docGrid.set(qn('w:type'), grid_type)
  394. # 设置每页行数(linePitch)
  395. if lines_per_page is not None and lines_per_page > 0:
  396. docGrid.set(qn('w:linePitch'), str(lines_per_page))
  397. # 设置每行字符数(charSpace)
  398. if chars_per_line is not None and chars_per_line > 0:
  399. docGrid.set(qn('w:charSpace'), str(chars_per_line))
  400. except Exception as e:
  401. print(f"警告: 应用文档网格失败: {e}")
  402. pass
  403. # ------------------------------------------------------------------ #
  404. # Blocks → Word 转换
  405. # ------------------------------------------------------------------ #
  406. def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict) -> bytes:
  407. """将 Blocks 列表转换为 Word 文档字节流,Args: blocks: Block 列表, style_map: 样式映射, style_data: 样式数据, Returns: Word 文档字节流"""
  408. doc = Document()
  409. # 注入样式
  410. inject_styles_from_json(doc, style_data)
  411. # 应用页面设置(在注入样式之后)
  412. apply_page_setup(doc, style_data)
  413. # 验证并修正 Normal 样式的段后间距
  414. # 这是为了确保样式正确应用,避免 python-docx 的默认值覆盖
  415. _fix_normal_style_spacing(doc, style_data)
  416. # 检查 blocks 中是否有常用的字体和字号,用于修改 Normal 样式
  417. # 这样可以确保空行在 Word 中显示正确的字体
  418. _update_normal_style_if_needed(doc, blocks)
  419. # ★ 新增:检查是否有 TOC block,如果有则设置自动更新域
  420. has_toc = any(block.get('type') == 'toc' for block in blocks)
  421. if has_toc:
  422. _set_update_fields_on_open(doc)
  423. for block in blocks:
  424. block_type = block['type']
  425. if block_type == 'heading':
  426. _render_heading_block(doc, block, style_map)
  427. elif block_type == 'paragraph':
  428. _render_paragraph_block(doc, block, style_map)
  429. elif block_type == 'table':
  430. _render_table_block(doc, block, style_map)
  431. elif block_type == 'image':
  432. _render_image_block(doc, block, style_map)
  433. elif block_type == 'toc': # ★ 新增:处理 TOC block
  434. _render_toc_block(doc, block)
  435. # 先保存到临时缓冲区
  436. buf = io.BytesIO()
  437. doc.save(buf)
  438. # 通过 ZIP 操作注入编号格式
  439. docx_bytes = _inject_numbering_via_zip(buf.getvalue(), style_data)
  440. return docx_bytes
  441. def _fix_normal_style_spacing(doc: Document, style_data: dict):
  442. """验证并修正 Normal 样式的段后间距,从 style_data 中读取 Normal 样式的段后间距定义,确保文档中的 Normal 样式与之一致,关键修复:python-docx 默认模板中 Normal 样式的 styleId 可能是 "Normal" 而不是 "1",需要同时检查这两种情况"""
  443. try:
  444. # 查找 Normal 样式的定义
  445. normal_style_def = None
  446. for style_entry in style_data.get("styles", []):
  447. if style_entry.get("name") == "Normal" or style_entry.get("style_id") == "1":
  448. normal_style_def = style_entry
  449. break
  450. if not normal_style_def:
  451. return
  452. # 从 full_xml_definition 中提取段后间距
  453. xml_def = normal_style_def.get("full_xml_definition", {})
  454. pPr = xml_def.get("@children", {}).get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr", {})
  455. spacing = pPr.get("@children", {}).get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}spacing", {})
  456. spacing_attrib = spacing.get("@attrib", {})
  457. # 检查是否定义了段后间距
  458. after_key = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}after"
  459. has_after = after_key in spacing_attrib
  460. after_value = spacing_attrib.get(after_key, "0")
  461. # 如果样式定义中没有 w:after 或者 w:after="0",确保文档中所有 Normal 样式也是 0
  462. if not has_after or after_value == "0":
  463. styles_element = doc.styles.element
  464. # 需要修正的所有 Normal 样式 ID(python-docx 可能使用不同的 ID)
  465. normal_style_ids = ["1", "Normal", "normal"]
  466. for style_id in normal_style_ids:
  467. normal_elem = styles_element.find(
  468. f'.//{qn("w:style")}[@{qn("w:styleId")}="{style_id}"]'
  469. )
  470. if normal_elem is not None:
  471. _apply_spacing_fix_to_style_elem(normal_elem)
  472. # 也尝试通过名称查找(可能有其他命名的 Normal 样式)
  473. for style_elem in styles_element.findall(qn("w:style")):
  474. name_elem = style_elem.find(qn("w:name"))
  475. if name_elem is not None:
  476. name_val = name_elem.get(qn("w:val"))
  477. if name_val and name_val.lower() == "normal":
  478. _apply_spacing_fix_to_style_elem(style_elem)
  479. except Exception as e:
  480. # 如果修正失败,不影响文档生成,只是可能保留默认的 10 磅间距
  481. print(f"警告: 修正 Normal 样式段后间距失败: {e}")
  482. pass
  483. def _apply_spacing_fix_to_style_elem(style_elem):
  484. """对单个样式元素应用间距修复"""
  485. try:
  486. # 找到或创建 pPr 节点
  487. pPr_elem = style_elem.find(qn("w:pPr"))
  488. if pPr_elem is None:
  489. pPr_elem = OxmlElement("w:pPr")
  490. # 插入到第一个位置(在 name 之后)
  491. name_elem = style_elem.find(qn("w:name"))
  492. if name_elem is not None:
  493. idx = list(style_elem).index(name_elem) + 1
  494. style_elem.insert(idx, pPr_elem)
  495. else:
  496. style_elem.insert(0, pPr_elem)
  497. # 找到或创建 spacing 节点
  498. spacing_elem = pPr_elem.find(qn("w:spacing"))
  499. if spacing_elem is None:
  500. spacing_elem = OxmlElement("w:spacing")
  501. pPr_elem.append(spacing_elem)
  502. # 确保 w:after="0"(明确设置为 0,而不是依赖默认值)
  503. spacing_elem.set(qn("w:after"), "0")
  504. except Exception:
  505. pass
  506. def _update_normal_style_if_needed(doc: Document, blocks: list[dict]):
  507. """更新 Normal 样式以匹配 blocks 中最常用的字体,这样可以确保空行在 Word 中显示正确的字体和字号"""
  508. # 统计段落中最常用的字体和字号
  509. font_counts = {}
  510. size_counts = {}
  511. for block in blocks:
  512. if block['type'] == 'paragraph':
  513. style = block.get('style', {})
  514. font_name = style.get('font_name')
  515. font_size = style.get('font_size')
  516. if font_name:
  517. font_counts[font_name] = font_counts.get(font_name, 0) + 1
  518. if font_size:
  519. size_counts[font_size] = size_counts.get(font_size, 0) + 1
  520. # 找到最常用的字体和字号
  521. most_common_font = max(font_counts.items(), key=lambda x: x[1])[0] if font_counts else None
  522. most_common_size = max(size_counts.items(), key=lambda x: x[1])[0] if size_counts else None
  523. # 如果找到了常用字体或字号,更新 Normal 样式
  524. if most_common_font or most_common_size:
  525. try:
  526. normal_style = doc.styles['Normal']
  527. if most_common_font:
  528. # 修改 Normal 样式的字体
  529. style_element = normal_style.element
  530. rPr = style_element.find(qn('w:rPr'))
  531. if rPr is None:
  532. rPr = OxmlElement('w:rPr')
  533. # 插入到第一个子元素之前
  534. if len(style_element):
  535. style_element.insert(0, rPr)
  536. else:
  537. style_element.append(rPr)
  538. rFonts = rPr.find(qn('w:rFonts'))
  539. if rFonts is None:
  540. rFonts = OxmlElement('w:rFonts')
  541. rPr.append(rFonts)
  542. # 设置所有字体属性
  543. rFonts.set(qn('w:ascii'), most_common_font)
  544. rFonts.set(qn('w:hAnsi'), most_common_font)
  545. rFonts.set(qn('w:eastAsia'), most_common_font)
  546. if most_common_size:
  547. # 修改 Normal 样式的字号
  548. style_element = normal_style.element
  549. rPr = style_element.find(qn('w:rPr'))
  550. if rPr is None:
  551. rPr = OxmlElement('w:rPr')
  552. if len(style_element):
  553. style_element.insert(0, rPr)
  554. else:
  555. style_element.append(rPr)
  556. # 删除旧的字号元素
  557. old_sz = rPr.find(qn('w:sz'))
  558. if old_sz is not None:
  559. rPr.remove(old_sz)
  560. old_szCs = rPr.find(qn('w:szCs'))
  561. if old_szCs is not None:
  562. rPr.remove(old_szCs)
  563. # 添加新的字号元素
  564. sz = OxmlElement('w:sz')
  565. sz.set(qn('w:val'), str(int(most_common_size * 2))) # Word 使用半磅
  566. rPr.append(sz)
  567. szCs = OxmlElement('w:szCs')
  568. szCs.set(qn('w:val'), str(int(most_common_size * 2)))
  569. rPr.append(szCs)
  570. except Exception:
  571. # 如果修改样式失败,继续(不影响文档生成)
  572. pass
  573. def _inject_numbering_via_zip(docx_bytes: bytes, style_data: dict) -> bytes:
  574. """通过 ZIP 操作注入编号格式到 Word 文档,Args: docx_bytes: 原始 Word 文档字节流, style_data: 样式数据(包含 numbering 定义), Returns: 注入编号格式后的 Word 文档字节流"""
  575. numbering_def = style_data.get("numbering")
  576. if not numbering_def:
  577. # 没有编号定义,直接返回原文档
  578. return docx_bytes
  579. try:
  580. from zipfile import ZipFile
  581. from lxml import etree
  582. # 读取原文档
  583. input_buf = io.BytesIO(docx_bytes)
  584. output_buf = io.BytesIO()
  585. with ZipFile(input_buf, 'r') as zip_read:
  586. with ZipFile(output_buf, 'w') as zip_write:
  587. # 复制所有文件
  588. for item in zip_read.infolist():
  589. data = zip_read.read(item.filename)
  590. # 跳过 numbering.xml,我们会重新写入
  591. if item.filename == 'word/numbering.xml':
  592. continue
  593. zip_write.writestr(item, data)
  594. # 将 JSON 格式的编号定义转换为 XML
  595. numbering_element = dict_to_element(numbering_def)
  596. numbering_xml = etree.tostring(
  597. numbering_element,
  598. encoding='UTF-8',
  599. xml_declaration=True,
  600. standalone=True
  601. )
  602. # 写入 numbering.xml
  603. zip_write.writestr('word/numbering.xml', numbering_xml)
  604. # 确保 _rels/document.xml.rels 中有 numbering 的关系
  605. # 读取 document.xml.rels
  606. try:
  607. rels_data = zip_read.read('word/_rels/document.xml.rels')
  608. rels_root = etree.fromstring(rels_data)
  609. # 检查是否已有 numbering 关系
  610. ns = {'r': 'http://schemas.openxmlformats.org/package/2006/relationships'}
  611. numbering_rels = rels_root.xpath(
  612. '//r:Relationship[@Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"]',
  613. namespaces=ns
  614. )
  615. if not numbering_rels:
  616. # 添加 numbering 关系
  617. from docx.oxml.ns import qn
  618. rel_elem = etree.SubElement(rels_root, qn('r:Relationship'))
  619. # 找到最大的 rId
  620. existing_ids = [int(r.get('Id')[3:]) for r in rels_root.findall(qn('r:Relationship')) if r.get('Id', '').startswith('rId')]
  621. next_id = max(existing_ids) + 1 if existing_ids else 1
  622. rel_elem.set('Id', f'rId{next_id}')
  623. rel_elem.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering')
  624. rel_elem.set('Target', 'numbering.xml')
  625. # 写回 rels 文件
  626. rels_xml = etree.tostring(rels_root, encoding='UTF-8', xml_declaration=True)
  627. zip_write.writestr('word/_rels/document.xml.rels', rels_xml)
  628. except KeyError:
  629. # 如果没有 rels 文件,忽略
  630. pass
  631. return output_buf.getvalue()
  632. except Exception as e:
  633. # 如果注入失败,返回原文档
  634. print(f"警告: 通过 ZIP 注入编号格式失败: {e}")
  635. return docx_bytes
  636. def _render_heading_block(doc: Document, block: dict, style_map: dict):
  637. """渲染标题块(支持编号格式和自定义样式)- 增强版"""
  638. level = block['level']
  639. content = block['content']
  640. style_name = block.get('word_style', f'Heading {level}')
  641. block_style = block.get('style', {})
  642. # 创建段落
  643. para = doc.add_paragraph()
  644. # 应用 Word 样式(使用样式名称,python-docx 推荐方式)
  645. style_name_or_id = _resolve_style_id(style_map, style_name, f'Heading {level}')
  646. if style_name_or_id:
  647. try:
  648. para.style = style_name_or_id # 直接赋值名称,python-docx 会自动查找
  649. except KeyError:
  650. para.style = f'Heading {level}'
  651. else:
  652. para.style = f'Heading {level}'
  653. # 应用 Block 级自定义样式(段落级)
  654. _apply_paragraph_style(para, block_style)
  655. # 渲染内容
  656. if isinstance(content, list):
  657. # 富文本:应用 run 级样式
  658. _render_rich_text(para, content, block_style)
  659. else:
  660. # 纯文本:应用 block 级样式到 run
  661. run = para.add_run(str(content))
  662. # 标题也需要应用自定义样式(如果有的话)
  663. if block_style:
  664. _apply_run_style(run, block_style)
  665. # 尝试应用编号格式(如果样式中包含编号定义)
  666. try:
  667. # 检查样式是否有编号定义
  668. style_element = para.style.element
  669. if style_element is not None:
  670. from docx.oxml.ns import qn
  671. # 查找样式中的编号属性
  672. pPr = style_element.find(qn('w:pPr'))
  673. if pPr is not None:
  674. numPr = pPr.find(qn('w:numPr'))
  675. if numPr is not None:
  676. # 样式中有编号定义,复制到段落
  677. para_pPr = para._element.get_or_add_pPr()
  678. # 移除可能存在的旧编号属性
  679. old_numPr = para_pPr.find(qn('w:numPr'))
  680. if old_numPr is not None:
  681. para_pPr.remove(old_numPr)
  682. # 复制编号属性
  683. import copy
  684. para_pPr.append(copy.deepcopy(numPr))
  685. except Exception as e:
  686. # 如果应用编号失败,继续(标题仍然会显示,只是没有编号)
  687. pass
  688. def _render_paragraph_block(doc: Document, block: dict, style_map: dict):
  689. """渲染段落块(支持富文本和自定义样式)- 增强版"""
  690. content = block['content']
  691. style_name = block.get('word_style', 'Normal')
  692. block_style = block.get('style', {})
  693. para = doc.add_paragraph()
  694. # 应用 Word 样式(使用样式名称,python-docx 推荐方式)
  695. style_name_or_id = _resolve_style_id(style_map, style_name, 'Normal')
  696. if style_name_or_id:
  697. try:
  698. para.style = style_name_or_id # 直接赋值名称,python-docx 会自动查找
  699. except KeyError:
  700. para.style = 'Normal'
  701. else:
  702. para.style = 'Normal'
  703. # 应用 Block 级自定义样式(段落级)
  704. _apply_paragraph_style(para, block_style)
  705. # 渲染内容
  706. if isinstance(content, list):
  707. # 富文本:应用 run 级样式
  708. _render_rich_text(para, content, block_style)
  709. elif content:
  710. # 有内容的纯文本
  711. run = para.add_run(str(content))
  712. _apply_run_style(run, block_style)
  713. else:
  714. # 空内容 - 关键修复:确保空段落也能保留样式
  715. # 创建空 run 并应用样式,这样用户在 Word 中输入文本时会自动应用这些样式
  716. run = para.add_run('')
  717. _apply_run_style(run, block_style)
  718. # 对于空段落,还需要确保段落格式正确
  719. # 特别是字体和字号,即使 run 是空的也要设置
  720. if block_style.get('font_name') or block_style.get('font_size'):
  721. # 再添加一个空格符 run 来"激活"样式(Word 的特殊处理)
  722. # 然后立即删除,但样式会保留
  723. try:
  724. # 方法:在段落属性中设置默认 run 属性
  725. pPr = para._element.get_or_add_pPr()
  726. rPr = pPr.find(qn('w:rPr'))
  727. if rPr is None:
  728. rPr = OxmlElement('w:rPr')
  729. pPr.insert(0, rPr)
  730. # 设置字体
  731. if block_style.get('font_name'):
  732. font_name = block_style['font_name']
  733. rFonts = rPr.find(qn('w:rFonts'))
  734. if rFonts is None:
  735. rFonts = OxmlElement('w:rFonts')
  736. rPr.append(rFonts)
  737. rFonts.set(qn('w:ascii'), font_name)
  738. rFonts.set(qn('w:hAnsi'), font_name)
  739. rFonts.set(qn('w:eastAsia'), font_name)
  740. rFonts.set(qn('w:cs'), font_name)
  741. # 设置字号
  742. if block_style.get('font_size'):
  743. size_pt = float(block_style['font_size'])
  744. # 移除旧的字号
  745. for sz in rPr.findall(qn('w:sz')):
  746. rPr.remove(sz)
  747. for szCs in rPr.findall(qn('w:szCs')):
  748. rPr.remove(szCs)
  749. # 添加新的字号
  750. sz = OxmlElement('w:sz')
  751. sz.set(qn('w:val'), str(int(size_pt * 2)))
  752. rPr.append(sz)
  753. szCs = OxmlElement('w:szCs')
  754. szCs.set(qn('w:val'), str(int(size_pt * 2)))
  755. rPr.append(szCs)
  756. except Exception:
  757. pass
  758. def _render_rich_text(para, segments: list, block_style: dict = None):
  759. """渲染富文本格式 - 增强版"""
  760. for seg in segments:
  761. text = seg.get('text', '')
  762. seg_style = seg.get('style', {})
  763. run = para.add_run(text)
  764. # 合并样式:block_style 作为默认,seg_style 覆盖
  765. merged_style = {}
  766. if block_style:
  767. merged_style.update(block_style)
  768. merged_style.update(seg_style)
  769. # 应用合并后的样式
  770. if merged_style:
  771. _apply_run_style(run, merged_style)
  772. # 处理 word_style(如果片段有独立的 word_style)
  773. word_style = seg.get('word_style')
  774. if word_style:
  775. # 注意:run 不能直接应用样式,只能应用字符样式
  776. # 这里我们只应用格式属性
  777. pass
  778. def _render_table_block(doc: Document, block: dict, style_map: dict):
  779. """渲染表格块(支持合并单元格、列宽、行高等)"""
  780. table_data = block['content']
  781. if isinstance(table_data, str):
  782. try:
  783. table_data = json.loads(table_data)
  784. except json.JSONDecodeError:
  785. return
  786. rows = table_data.get('rows', [])
  787. if not rows:
  788. return
  789. # 使用 col_widths 确定真实列数(而不是第一行的单元格数)
  790. # 注意: col_widths 应该从 metadata 中获取, 而不是 content
  791. metadata = block.get('metadata', {})
  792. col_widths = metadata.get('col_widths', [])
  793. if col_widths:
  794. num_cols = len(col_widths)
  795. else:
  796. # 回退:扫描所有行,找到最大的列索引
  797. num_cols = 0
  798. for row_data in rows:
  799. col_index = 0
  800. for cell_data in row_data.get('cells', []):
  801. colspan = cell_data.get('colspan', 1)
  802. col_index += colspan
  803. num_cols = max(num_cols, col_index)
  804. if num_cols == 0:
  805. return
  806. num_rows = len(rows)
  807. # 创建表格
  808. table = doc.add_table(rows=num_rows, cols=num_cols)
  809. # 应用表格样式
  810. table_style = block.get('word_style', 'Table Grid')
  811. try:
  812. table.style = table_style
  813. except KeyError:
  814. table.style = 'Table Grid'
  815. # 设置表格对齐方式(默认居中)
  816. block_style = block.get('style', {})
  817. table_align = block_style.get('table_align', 'center') # 默认居中
  818. if table_align == 'center':
  819. table.alignment = WD_TABLE_ALIGNMENT.CENTER
  820. elif table_align == 'left':
  821. table.alignment = WD_TABLE_ALIGNMENT.LEFT
  822. elif table_align == 'right':
  823. table.alignment = WD_TABLE_ALIGNMENT.RIGHT
  824. # 设置列宽
  825. # 注意: col_widths 中存储的是百分比值(如 [50, 50] 表示两列各占50%)
  826. # 需要根据表格总宽度计算实际列宽(磅值)
  827. if col_widths:
  828. # 获取表格总宽度设置
  829. table_width = metadata.get('table_width', 100)
  830. table_width_unit = metadata.get('table_width_unit', 'percent')
  831. # 计算表格实际宽度(磅)
  832. if table_width_unit == 'percent':
  833. # 百分比模式:基于页面可用宽度计算
  834. # 假设 A4 纸张,页面宽度约 595磅(21cm),左右边距各约71磅(2.5cm)
  835. # 可用宽度 = 595 - 71 - 71 = 453 磅
  836. page_available_width_pt = 453.0 # 可以从 style_data 中的 page_setup 获取更精确的值
  837. actual_table_width_pt = page_available_width_pt * (table_width / 100.0)
  838. elif table_width_unit == 'cm':
  839. # 厘米转磅: 1cm = 28.35磅
  840. actual_table_width_pt = table_width * 28.35
  841. elif table_width_unit == 'inch':
  842. # 英寸转磅: 1inch = 72磅
  843. actual_table_width_pt = table_width * 72.0
  844. else:
  845. # 默认使用百分比模式
  846. page_available_width_pt = 453.0
  847. actual_table_width_pt = page_available_width_pt * (table_width / 100.0)
  848. # 计算列宽百分比总和,用于归一化
  849. col_widths_sum = sum(col_widths)
  850. # 根据百分比计算每列的实际宽度并设置
  851. # 注意:为了避免因百分比总和不为100而导致的问题,我们基于实际总和进行归一化
  852. for col_idx, width_percent in enumerate(col_widths):
  853. if col_idx < len(table.columns):
  854. # 归一化:基于实际的百分比总和计算每列占表格宽度的比例
  855. # 例如:如果7列各14%,总和98%,则每列实际占 14/98 的表格宽度
  856. if col_widths_sum > 0:
  857. col_width_pt = actual_table_width_pt * (width_percent / col_widths_sum)
  858. else:
  859. # 如果总和为0,平均分配
  860. col_width_pt = actual_table_width_pt / len(col_widths)
  861. table.columns[col_idx].width = Pt(col_width_pt)
  862. # 填充内容并处理合并单元格
  863. merge_map = {} # {(row, col): (end_row, end_col)} 记录合并区域
  864. occupied = {} # {(row, col): True} 记录哪些位置已被占用(被合并的单元格)
  865. for r_idx, row_data in enumerate(rows):
  866. # 设置行高
  867. row_height = row_data.get('height')
  868. if row_height:
  869. table.rows[r_idx].height = Pt(row_height)
  870. cells_data = row_data.get('cells', [])
  871. # 遍历单元格数据
  872. # 注意:单元格在 cells 数组中的索引就是它的列索引(col_idx)
  873. col_offset = 0 # 当前应该填充到哪一列
  874. for cell_idx, cell_data in enumerate(cells_data):
  875. # 跳过被上方合并单元格占用的列
  876. while occupied.get((r_idx, col_offset), False):
  877. col_offset += 1
  878. if col_offset >= num_cols:
  879. break
  880. if col_offset >= num_cols:
  881. break # 超出列数,停止处理
  882. # 跳过被合并的单元格(rowspan=0 或 colspan=0 表示这个单元格被合并了)
  883. rowspan = cell_data.get('rowspan', 1)
  884. colspan = cell_data.get('colspan', 1)
  885. if rowspan == 0 or colspan == 0:
  886. # 这个单元格已被合并,跳过
  887. continue
  888. # 获取起始单元格
  889. start_cell = table.rows[r_idx].cells[col_offset]
  890. # 处理合并单元格
  891. if colspan > 1 or rowspan > 1:
  892. # 计算结束位置
  893. end_col = min(col_offset + colspan - 1, num_cols - 1)
  894. end_row = min(r_idx + rowspan - 1, num_rows - 1)
  895. # 合并单元格
  896. if end_col > col_offset or end_row > r_idx:
  897. try:
  898. end_cell = table.rows[end_row].cells[end_col]
  899. start_cell.merge(end_cell)
  900. merge_map[(r_idx, col_offset)] = (end_row, end_col)
  901. # 标记被合并的单元格位置为已占用
  902. for merge_r in range(r_idx, end_row + 1):
  903. for merge_c in range(col_offset, end_col + 1):
  904. if merge_r != r_idx or merge_c != col_offset: # 不标记起始单元格
  905. occupied[(merge_r, merge_c)] = True
  906. except Exception as e:
  907. pass # 合并失败,继续
  908. # 设置单元格宽度(如果有)
  909. cell_width = cell_data.get('width')
  910. if cell_width:
  911. try:
  912. start_cell.width = Pt(cell_width)
  913. except Exception:
  914. pass
  915. # 填充单元格内容
  916. cell_text = cell_data.get('text', '')
  917. cell_style = cell_data.get('style', {})
  918. cell_word_style = cell_data.get('word_style') # 获取单元格的 word_style
  919. # 设置单元格垂直对齐(默认居中)
  920. valign = cell_style.get('valign', 'center') # 默认垂直居中
  921. if valign == 'center' or valign == 'middle':
  922. start_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
  923. elif valign == 'top':
  924. start_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.TOP
  925. elif valign == 'bottom':
  926. start_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.BOTTOM
  927. # 清空默认段落
  928. start_cell.text = ''
  929. para = start_cell.paragraphs[0]
  930. # 应用单元格的 Word 样式(如果有)
  931. if cell_word_style:
  932. # 尝试从 style_map 解析样式名称
  933. style_name_or_id = _resolve_style_id(style_map, cell_word_style)
  934. if style_name_or_id:
  935. # 直接使用样式名称(python-docx 推荐方式)
  936. try:
  937. para.style = style_name_or_id
  938. except KeyError:
  939. # 如果解析的名称不存在,尝试直接使用原始名称
  940. try:
  941. para.style = cell_word_style
  942. except KeyError:
  943. # 都失败了,使用 Normal
  944. para.style = 'Normal'
  945. else:
  946. # 没有找到映射,尝试直接使用名称
  947. try:
  948. para.style = cell_word_style
  949. except KeyError:
  950. # 失败了,使用 Normal
  951. para.style = 'Normal'
  952. # 应用单元格段落级样式(对齐)
  953. _apply_paragraph_style(para, cell_style)
  954. # 渲染单元格内容(支持富文本)
  955. if isinstance(cell_text, list):
  956. # 富文本格式
  957. _render_rich_text(para, cell_text, cell_style)
  958. else:
  959. # 纯文本格式
  960. run = para.add_run(str(cell_text))
  961. # 应用单元格 run 级样式
  962. _apply_run_style(run, cell_style)
  963. # 移动到下一列位置(考虑colspan)
  964. col_offset += colspan
  965. def _render_image_block(doc: Document, block: dict, style_map: dict = None):
  966. """渲染图片块(支持 Base64 Data URL 和 Word 样式)"""
  967. content = block['content']
  968. style = block.get('style', {})
  969. word_style = block.get('word_style', 'Normal')
  970. # 只处理 Data URL
  971. if not isinstance(content, str) or not content.startswith('data:'):
  972. return
  973. try:
  974. # 解析 data:image/png;base64,xxxxx
  975. if ',' not in content:
  976. return
  977. header, b64_data = content.split(',', 1)
  978. image_bytes = base64.b64decode(b64_data)
  979. # 创建段落并应用 Word 样式
  980. paragraph = doc.add_paragraph()
  981. # 应用 Word 样式(如"图表标题")(使用样式名称,python-docx 推荐方式)
  982. if style_map:
  983. style_name_or_id = _resolve_style_id(style_map, word_style, 'Normal')
  984. if style_name_or_id:
  985. try:
  986. paragraph.style = style_name_or_id # 直接赋值名称
  987. except KeyError:
  988. # 如果解析的名称不存在,尝试使用原始样式名称
  989. try:
  990. paragraph.style = word_style
  991. except KeyError:
  992. paragraph.style = 'Normal'
  993. else:
  994. # 如果没有找到映射,尝试直接使用 word_style
  995. try:
  996. paragraph.style = word_style
  997. except KeyError:
  998. paragraph.style = 'Normal'
  999. else:
  1000. # 没有 style_map,尝试直接使用 word_style
  1001. try:
  1002. paragraph.style = word_style
  1003. except KeyError:
  1004. paragraph.style = 'Normal'
  1005. # 设置对齐方式(可能覆盖样式中的对齐)
  1006. align = style.get('align', 'left')
  1007. if align == 'center':
  1008. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  1009. elif align == 'right':
  1010. paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
  1011. elif align == 'left':
  1012. paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
  1013. # 插入图片
  1014. run = paragraph.add_run()
  1015. width = style.get('width', 10.0)
  1016. height = style.get('height', 7.0)
  1017. unit = style.get('unit', 'cm')
  1018. # 转换为磅(Word内部单位:1厘米 = 28.35磅,1英寸 = 72磅)
  1019. if unit == 'cm':
  1020. width_pt = width * 28.35
  1021. height_pt = height * 28.35
  1022. else: # inches
  1023. width_pt = width * 72
  1024. height_pt = height * 72
  1025. run.add_picture(
  1026. io.BytesIO(image_bytes),
  1027. width=Pt(width_pt),
  1028. height=Pt(height_pt)
  1029. )
  1030. except Exception as e:
  1031. # 失败时添加占位文本
  1032. p = doc.add_paragraph(f"[图片加载失败]")
  1033. p.runs[0].font.color.rgb = RGBColor(255, 0, 0)
  1034. def _set_update_fields_on_open(doc: Document):
  1035. """设置文档在 Word/WPS 中打开时自动更新所有域(包括目录和页码)"""
  1036. try:
  1037. settings = doc.settings.element
  1038. update_fields = OxmlElement('w:updateFields')
  1039. update_fields.set(qn('w:val'), 'true')
  1040. settings.append(update_fields)
  1041. except Exception as e:
  1042. print(f"警告: 设置自动更新域失败: {e}")
  1043. def _render_toc_block(doc: Document, block: dict):
  1044. """渲染目录块,创建 TOC 域并添加到文档"""
  1045. # 获取目录标题和配置
  1046. content = block.get('content', {})
  1047. if isinstance(content, dict):
  1048. toc_title = content.get('title', '目录')
  1049. else:
  1050. toc_title = '目录'
  1051. metadata = block.get('metadata', {})
  1052. toc_config = metadata.get('toc_config', {})
  1053. # 获取配置参数
  1054. levels = toc_config.get('levels', '1-3') # 默认包含1-3级标题
  1055. use_hyperlinks = toc_config.get('use_hyperlinks', True)
  1056. use_outline_levels = toc_config.get('use_outline_levels', True)
  1057. # 0. 在目录前添加分页符(让目录从新页开始)
  1058. doc.add_page_break()
  1059. # 1. 添加目录标题(可选)
  1060. if toc_title:
  1061. title_para = doc.add_paragraph(toc_title)
  1062. title_para.style = 'Normal' # 使用正文样式
  1063. title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
  1064. # 2. 插入目录域
  1065. toc_para = doc.add_paragraph()
  1066. _create_toc_field(toc_para, levels, use_hyperlinks, use_outline_levels)
  1067. # 3. 添加分节符(目录后开始新节,页码重新编号)
  1068. # 使用分节符而不是简单的分页符,这样可以:
  1069. # - 目录单独占一节(不显示页码或显示罗马数字)
  1070. # - 正文从新节开始,页码从1开始
  1071. paragraph = doc.add_paragraph()
  1072. run = paragraph.add_run()
  1073. # 插入分节符(nextPage 类型:下一页开始新节)
  1074. from docx.enum.section import WD_SECTION
  1075. paragraph._element.getparent().remove(paragraph._element) # 移除空段落
  1076. # 添加一个新节
  1077. new_section = doc.add_section(WD_SECTION.NEW_PAGE)
  1078. # 4. 设置新节的页码(如果配置要求)
  1079. if toc_config.get('use_page_numbers', True):
  1080. # 为新节(正文部分)添加页码,从1开始
  1081. _add_page_number_footer_with_restart(doc, new_section)
  1082. def _create_toc_field(paragraph, levels: str = '1-3', use_hyperlinks: bool = True, use_outline_levels: bool = True):
  1083. """在段落中创建 TOC 域代码,支持指定标题层级、超链接和大纲级别"""
  1084. run = paragraph.add_run()
  1085. # 开始域字符
  1086. fldChar_begin = OxmlElement('w:fldChar')
  1087. fldChar_begin.set(qn('w:fldCharType'), 'begin')
  1088. fldChar_begin.set(qn('w:dirty'), '1') # 标记域需要更新
  1089. # 域代码指令
  1090. # TOC 域代码格式:TOC \o "1-3" \h \z \u
  1091. # \o "1-3": 使用大纲级别 1-3
  1092. # \h: 使用超链接
  1093. # \z: 隐藏 Web 视图中的页码
  1094. # \u: 使用 Unicode
  1095. instrText = OxmlElement('w:instrText')
  1096. instrText.set(qn('xml:space'), 'preserve')
  1097. toc_code = f'TOC \\o "{levels}"'
  1098. if use_hyperlinks:
  1099. toc_code += ' \\h'
  1100. toc_code += ' \\z \\u' # 标准选项
  1101. instrText.text = toc_code
  1102. # 分隔符
  1103. fldChar_sep = OxmlElement('w:fldChar')
  1104. fldChar_sep.set(qn('w:fldCharType'), 'separate')
  1105. # 占位文字(更新域后会被真实目录替换)
  1106. placeholder_r = OxmlElement('w:r')
  1107. placeholder_rpr = OxmlElement('w:rPr')
  1108. placeholder_color = OxmlElement('w:color')
  1109. placeholder_color.set(qn('w:val'), '808080') # 灰色提示
  1110. placeholder_rpr.append(placeholder_color)
  1111. placeholder_r.append(placeholder_rpr)
  1112. # 结束域字符
  1113. fldChar_end = OxmlElement('w:fldChar')
  1114. fldChar_end.set(qn('w:fldCharType'), 'end')
  1115. # 将所有元素添加到 run
  1116. run._r.extend([fldChar_begin, instrText, fldChar_sep, placeholder_r, fldChar_end])
  1117. def _add_page_number_footer(doc: Document):
  1118. """在页脚居中插入「第 X 页 / 共 Y 页」格式的页码"""
  1119. try:
  1120. section = doc.sections[0]
  1121. section.footer_distance = Pt(20) # 页脚距底边设置
  1122. footer = section.footer
  1123. footer.is_linked_to_previous = False
  1124. # 清空默认段落并居中
  1125. para = footer.paragraphs[0]
  1126. para.clear()
  1127. para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
  1128. para.paragraph_format.space_before = Pt(8)
  1129. para.paragraph_format.space_after = Pt(15)
  1130. def add_field(run_elem, field_type):
  1131. """向 run 的 XML 元素里插入一个域"""
  1132. fldChar_b = OxmlElement('w:fldChar')
  1133. fldChar_b.set(qn('w:fldCharType'), 'begin')
  1134. instr = OxmlElement('w:instrText')
  1135. instr.set(qn('xml:space'), 'preserve')
  1136. instr.text = field_type
  1137. fldChar_s = OxmlElement('w:fldChar')
  1138. fldChar_s.set(qn('w:fldCharType'), 'separate')
  1139. fldChar_e = OxmlElement('w:fldChar')
  1140. fldChar_e.set(qn('w:fldCharType'), 'end')
  1141. run_elem.extend([fldChar_b, instr, fldChar_s, fldChar_e])
  1142. r1 = para.add_run("第 ")
  1143. r2 = para.add_run()
  1144. add_field(r2._r, ' PAGE ') # 当前页码
  1145. r3 = para.add_run(" 页 / 共 ")
  1146. r4 = para.add_run()
  1147. add_field(r4._r, ' NUMPAGES ') # 总页数
  1148. para.add_run(" 页")
  1149. except Exception as e:
  1150. print(f"警告: 添加页码页脚失败: {e}")
  1151. def _add_page_number_footer_with_restart(doc: Document, section):
  1152. """在指定节的页脚居中插入页码,并设置从 1 开始编号"""
  1153. try:
  1154. # 设置页脚距底边
  1155. section.footer_distance = Pt(20)
  1156. # 获取页脚,不链接到前面的节
  1157. footer = section.footer
  1158. footer.is_linked_to_previous = False
  1159. # 清空默认段落并居中
  1160. para = footer.paragraphs[0]
  1161. para.clear()
  1162. para.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
  1163. para.paragraph_format.space_before = Pt(8)
  1164. para.paragraph_format.space_after = Pt(15)
  1165. def add_field(run_elem, field_type):
  1166. """向 run 的 XML 元素里插入一个域"""
  1167. fldChar_b = OxmlElement('w:fldChar')
  1168. fldChar_b.set(qn('w:fldCharType'), 'begin')
  1169. instr = OxmlElement('w:instrText')
  1170. instr.set(qn('xml:space'), 'preserve')
  1171. instr.text = field_type
  1172. fldChar_s = OxmlElement('w:fldChar')
  1173. fldChar_s.set(qn('w:fldCharType'), 'separate')
  1174. fldChar_e = OxmlElement('w:fldChar')
  1175. fldChar_e.set(qn('w:fldCharType'), 'end')
  1176. run_elem.extend([fldChar_b, instr, fldChar_s, fldChar_e])
  1177. r1 = para.add_run("第 ")
  1178. r2 = para.add_run()
  1179. add_field(r2._r, ' PAGE ') # 当前页码
  1180. r3 = para.add_run(" 页 / 共 ")
  1181. r4 = para.add_run()
  1182. add_field(r4._r, ' SECTIONPAGES ') # 当前节的总页数(只计算正文,不包括目录)
  1183. para.add_run(" 页")
  1184. # 设置该节的页码从1开始
  1185. # 通过修改节属性中的 pageNum 设置
  1186. sectPr = section._sectPr
  1187. pgNumType = sectPr.find(qn('w:pgNumType'))
  1188. if pgNumType is None:
  1189. pgNumType = OxmlElement('w:pgNumType')
  1190. sectPr.append(pgNumType)
  1191. pgNumType.set(qn('w:start'), '1') # 从1开始编号
  1192. except Exception as e:
  1193. print(f"警告: 添加页码页脚(带重启)失败: {e}")
  1194. # ------------------------------------------------------------------ #
  1195. # 公共工具
  1196. # ------------------------------------------------------------------ #
  1197. def _safe_filename(name: str) -> str:
  1198. """生成安全的文件名"""
  1199. name = unicodedata.normalize("NFKC", name)
  1200. for ch in r'\/:*?"<>|':
  1201. name = name.replace(ch, "_")
  1202. return name.strip() or "document"
  1203. def _make_filename(blocks: list[dict]) -> str:
  1204. """从 blocks 中提取第一个标题或段落作为文件名并添加时间戳"""
  1205. # 查找第一个标题或段落
  1206. first_text = ""
  1207. for block in blocks:
  1208. if block['type'] in ('heading', 'paragraph'):
  1209. content = block['content']
  1210. if isinstance(content, list):
  1211. # 富文本:拼接所有片段
  1212. first_text = "".join(seg.get("text", "") for seg in content)
  1213. else:
  1214. first_text = str(content)
  1215. if first_text.strip():
  1216. break
  1217. # 提取第一行
  1218. first_line = first_text.split('\n')[0].strip()
  1219. safe = _safe_filename(first_line) if first_line else "document"
  1220. # 限制长度
  1221. if len(safe) > 50:
  1222. safe = safe[:50]
  1223. ts = int(time.time() * 1000)
  1224. return f"{safe}_{ts}"