export_service.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743
  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. from docx import Document
  10. from docx.enum.text import WD_ALIGN_PARAGRAPH
  11. from docx.oxml import OxmlElement
  12. from docx.oxml.ns import qn
  13. from docx.shared import Pt, RGBColor
  14. from lxml import etree
  15. from app.config import settings
  16. from app.core.exceptions import ExportError
  17. # ------------------------------------------------------------------ #
  18. # 样式文件加载
  19. # ------------------------------------------------------------------ #
  20. def load_style_file(style_id: Optional[str] = None) -> dict:
  21. """加载样式 JSON;style_id=None 时使用默认样式文件"""
  22. if style_id is not None:
  23. # 阶段 1 占位
  24. raise ExportError(f"样式 ID 暂不支持: {style_id}(阶段 1 功能)")
  25. path = Path(settings.default_style_file)
  26. if not path.exists():
  27. raise ExportError(f"默认样式文件不存在: {path}")
  28. try:
  29. with open(path, encoding="utf-8") as f:
  30. return json.load(f)
  31. except (OSError, json.JSONDecodeError) as exc:
  32. raise ExportError(f"样式文件解析失败: {exc}") from exc
  33. def build_style_map(style_data: dict) -> dict[str, dict]:
  34. """将样式列表转为双键映射(style_id 和 name 均可命中)"""
  35. mapping: dict[str, dict] = {}
  36. for s in style_data.get("styles", []):
  37. if s.get("style_id"):
  38. mapping[s["style_id"]] = s
  39. if s.get("name"):
  40. mapping[s["name"]] = s
  41. return mapping
  42. # ------------------------------------------------------------------ #
  43. # JSON ↔ lxml 互转
  44. # ------------------------------------------------------------------ #
  45. def dict_to_element(d: dict) -> etree._Element:
  46. """递归将字典转为 lxml Element"""
  47. elem = etree.Element(d["@tag"], attrib=dict(d.get("@attrib", {})))
  48. if d.get("#text"):
  49. elem.text = d["#text"]
  50. if d.get("#tail"):
  51. elem.tail = d["#tail"]
  52. for child_tag, child_val in d.get("@children", {}).items():
  53. items = child_val if isinstance(child_val, list) else [child_val]
  54. for item in items:
  55. if isinstance(item, dict):
  56. elem.append(dict_to_element(item))
  57. return elem
  58. def inject_styles_from_json(doc: Document, style_data: dict) -> None:
  59. """将 JSON 中所有样式的 full_xml_definition upsert 到文档 <w:styles> 节点"""
  60. styles_element = doc.styles.element
  61. for style_entry in style_data.get("styles", []):
  62. xml_def = style_entry.get("full_xml_definition")
  63. if not xml_def:
  64. continue
  65. try:
  66. new_elem = dict_to_element(xml_def)
  67. except Exception:
  68. continue
  69. style_id_key = qn("w:styleId")
  70. new_style_id = new_elem.get(style_id_key)
  71. if new_style_id:
  72. existing = styles_element.find(
  73. f'.//{qn("w:style")}[@{qn("w:styleId")}="{new_style_id}"]'
  74. )
  75. if existing is not None:
  76. styles_element.remove(existing)
  77. styles_element.append(new_elem)
  78. # 注入编号格式定义
  79. inject_numbering_from_json(doc, style_data)
  80. def inject_numbering_from_json(doc: Document, style_data: dict) -> None:
  81. """将 JSON 中的编号格式定义注入到文档
  82. 由于 python-docx 对 numbering part 的支持有限,
  83. 我们需要在保存后通过修改 ZIP 文件来注入编号格式。
  84. 这个函数主要是为了记录编号定义,实际注入在 blocks_to_docx_bytes 中完成。
  85. """
  86. # 暂时不在这里注入,而是在生成文档后通过 ZIP 修改
  87. pass
  88. def _resolve_style_id(style_map: dict, *keys: str) -> Optional[str]:
  89. """解析样式 ID"""
  90. for key in keys:
  91. entry = style_map.get(key)
  92. if entry and entry.get("style_id"):
  93. return entry["style_id"]
  94. return None
  95. def _apply_paragraph_style(para, style: dict):
  96. """应用段落级样式(对齐方式)
  97. Args:
  98. para: python-docx 段落对象
  99. style: 样式字典
  100. """
  101. # 对齐方式
  102. align = style.get('align')
  103. if align == 'center':
  104. para.alignment = WD_ALIGN_PARAGRAPH.CENTER
  105. elif align == 'right':
  106. para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
  107. elif align == 'justify':
  108. para.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
  109. elif align == 'left':
  110. para.alignment = WD_ALIGN_PARAGRAPH.LEFT
  111. def _apply_run_style(run, style: dict):
  112. """应用 run 级样式(字符级格式)
  113. Args:
  114. run: python-docx run 对象
  115. style: 样式字典
  116. """
  117. # 粗体
  118. if style.get('bold'):
  119. run.bold = True
  120. # 斜体
  121. if style.get('italic'):
  122. run.italic = True
  123. # 下划线
  124. if style.get('underline'):
  125. run.underline = True
  126. # 删除线
  127. if style.get('strike'):
  128. run.font.strike = True
  129. # 颜色
  130. if style.get('color'):
  131. try:
  132. # 移除可能的 # 前缀
  133. color = style['color'].lstrip('#')
  134. if len(color) == 6:
  135. run.font.color.rgb = RGBColor(
  136. int(color[0:2], 16),
  137. int(color[2:4], 16),
  138. int(color[4:6], 16)
  139. )
  140. except (ValueError, AttributeError):
  141. pass
  142. # 字体名称(支持中文字体 eastAsia)
  143. if style.get('font_name'):
  144. font_name = style['font_name']
  145. run.font.name = font_name
  146. # 对于中文字体,需要设置 eastAsia 属性
  147. try:
  148. r = run._element
  149. rPr = r.get_or_add_rPr()
  150. rFonts = rPr.get_or_add_rFonts()
  151. rFonts.set(qn('w:eastAsia'), font_name)
  152. except Exception:
  153. pass
  154. # 字号
  155. if style.get('font_size'):
  156. run.font.size = Pt(style['font_size'])
  157. # ------------------------------------------------------------------ #
  158. # Blocks → Word 转换
  159. # ------------------------------------------------------------------ #
  160. def blocks_to_docx_bytes(blocks: list[dict], style_map: dict, style_data: dict) -> bytes:
  161. """将 Blocks 列表转换为 Word 文档字节流
  162. Args:
  163. blocks: Block 列表
  164. style_map: 样式映射
  165. style_data: 样式数据
  166. Returns:
  167. Word 文档字节流
  168. """
  169. doc = Document()
  170. inject_styles_from_json(doc, style_data)
  171. # 检查 blocks 中是否有常用的字体和字号,用于修改 Normal 样式
  172. # 这样可以确保空行在 Word 中显示正确的字体
  173. _update_normal_style_if_needed(doc, blocks)
  174. for block in blocks:
  175. block_type = block['type']
  176. if block_type == 'heading':
  177. _render_heading_block(doc, block, style_map)
  178. elif block_type == 'paragraph':
  179. _render_paragraph_block(doc, block, style_map)
  180. elif block_type == 'table':
  181. _render_table_block(doc, block, style_map)
  182. elif block_type == 'image':
  183. _render_image_block(doc, block)
  184. # 先保存到临时缓冲区
  185. buf = io.BytesIO()
  186. doc.save(buf)
  187. # 通过 ZIP 操作注入编号格式
  188. docx_bytes = _inject_numbering_via_zip(buf.getvalue(), style_data)
  189. return docx_bytes
  190. def _update_normal_style_if_needed(doc: Document, blocks: list[dict]):
  191. """更新 Normal 样式以匹配 blocks 中最常用的字体
  192. 这样可以确保空行在 Word 中显示正确的字体和字号
  193. """
  194. # 统计段落中最常用的字体和字号
  195. font_counts = {}
  196. size_counts = {}
  197. for block in blocks:
  198. if block['type'] == 'paragraph':
  199. style = block.get('style', {})
  200. font_name = style.get('font_name')
  201. font_size = style.get('font_size')
  202. if font_name:
  203. font_counts[font_name] = font_counts.get(font_name, 0) + 1
  204. if font_size:
  205. size_counts[font_size] = size_counts.get(font_size, 0) + 1
  206. # 找到最常用的字体和字号
  207. most_common_font = max(font_counts.items(), key=lambda x: x[1])[0] if font_counts else None
  208. most_common_size = max(size_counts.items(), key=lambda x: x[1])[0] if size_counts else None
  209. # 如果找到了常用字体或字号,更新 Normal 样式
  210. if most_common_font or most_common_size:
  211. try:
  212. normal_style = doc.styles['Normal']
  213. if most_common_font:
  214. # 修改 Normal 样式的字体
  215. style_element = normal_style.element
  216. rPr = style_element.find(qn('w:rPr'))
  217. if rPr is None:
  218. rPr = OxmlElement('w:rPr')
  219. # 插入到第一个子元素之前
  220. if len(style_element):
  221. style_element.insert(0, rPr)
  222. else:
  223. style_element.append(rPr)
  224. rFonts = rPr.find(qn('w:rFonts'))
  225. if rFonts is None:
  226. rFonts = OxmlElement('w:rFonts')
  227. rPr.append(rFonts)
  228. # 设置所有字体属性
  229. rFonts.set(qn('w:ascii'), most_common_font)
  230. rFonts.set(qn('w:hAnsi'), most_common_font)
  231. rFonts.set(qn('w:eastAsia'), most_common_font)
  232. if most_common_size:
  233. # 修改 Normal 样式的字号
  234. style_element = normal_style.element
  235. rPr = style_element.find(qn('w:rPr'))
  236. if rPr is None:
  237. rPr = OxmlElement('w:rPr')
  238. if len(style_element):
  239. style_element.insert(0, rPr)
  240. else:
  241. style_element.append(rPr)
  242. # 删除旧的字号元素
  243. old_sz = rPr.find(qn('w:sz'))
  244. if old_sz is not None:
  245. rPr.remove(old_sz)
  246. old_szCs = rPr.find(qn('w:szCs'))
  247. if old_szCs is not None:
  248. rPr.remove(old_szCs)
  249. # 添加新的字号元素
  250. sz = OxmlElement('w:sz')
  251. sz.set(qn('w:val'), str(int(most_common_size * 2))) # Word 使用半磅
  252. rPr.append(sz)
  253. szCs = OxmlElement('w:szCs')
  254. szCs.set(qn('w:val'), str(int(most_common_size * 2)))
  255. rPr.append(szCs)
  256. except Exception:
  257. # 如果修改样式失败,继续(不影响文档生成)
  258. pass
  259. def _inject_numbering_via_zip(docx_bytes: bytes, style_data: dict) -> bytes:
  260. """通过 ZIP 操作注入编号格式到 Word 文档
  261. Args:
  262. docx_bytes: 原始 Word 文档字节流
  263. style_data: 样式数据(包含 numbering 定义)
  264. Returns:
  265. 注入编号格式后的 Word 文档字节流
  266. """
  267. numbering_def = style_data.get("numbering")
  268. if not numbering_def:
  269. # 没有编号定义,直接返回原文档
  270. return docx_bytes
  271. try:
  272. from zipfile import ZipFile
  273. from lxml import etree
  274. # 读取原文档
  275. input_buf = io.BytesIO(docx_bytes)
  276. output_buf = io.BytesIO()
  277. with ZipFile(input_buf, 'r') as zip_read:
  278. with ZipFile(output_buf, 'w') as zip_write:
  279. # 复制所有文件
  280. for item in zip_read.infolist():
  281. data = zip_read.read(item.filename)
  282. # 跳过 numbering.xml,我们会重新写入
  283. if item.filename == 'word/numbering.xml':
  284. continue
  285. zip_write.writestr(item, data)
  286. # 将 JSON 格式的编号定义转换为 XML
  287. numbering_element = dict_to_element(numbering_def)
  288. numbering_xml = etree.tostring(
  289. numbering_element,
  290. encoding='UTF-8',
  291. xml_declaration=True,
  292. standalone=True
  293. )
  294. # 写入 numbering.xml
  295. zip_write.writestr('word/numbering.xml', numbering_xml)
  296. # 确保 _rels/document.xml.rels 中有 numbering 的关系
  297. # 读取 document.xml.rels
  298. try:
  299. rels_data = zip_read.read('word/_rels/document.xml.rels')
  300. rels_root = etree.fromstring(rels_data)
  301. # 检查是否已有 numbering 关系
  302. ns = {'r': 'http://schemas.openxmlformats.org/package/2006/relationships'}
  303. numbering_rels = rels_root.xpath(
  304. '//r:Relationship[@Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"]',
  305. namespaces=ns
  306. )
  307. if not numbering_rels:
  308. # 添加 numbering 关系
  309. from docx.oxml.ns import qn
  310. rel_elem = etree.SubElement(rels_root, qn('r:Relationship'))
  311. # 找到最大的 rId
  312. existing_ids = [int(r.get('Id')[3:]) for r in rels_root.findall(qn('r:Relationship')) if r.get('Id', '').startswith('rId')]
  313. next_id = max(existing_ids) + 1 if existing_ids else 1
  314. rel_elem.set('Id', f'rId{next_id}')
  315. rel_elem.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering')
  316. rel_elem.set('Target', 'numbering.xml')
  317. # 写回 rels 文件
  318. rels_xml = etree.tostring(rels_root, encoding='UTF-8', xml_declaration=True)
  319. zip_write.writestr('word/_rels/document.xml.rels', rels_xml)
  320. except KeyError:
  321. # 如果没有 rels 文件,忽略
  322. pass
  323. return output_buf.getvalue()
  324. except Exception as e:
  325. # 如果注入失败,返回原文档
  326. print(f"警告: 通过 ZIP 注入编号格式失败: {e}")
  327. return docx_bytes
  328. def _render_heading_block(doc: Document, block: dict, style_map: dict):
  329. """渲染标题块(支持编号格式和自定义样式)"""
  330. level = block['level']
  331. content = block['content']
  332. style_name = block.get('word_style', f'Heading {level}')
  333. block_style = block.get('style', {})
  334. # 创建段落
  335. para = doc.add_paragraph()
  336. # 应用 Word 样式
  337. style_id = _resolve_style_id(style_map, style_name, f'Heading {level}')
  338. if style_id:
  339. try:
  340. para.style = doc.styles[style_id]
  341. except KeyError:
  342. para.style = f'Heading {level}'
  343. else:
  344. para.style = f'Heading {level}'
  345. # 应用 Block 级自定义样式(段落级)
  346. _apply_paragraph_style(para, block_style)
  347. # 渲染内容
  348. if isinstance(content, list):
  349. # 富文本:应用 run 级样式
  350. _render_rich_text(para, content, block_style)
  351. else:
  352. # 纯文本:应用 block 级样式到 run
  353. run = para.add_run(str(content))
  354. _apply_run_style(run, block_style)
  355. # 尝试应用编号格式(如果样式中包含编号定义)
  356. try:
  357. # 检查样式是否有编号定义
  358. style_element = para.style.element
  359. if style_element is not None:
  360. from docx.oxml.ns import qn
  361. # 查找样式中的编号属性
  362. pPr = style_element.find(qn('w:pPr'))
  363. if pPr is not None:
  364. numPr = pPr.find(qn('w:numPr'))
  365. if numPr is not None:
  366. # 样式中有编号定义,复制到段落
  367. para_pPr = para._element.get_or_add_pPr()
  368. # 移除可能存在的旧编号属性
  369. old_numPr = para_pPr.find(qn('w:numPr'))
  370. if old_numPr is not None:
  371. para_pPr.remove(old_numPr)
  372. # 复制编号属性
  373. import copy
  374. para_pPr.append(copy.deepcopy(numPr))
  375. except Exception as e:
  376. # 如果应用编号失败,继续(标题仍然会显示,只是没有编号)
  377. pass
  378. def _render_paragraph_block(doc: Document, block: dict, style_map: dict):
  379. """渲染段落块(支持富文本和自定义样式)"""
  380. content = block['content']
  381. style_name = block.get('word_style', 'Normal')
  382. block_style = block.get('style', {})
  383. para = doc.add_paragraph()
  384. # 应用 Word 样式
  385. style_id = _resolve_style_id(style_map, style_name, 'Normal')
  386. if style_id:
  387. try:
  388. para.style = doc.styles[style_id]
  389. except KeyError:
  390. para.style = 'Normal'
  391. else:
  392. para.style = 'Normal'
  393. # 应用 Block 级自定义样式(段落级)
  394. _apply_paragraph_style(para, block_style)
  395. # 渲染内容
  396. if isinstance(content, list):
  397. # 富文本:应用 run 级样式
  398. _render_rich_text(para, content, block_style)
  399. else:
  400. # 纯文本或空内容
  401. # 即使是空内容,如果有 block 级样式(字体、字号等),也需要添加空 run 来保存样式
  402. # 这样当用户在 Word 中输入文本时,会自动应用这些样式
  403. run = para.add_run(str(content) if content else '')
  404. _apply_run_style(run, block_style)
  405. def _render_rich_text(para, segments: list, block_style: dict = None):
  406. """渲染富文本格式
  407. Args:
  408. para: python-docx 段落对象
  409. segments: 富文本片段列表,每个片段包含 text 和 style
  410. block_style: Block 级样式,作为默认样式(可选)
  411. """
  412. for seg in segments:
  413. text = seg.get('text', '')
  414. seg_style = seg.get('style', {})
  415. run = para.add_run(text)
  416. # 合并样式:block_style 作为默认,seg_style 覆盖
  417. merged_style = {}
  418. if block_style:
  419. merged_style.update(block_style)
  420. merged_style.update(seg_style)
  421. # 应用合并后的样式
  422. _apply_run_style(run, merged_style)
  423. def _render_table_block(doc: Document, block: dict, style_map: dict):
  424. """渲染表格块(支持合并单元格、列宽、行高等)"""
  425. table_data = block['content']
  426. if isinstance(table_data, str):
  427. try:
  428. table_data = json.loads(table_data)
  429. except json.JSONDecodeError:
  430. return
  431. rows = table_data.get('rows', [])
  432. if not rows:
  433. return
  434. # 使用 col_widths 确定真实列数(而不是第一行的单元格数)
  435. col_widths = table_data.get('col_widths', [])
  436. if col_widths:
  437. num_cols = len(col_widths)
  438. else:
  439. # 回退:扫描所有行,找到最大的列索引
  440. num_cols = 0
  441. for row_data in rows:
  442. col_index = 0
  443. for cell_data in row_data.get('cells', []):
  444. colspan = cell_data.get('colspan', 1)
  445. col_index += colspan
  446. num_cols = max(num_cols, col_index)
  447. if num_cols == 0:
  448. return
  449. num_rows = len(rows)
  450. # 创建表格
  451. table = doc.add_table(rows=num_rows, cols=num_cols)
  452. # 应用表格样式
  453. table_style = block.get('word_style', 'Table Grid')
  454. try:
  455. table.style = table_style
  456. except KeyError:
  457. table.style = 'Table Grid'
  458. # 设置列宽
  459. if col_widths:
  460. for col_idx, width in enumerate(col_widths):
  461. if col_idx < len(table.columns):
  462. table.columns[col_idx].width = Pt(width)
  463. # 填充内容并处理合并单元格
  464. merge_map = {} # {(row, col): (end_row, end_col)} 记录合并区域
  465. for r_idx, row_data in enumerate(rows):
  466. # 设置行高
  467. row_height = row_data.get('height')
  468. if row_height:
  469. table.rows[r_idx].height = Pt(row_height)
  470. cells_data = row_data.get('cells', [])
  471. col_offset = 0 # 当前列偏移(考虑 colspan)
  472. for cell_data in cells_data:
  473. # 跳过被合并的单元格(rowspan=0 表示这个单元格被上面的单元格合并了)
  474. rowspan = cell_data.get('rowspan', 1)
  475. if rowspan == 0:
  476. col_offset += 1
  477. continue
  478. colspan = cell_data.get('colspan', 1)
  479. # 确保不越界
  480. if col_offset >= num_cols:
  481. break
  482. # 获取起始单元格
  483. start_cell = table.rows[r_idx].cells[col_offset]
  484. # 处理合并单元格
  485. if colspan > 1 or rowspan > 1:
  486. # 计算结束位置
  487. end_col = min(col_offset + colspan - 1, num_cols - 1)
  488. end_row = min(r_idx + rowspan - 1, num_rows - 1)
  489. # 合并单元格
  490. if end_col > col_offset or end_row > r_idx:
  491. try:
  492. end_cell = table.rows[end_row].cells[end_col]
  493. start_cell.merge(end_cell)
  494. merge_map[(r_idx, col_offset)] = (end_row, end_col)
  495. except Exception:
  496. pass # 合并失败,继续
  497. # 设置单元格宽度(如果有)
  498. cell_width = cell_data.get('width')
  499. if cell_width:
  500. try:
  501. start_cell.width = Pt(cell_width)
  502. except Exception:
  503. pass
  504. # 填充单元格内容
  505. cell_text = cell_data.get('text', '')
  506. cell_style = cell_data.get('style', {})
  507. # 清空默认段落
  508. start_cell.text = ''
  509. para = start_cell.paragraphs[0]
  510. # 应用单元格段落级样式(对齐)
  511. _apply_paragraph_style(para, cell_style)
  512. # 渲染单元格内容(支持富文本)
  513. if isinstance(cell_text, list):
  514. # 富文本格式
  515. _render_rich_text(para, cell_text, cell_style)
  516. else:
  517. # 纯文本格式
  518. run = para.add_run(str(cell_text))
  519. # 应用单元格 run 级样式
  520. _apply_run_style(run, cell_style)
  521. # 更新列偏移
  522. col_offset += colspan
  523. def _render_image_block(doc: Document, block: dict):
  524. """渲染图片块(支持 Base64 Data URL)"""
  525. content = block['content']
  526. style = block.get('style', {})
  527. # 只处理 Data URL
  528. if not isinstance(content, str) or not content.startswith('data:'):
  529. return
  530. try:
  531. # 解析 data:image/png;base64,xxxxx
  532. if ',' not in content:
  533. return
  534. header, b64_data = content.split(',', 1)
  535. image_bytes = base64.b64decode(b64_data)
  536. # 创建段落并设置对齐
  537. paragraph = doc.add_paragraph()
  538. align = style.get('align', 'left')
  539. if align == 'center':
  540. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  541. elif align == 'right':
  542. paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
  543. # 插入图片
  544. run = paragraph.add_run()
  545. width = style.get('width', 10.0)
  546. height = style.get('height', 7.0)
  547. unit = style.get('unit', 'cm')
  548. # 转换为磅(Word内部单位:1厘米 = 28.35磅,1英寸 = 72磅)
  549. if unit == 'cm':
  550. width_pt = width * 28.35
  551. height_pt = height * 28.35
  552. else: # inches
  553. width_pt = width * 72
  554. height_pt = height * 72
  555. run.add_picture(
  556. io.BytesIO(image_bytes),
  557. width=Pt(width_pt),
  558. height=Pt(height_pt)
  559. )
  560. except Exception as e:
  561. # 失败时添加占位文本
  562. p = doc.add_paragraph(f"[图片加载失败]")
  563. p.runs[0].font.color.rgb = RGBColor(255, 0, 0)
  564. # ------------------------------------------------------------------ #
  565. # 公共工具
  566. # ------------------------------------------------------------------ #
  567. def _safe_filename(name: str) -> str:
  568. """生成安全的文件名"""
  569. name = unicodedata.normalize("NFKC", name)
  570. for ch in r'\/:*?"<>|':
  571. name = name.replace(ch, "_")
  572. return name.strip() or "document"
  573. def _make_filename(blocks: list[dict]) -> str:
  574. """从 blocks 中提取第一个标题或段落作为文件名 + 时间戳
  575. Args:
  576. blocks: Block 列表
  577. Returns:
  578. 文件名(不含扩展名)
  579. """
  580. # 查找第一个标题或段落
  581. first_text = ""
  582. for block in blocks:
  583. if block['type'] in ('heading', 'paragraph'):
  584. content = block['content']
  585. if isinstance(content, list):
  586. # 富文本:拼接所有片段
  587. first_text = "".join(seg.get("text", "") for seg in content)
  588. else:
  589. first_text = str(content)
  590. if first_text.strip():
  591. break
  592. # 提取第一行
  593. first_line = first_text.split('\n')[0].strip()
  594. safe = _safe_filename(first_line) if first_line else "document"
  595. # 限制长度
  596. if len(safe) > 50:
  597. safe = safe[:50]
  598. ts = int(time.time() * 1000)
  599. return f"{safe}_{ts}"