export_service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. """export_service.py — 将文档 Markdown 内容转换为 .doc 文件并返回永久下载链接。"""
  2. import base64
  3. import io
  4. import json
  5. import time
  6. import unicodedata
  7. from datetime import date
  8. from pathlib import Path
  9. from typing import Optional
  10. import mistune
  11. from docx import Document
  12. from docx.enum.text import WD_ALIGN_PARAGRAPH
  13. from docx.oxml import OxmlElement
  14. from docx.oxml.ns import qn
  15. from docx.shared import Pt, RGBColor
  16. from lxml import etree
  17. from app.config import settings
  18. from app.core.exceptions import ExportError
  19. # ------------------------------------------------------------------ #
  20. # 样式文件加载
  21. # ------------------------------------------------------------------ #
  22. def load_style_file(style_id: Optional[str] = None) -> dict:
  23. """加载样式 JSON;style_id=None 时使用默认样式文件。"""
  24. if style_id is not None:
  25. # 阶段 1 占位
  26. raise ExportError(f"样式 ID 暂不支持: {style_id}(阶段 1 功能)")
  27. path = Path(settings.default_style_file)
  28. if not path.exists():
  29. raise ExportError(f"默认样式文件不存在: {path}")
  30. try:
  31. with open(path, encoding="utf-8") as f:
  32. return json.load(f)
  33. except (OSError, json.JSONDecodeError) as exc:
  34. raise ExportError(f"样式文件解析失败: {exc}") from exc
  35. def build_style_map(style_data: dict) -> dict[str, dict]:
  36. """将样式列表转为双键映射(style_id 和 name 均可命中)。"""
  37. mapping: dict[str, dict] = {}
  38. for s in style_data.get("styles", []):
  39. if s.get("style_id"):
  40. mapping[s["style_id"]] = s
  41. if s.get("name"):
  42. mapping[s["name"]] = s
  43. return mapping
  44. # ------------------------------------------------------------------ #
  45. # JSON ↔ lxml 互转
  46. # ------------------------------------------------------------------ #
  47. def dict_to_element(d: dict) -> etree._Element:
  48. elem = etree.Element(d["@tag"], attrib=dict(d.get("@attrib", {})))
  49. if d.get("#text"):
  50. elem.text = d["#text"]
  51. if d.get("#tail"):
  52. elem.tail = d["#tail"]
  53. for child_tag, child_val in d.get("@children", {}).items():
  54. items = child_val if isinstance(child_val, list) else [child_val]
  55. for item in items:
  56. if isinstance(item, dict):
  57. elem.append(dict_to_element(item))
  58. return elem
  59. def inject_styles_from_json(doc: Document, style_data: dict) -> None:
  60. """将 JSON 中所有样式的 full_xml_definition upsert 到文档 <w:styles> 节点。"""
  61. styles_element = doc.styles.element
  62. for style_entry in style_data.get("styles", []):
  63. xml_def = style_entry.get("full_xml_definition")
  64. if not xml_def:
  65. continue
  66. try:
  67. new_elem = dict_to_element(xml_def)
  68. except Exception:
  69. continue
  70. style_id_key = qn("w:styleId")
  71. new_style_id = new_elem.get(style_id_key)
  72. if new_style_id:
  73. existing = styles_element.find(
  74. f'.//{qn("w:style")}[@{qn("w:styleId")}="{new_style_id}"]'
  75. )
  76. if existing is not None:
  77. styles_element.remove(existing)
  78. styles_element.append(new_elem)
  79. def inject_numbering_from_json(doc: Document, style_data: dict) -> None:
  80. """
  81. 将 JSON 中的 numbering 定义注入到文档中。
  82. 这样可以恢复标题的编号格式。
  83. """
  84. numbering_def = style_data.get("numbering")
  85. if not numbering_def:
  86. return # 没有编号定义,跳过
  87. try:
  88. # 将字典转换为 lxml Element
  89. numbering_elem = dict_to_element(numbering_def)
  90. # 获取文档的 numbering part
  91. # python-docx 可能没有 numbering part,需要创建
  92. if doc.part.numbering_part is None:
  93. # 创建 numbering part
  94. from docx.opc.constants import CONTENT_TYPE as CT
  95. from docx.opc.part import XmlPart
  96. from docx.opc.packuri import PackURI
  97. numbering_part = XmlPart(
  98. PackURI('/word/numbering.xml'),
  99. CT.WML_NUMBERING,
  100. numbering_elem,
  101. doc.part.package
  102. )
  103. doc.part.relate_to(numbering_part, 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering')
  104. else:
  105. # 替换现有的 numbering part 内容
  106. doc.part.numbering_part._element = numbering_elem
  107. except Exception as e:
  108. # 编号注入失败,不影响其他功能
  109. print(f"警告: 编号格式注入失败: {e}")
  110. def _resolve_style_id(style_map: dict, *keys: str) -> Optional[str]:
  111. for key in keys:
  112. entry = style_map.get(key)
  113. if entry and entry.get("style_id"):
  114. return entry["style_id"]
  115. return None
  116. # ------------------------------------------------------------------ #
  117. # Markdown → python-docx 渲染器
  118. # ------------------------------------------------------------------ #
  119. class DocxRenderer(mistune.BaseRenderer):
  120. _HEADING_ALIASES = {i: [f"Heading {i}", f"heading {i}"] for i in range(1, 7)}
  121. def __init__(self, style_map: dict, style_data: dict) -> None:
  122. super().__init__()
  123. self.style_map = style_map
  124. self.doc = Document()
  125. inject_styles_from_json(self.doc, style_data)
  126. inject_numbering_from_json(self.doc, style_data) # 新增:注入编号格式
  127. self._normal_id: Optional[str] = _resolve_style_id(style_map, "Normal", "1")
  128. self.pending_style: Optional[str] = None # 待应用的样式名
  129. self.pending_image_style: Optional[dict] = None # 待应用的图片样式
  130. def _get_style_by_id(self, style_id: str):
  131. for style in self.doc.styles:
  132. if style.style_id == style_id:
  133. return style
  134. raise KeyError(style_id)
  135. def _apply_numbering_from_style(self, paragraph):
  136. """
  137. 从段落的样式中提取编号属性并应用到段落。
  138. 这是必需的,因为 python-docx 不会自动继承样式的编号格式。
  139. """
  140. if not paragraph.style:
  141. return
  142. try:
  143. # 获取样式的 XML 元素
  144. style_elem = paragraph.style.element
  145. # 查找样式中的编号定义
  146. pPr = style_elem.find(qn('w:pPr'))
  147. if pPr is None:
  148. return
  149. numPr = pPr.find(qn('w:numPr'))
  150. if numPr is None:
  151. return
  152. # 复制编号属性到段落
  153. para_pPr = paragraph._p.get_or_add_pPr()
  154. # 移除现有的 numPr(如果有)
  155. existing_numPr = para_pPr.find(qn('w:numPr'))
  156. if existing_numPr is not None:
  157. para_pPr.remove(existing_numPr)
  158. # 深度复制样式的 numPr 到段落
  159. from copy import deepcopy
  160. new_numPr = deepcopy(numPr)
  161. para_pPr.append(new_numPr)
  162. except Exception:
  163. # 编号应用失败,不影响其他功能
  164. pass
  165. @staticmethod
  166. def _extract_text(children: list) -> str:
  167. parts: list[str] = []
  168. for child in children:
  169. if isinstance(child, dict):
  170. if child.get("raw"):
  171. parts.append(child["raw"])
  172. if child.get("children"):
  173. parts.append(DocxRenderer._extract_text(child["children"]))
  174. return "".join(parts)
  175. def heading(self, token: dict, state) -> str:
  176. level = token["attrs"]["level"]
  177. text = self._extract_text(token.get("children", []))
  178. aliases = self._HEADING_ALIASES.get(level, [f"Heading {level}"])
  179. style_id = _resolve_style_id(self.style_map, *aliases)
  180. if style_id:
  181. para = self.doc.add_paragraph(text)
  182. try:
  183. para.style = self._get_style_by_id(style_id)
  184. # 应用样式后,复制编号属性到段落
  185. self._apply_numbering_from_style(para)
  186. except KeyError:
  187. pass
  188. else:
  189. self.doc.add_heading(text, level=level)
  190. return ""
  191. def paragraph(self, token: dict, state) -> str:
  192. # 检查是否包含图片
  193. children = token.get("children", [])
  194. has_image = any(child.get("type") == "image" for child in children)
  195. if has_image:
  196. # 如果包含图片,直接调用 image 处理
  197. for child in children:
  198. if child.get("type") == "image":
  199. self.image(child, state)
  200. return ""
  201. p = self.doc.add_paragraph()
  202. # 尝试应用待定样式
  203. style_applied = False
  204. if self.pending_style:
  205. style_id = _resolve_style_id(self.style_map, self.pending_style)
  206. if style_id:
  207. try:
  208. p.style = self._get_style_by_id(style_id)
  209. style_applied = True
  210. except KeyError:
  211. pass # 样式不存在,静默忽略
  212. self.pending_style = None
  213. # 如果没有应用样式,使用 Normal
  214. if not style_applied and self._normal_id:
  215. try:
  216. p.style = self._get_style_by_id(self._normal_id)
  217. except Exception:
  218. pass
  219. self._render_inline_children(p, token.get("children", []))
  220. return ""
  221. def html(self, token: dict, state) -> str:
  222. """处理内联 HTML 注释(表格单元格中的样式标记)"""
  223. raw = token.get("raw", "")
  224. if "<!-- style:" in raw and "-->" in raw:
  225. try:
  226. start = raw.index("<!-- style:") + 11
  227. end = raw.index("-->", start)
  228. self.pending_style = raw[start:end].strip()
  229. except (ValueError, IndexError):
  230. pass
  231. return ""
  232. def block_html(self, token: dict, state) -> str:
  233. """处理块级 HTML(样式注释+文本在同一行)"""
  234. raw = token.get("raw", "")
  235. # 处理图片样式注释
  236. if "<!-- img-style:" in raw and "-->" in raw:
  237. try:
  238. start = raw.index("{")
  239. end = raw.rindex("}") + 1
  240. self.pending_image_style = json.loads(raw[start:end])
  241. except (ValueError, json.JSONDecodeError):
  242. pass
  243. return ""
  244. # 处理文本样式注释
  245. if "<!-- style:" in raw and "-->" in raw:
  246. try:
  247. # 提取样式名
  248. style_start = raw.index("<!-- style:") + 11
  249. style_end = raw.index("-->", style_start)
  250. style_name = raw[style_start:style_end].strip()
  251. # 提取文本(注释后面的内容)
  252. text_start = style_end + 3 # "-->".length = 3
  253. text = raw[text_start:].strip()
  254. # 创建段落并应用样式
  255. p = self.doc.add_paragraph(text)
  256. style_id = _resolve_style_id(self.style_map, style_name)
  257. if style_id:
  258. try:
  259. p.style = self._get_style_by_id(style_id)
  260. # 应用样式后,复制编号属性到段落
  261. self._apply_numbering_from_style(p)
  262. except KeyError:
  263. pass # 样式不存在,使用默认
  264. except (ValueError, IndexError):
  265. # 解析失败,当作普通 HTML 处理(忽略)
  266. pass
  267. return ""
  268. def blank_line(self, token: dict, state) -> str:
  269. return ""
  270. def image(self, token: dict, state) -> str:
  271. """处理图片 token(支持 Base64 Data URL)"""
  272. url = token['attrs']['url']
  273. alt = token['attrs'].get('alt', '图片')
  274. # 只处理 Data URL
  275. if not url.startswith('data:'):
  276. return ""
  277. try:
  278. # 解析 data:image/png;base64,xxxxx
  279. if ',' not in url:
  280. return ""
  281. header, b64_data = url.split(',', 1)
  282. image_bytes = base64.b64decode(b64_data)
  283. # 获取样式(来自前面的 HTML 注释)
  284. style = self.pending_image_style or {}
  285. self.pending_image_style = None
  286. # 创建段落并设置对齐
  287. paragraph = self.doc.add_paragraph()
  288. # 应用段落样式:优先使用保存的样式,否则使用 Normal
  289. para_style = style.get('para_style', 'Normal')
  290. style_id = _resolve_style_id(self.style_map, para_style)
  291. if style_id:
  292. try:
  293. paragraph.style = self._get_style_by_id(style_id)
  294. except KeyError:
  295. # 如果样式不存在,回退到 Normal
  296. if self._normal_id:
  297. try:
  298. paragraph.style = self._get_style_by_id(self._normal_id)
  299. except Exception:
  300. pass
  301. elif self._normal_id:
  302. # 如果没有找到样式 ID,使用 Normal
  303. try:
  304. paragraph.style = self._get_style_by_id(self._normal_id)
  305. except Exception:
  306. pass
  307. align = style.get('align', 'left')
  308. if align == 'center':
  309. paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
  310. elif align == 'right':
  311. paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
  312. # 插入图片
  313. run = paragraph.add_run()
  314. width = style.get('width', 10.0)
  315. height = style.get('height', 7.0)
  316. unit = style.get('unit', 'cm')
  317. # 转换为磅(Word内部单位:1厘米 = 28.35磅,1英寸 = 72磅)
  318. if unit == 'cm':
  319. width_pt = width * 28.35
  320. height_pt = height * 28.35
  321. else: # inches
  322. width_pt = width * 72
  323. height_pt = height * 72
  324. run.add_picture(
  325. io.BytesIO(image_bytes),
  326. width=Pt(width_pt),
  327. height=Pt(height_pt)
  328. )
  329. except Exception as e:
  330. # 失败时添加占位文本
  331. p = self.doc.add_paragraph(f"[图片加载失败: {alt}]")
  332. if self._normal_id:
  333. try:
  334. p.style = self._get_style_by_id(self._normal_id)
  335. except Exception:
  336. pass
  337. p.runs[0].font.color.rgb = RGBColor(255, 0, 0)
  338. return ""
  339. def thematic_break(self, token: dict, state) -> str:
  340. p = self.doc.add_paragraph()
  341. pPr = p._p.get_or_add_pPr()
  342. pBdr = OxmlElement("w:pBdr")
  343. bottom = OxmlElement("w:bottom")
  344. bottom.set(qn("w:val"), "single")
  345. bottom.set(qn("w:sz"), "6")
  346. bottom.set(qn("w:space"), "1")
  347. bottom.set(qn("w:color"), "auto")
  348. pBdr.append(bottom)
  349. pPr.append(pBdr)
  350. return ""
  351. def block_quote(self, token: dict, state) -> str:
  352. for child in token.get("children", []):
  353. text = self._extract_text(child.get("children", []))
  354. quote_id = _resolve_style_id(self.style_map, "Quote", "Quote Char")
  355. p = self.doc.add_paragraph(text)
  356. if quote_id:
  357. try:
  358. p.style = self._get_style_by_id(quote_id)
  359. except Exception:
  360. p.style = "Quote"
  361. else:
  362. p.style = "Quote"
  363. return ""
  364. def block_code(self, token: dict, state) -> str:
  365. p = self.doc.add_paragraph(style="No Spacing")
  366. run = p.add_run(token.get("raw", ""))
  367. run.font.name = "Courier New"
  368. run.font.size = Pt(10)
  369. run.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
  370. return ""
  371. def list(self, token: dict, state) -> str:
  372. ordered = token["attrs"].get("ordered", False)
  373. depth = token["attrs"].get("depth", 0) # mistune的depth从0开始
  374. self._render_list_items(token.get("children", []), ordered, depth + 1) # 转换为从1开始
  375. return ""
  376. def _render_list_items(self, items: list, ordered: bool, depth: int) -> None:
  377. for item in items:
  378. for child in item.get("children", []):
  379. if child["type"] == "list":
  380. self._render_list_items(
  381. child.get("children", []),
  382. child["attrs"].get("ordered", False),
  383. depth + 1,
  384. )
  385. else:
  386. # 使用内联格式渲染(保留粗体、斜体等)
  387. # 注意:depth=1 用 "List Bullet",depth=2用"List Bullet 2"
  388. if ordered:
  389. if depth == 1:
  390. style = "List Number"
  391. elif depth == 2:
  392. style = "List Number 2"
  393. else:
  394. style = "List Number 3"
  395. else:
  396. if depth == 1:
  397. style = "List Bullet"
  398. elif depth == 2:
  399. style = "List Bullet 2"
  400. else:
  401. style = "List Bullet 3"
  402. para = self.doc.add_paragraph(style=style)
  403. self._render_inline_children(para, child.get("children", [child]))
  404. def table(self, token: dict, state) -> str:
  405. children = token.get("children", [])
  406. head_token = next((c for c in children if c["type"] == "table_head"), None)
  407. body_token = next((c for c in children if c["type"] == "table_body"), None)
  408. head_cells = head_token.get("children", []) if head_token else []
  409. cols = len(head_cells)
  410. if cols == 0:
  411. return ""
  412. body_rows: list[list[dict]] = []
  413. if body_token:
  414. for row in body_token.get("children", []):
  415. if row["type"] == "table_row":
  416. body_rows.append(row.get("children", []))
  417. tbl = self.doc.add_table(rows=1 + len(body_rows), cols=cols)
  418. tbl.style = "Table Grid"
  419. # 表头行(保留内联格式,不强制加粗)
  420. for c, cell_token in enumerate(head_cells):
  421. cell = tbl.rows[0].cells[c]
  422. # 清空默认段落
  423. cell.text = ""
  424. para = cell.paragraphs[0]
  425. # 渲染内联内容(样式由 _render_inline_children 处理)
  426. self._render_inline_children(para, cell_token.get("children", []))
  427. # 数据行(保留内联格式)
  428. for r, row_cells in enumerate(body_rows):
  429. for c, cell_token in enumerate(row_cells):
  430. if c >= cols:
  431. break
  432. cell = tbl.rows[r + 1].cells[c]
  433. cell.text = ""
  434. para = cell.paragraphs[0]
  435. # 渲染内联内容(样式注释会在 _render_inline_children 中处理)
  436. self._render_inline_children(para, cell_token.get("children", []))
  437. return ""
  438. def _render_inline_children(self, paragraph, children: list) -> None:
  439. """渲染内联子元素,处理粗体、斜体等格式"""
  440. for child in children:
  441. ctype = child.get("type", "")
  442. raw = child.get("raw", "")
  443. if ctype == "inline_html":
  444. # 处理图片样式注释
  445. if "<!-- img-style:" in raw and "-->" in raw:
  446. try:
  447. start = raw.index("{")
  448. end = raw.rindex("}") + 1
  449. self.pending_image_style = json.loads(raw[start:end])
  450. except (ValueError, json.JSONDecodeError):
  451. pass
  452. # 注释不输出
  453. continue
  454. # 处理文本样式注释
  455. if "<!-- style:" in raw and "-->" in raw:
  456. try:
  457. start = raw.index("<!-- style:") + 11
  458. end = raw.index("-->", start)
  459. self.pending_style = raw[start:end].strip()
  460. except (ValueError, IndexError):
  461. pass
  462. # 注释不输出
  463. continue
  464. elif ctype == "text":
  465. # 应用待定样式(来自前一个 inline_html)
  466. if self.pending_style:
  467. style_id = _resolve_style_id(self.style_map, self.pending_style)
  468. if style_id:
  469. try:
  470. paragraph.style = self._get_style_by_id(style_id)
  471. except KeyError:
  472. pass
  473. self.pending_style = None
  474. paragraph.add_run(raw)
  475. elif ctype == "strong":
  476. paragraph.add_run(self._extract_text(child.get("children", []))).bold = True
  477. elif ctype == "emphasis":
  478. paragraph.add_run(self._extract_text(child.get("children", []))).italic = True
  479. elif ctype == "strikethrough":
  480. paragraph.add_run(self._extract_text(child.get("children", []))).font.strike = True
  481. elif ctype == "codespan":
  482. run = paragraph.add_run(raw)
  483. run.font.name = "Courier New"
  484. run.font.size = Pt(10)
  485. elif ctype == "linebreak":
  486. paragraph.add_run().add_break()
  487. elif ctype == "softlinebreak":
  488. paragraph.add_run(" ")
  489. elif ctype == "image":
  490. # 处理内联图片
  491. # 注意:这里的图片是在段落中的,需要特殊处理
  492. # 我们需要跳过这个段落,让 image() 方法来处理
  493. pass
  494. else:
  495. sub = child.get("children")
  496. if sub:
  497. self._render_inline_children(paragraph, sub)
  498. elif raw:
  499. paragraph.add_run(raw)
  500. def render_token(self, token: dict, state) -> str:
  501. func = getattr(self, token["type"], None)
  502. if func:
  503. return func(token, state)
  504. for child in token.get("children", []):
  505. self.render_token(child, state)
  506. return ""
  507. def __call__(self, tokens: list, state) -> str:
  508. for token in tokens:
  509. self.render_token(token, state)
  510. return ""
  511. # ------------------------------------------------------------------ #
  512. # 公共工具
  513. # ------------------------------------------------------------------ #
  514. def markdown_to_docx_bytes(content: str, style_map: dict, style_data: dict) -> bytes:
  515. renderer = DocxRenderer(style_map=style_map, style_data=style_data)
  516. md = mistune.create_markdown(
  517. renderer=renderer,
  518. plugins=["table", "strikethrough", "url"],
  519. )
  520. md(content)
  521. buf = io.BytesIO()
  522. renderer.doc.save(buf)
  523. return buf.getvalue()
  524. def _safe_filename(name: str) -> str:
  525. name = unicodedata.normalize("NFKC", name)
  526. for ch in r'\/:*?"<>|':
  527. name = name.replace(ch, "_")
  528. return name.strip() or "document"
  529. def _make_filename(content: str) -> str:
  530. """取内容第一行文本 + 毫秒时间戳,生成文件名(不含扩展名)。"""
  531. first_line = content.lstrip().split("\n")[0].lstrip("#").strip()
  532. safe = _safe_filename(first_line) if first_line else "document"
  533. ts = int(time.time() * 1000)
  534. return f"{safe}_{ts}"