Coverage for src\genesis\writer\docx_injector.py: 97%

132 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-26 14:20 +0800

1"""docx 注入原型(T17,OV8)。 

2 

3背景:design.md §6.6/6.7 定义 docxtpl 占位符注入 + 格式精度要求,但完整 Writer 

4未实现。OV8 裁定将最难成功标准(格式精度)提前验证 → 本原型用原生 python-docx 

5实现占位符替换,验证关键路径: 

6 - 章节级占位符 `{{section:id}}` → 替换为内容块渲染的 docx 元素序列 

7 - 行内占位符 `{{meta}}` → 元信息填充 

8 - 残留检查:未替换 `{{...}}` 视为渲染失败(design §6.6 规范约束) 

9 - 格式精度:注入 heading 继承模板对应 Heading 样式(不破坏模板样式) 

10 

11注:原型不引入 docxtpl 依赖,验证 python-docx 原生注入即可满足格式精度关键路径。 

12""" 

13 

14from __future__ import annotations 

15 

16import re 

17from dataclasses import dataclass, field 

18 

19from docx import Document 

20from docx.document import Document as DocxDocument 

21from docx.oxml.ns import qn 

22from docx.text.paragraph import Paragraph 

23 

24# 宽容:docx 正文锚点可能写为 {{Section:id}} / {{section:id}}(大小写/全角冒号) 

25_SECTION_RE = re.compile(r"\{\{\s*section\s*[::]\s*([^}]+?)\s*\}\}", re.IGNORECASE) 

26_INLINE_RE = re.compile(r"\{\{([^}]+)\}\}") 

27 

28 

29class DocxInjectError(Exception): 

30 """docx 注入失败(占位符残留 / 非法模板)。""" 

31 

32 

33@dataclass 

34class Block: 

35 """简化的内容块(ContentBlock 原型的子集)。""" 

36 

37 kind: str # "paragraph" | "heading" | "table" 

38 text: str = "" 

39 level: int = 1 # heading 层级 

40 rows: list[list[str]] = field(default_factory=list) # table 行 

41 

42 

43class DocxInjector: 

44 """模板占位符注入器(原型)。""" 

45 

46 def __init__(self, template_path: str) -> None: 

47 self._template_path = template_path 

48 

49 def inject(self, sections: dict[str, list[Block]], meta: dict[str, str]) -> DocxDocument: 

50 doc = Document(self._template_path) 

51 self._inject_sections(doc, sections) 

52 self._dedupe_bare_subheadings(doc) 

53 self._inject_inline(doc, meta) 

54 

55 # 残留检查(design §6.6 规范约束) 

56 if self._has_residue(doc): 

57 residue = self._collect_residue(doc) 

58 raise DocxInjectError(f"占位符残留未替换:{residue}") 

59 return doc 

60 

61 # ---------- 裸子节去重(design §6.5:H2/H3 归并进父章生成后) ---------- 

62 

63 @staticmethod 

64 def _heading_level(style_name: str | None) -> int | None: 

65 """样式名 → Heading 层级;非 Heading 样式返回 None。""" 

66 if not style_name or not style_name.startswith("Heading"): 

67 return None 

68 tail = style_name[len("Heading"):].strip() 

69 try: 

70 return int(tail) 

71 except ValueError: 

72 return None 

73 

74 def _iter_body_items(self, doc: DocxDocument): 

75 """按文档顺序产出 (element, kind, text, style)。kind: "p" | "tbl"。""" 

76 for child in doc.element.body.iterchildren(): 

77 if child.tag == qn("w:p"): 

78 para = Paragraph(child, doc) 

79 style = para.style.name if para.style is not None else "" 

80 yield child, "p", para.text.strip(), style 

81 elif child.tag == qn("w:tbl"): 

82 yield child, "tbl", "", "" 

83 

84 def _dedupe_bare_subheadings(self, doc: DocxDocument) -> None: 

85 """删除「裸重复子节标题」:同一 H1 章内,与更早的同级同名标题重复、 

86 且其后到下一个标题/表格之间无任何实质内容的模板自带 H2/H3。 

87 

88 背景:§6.5 将 H2/H3 归并进父章生成(生成内容含小节标题),模板原有 

89 空 H2/H3 会与之重复。仅删完全空的重复标题(保守:模板子节下有内容则保留)。 

90 """ 

91 items = list(self._iter_body_items(doc)) 

92 to_remove: set = set() 

93 last_h1_idx = -1 

94 for i, (_, kind, text, style) in enumerate(items): 

95 if kind != "p": 

96 continue 

97 level = self._heading_level(style) 

98 if level is None: 

99 continue 

100 if level <= 1: 

101 last_h1_idx = i 

102 continue 

103 # 裸判定:直到下一个标题/表格前,只有空段落 

104 bare = True 

105 for j in range(i + 1, len(items)): 

106 _el2, kind2, text2, style2 = items[j] 

107 if kind2 == "tbl": 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 bare = False 

109 break 

110 if self._heading_level(style2) is not None: 

111 break 

112 if text2: 112 ↛ 105line 112 didn't jump to line 105 because the condition on line 112 was always true

113 bare = False 

114 break 

115 if not bare or text == "": 

116 continue 

117 # 同章内存在更早的同级同名标题(即注入生成的那份,带内容) 

118 dup = any( 

119 items[k][1] == "p" 

120 and self._heading_level(items[k][3]) == level 

121 and items[k][2] == text 

122 and last_h1_idx < k < i 

123 for k in range(last_h1_idx + 1, i) 

124 ) 

125 if dup: 

126 to_remove.add(items[i][0]) 

127 for el in to_remove: 

128 el.getparent().remove(el) 

129 

130 # ---------- 内部 ---------- 

131 

132 def _inject_sections(self, doc: DocxDocument, sections: dict[str, list[Block]]) -> None: 

133 for para in list(doc.paragraphs): 

134 m = _SECTION_RE.search(para.text) 

135 if not m: 

136 continue 

137 section_id = m.group(1).strip() 

138 blocks = sections.get(section_id) 

139 if blocks is None: 

140 # 未提供该章节内容 → 保留占位符段落,交由残留检查报错 

141 continue 

142 self._replace_paragraph_with_blocks(doc, para, blocks) 

143 

144 def _inject_inline(self, doc: DocxDocument, meta: dict[str, str]) -> None: 

145 for para in doc.paragraphs: 

146 if _INLINE_RE.search(para.text): 

147 # 仅替换行内占位符,保留模板其余文本 

148 new_text = _INLINE_RE.sub(lambda mm: meta.get(mm.group(1), mm.group(0)), para.text) 

149 self._set_paragraph_text(para, new_text) 

150 

151 def _replace_paragraph_with_blocks( 

152 self, doc: DocxDocument, para: Paragraph, blocks: list[Block] 

153 ) -> None: 

154 """将含 {{section:id}} 的段落替换为 blocks 渲染的元素序列。""" 

155 parent = para._p.getparent() 

156 para_idx = list(parent).index(para._p) 

157 

158 # 先移除原占位符段落 

159 parent.remove(para._p) 

160 

161 # 逆序插入,使最终顺序正确 

162 for block in reversed(blocks): 

163 for el in reversed(self._block_element(doc, block)): 

164 parent.insert(para_idx, el) 

165 

166 def _block_element(self, doc: DocxDocument, block: Block) -> list: 

167 if block.kind == "heading": 

168 p = doc.add_paragraph(block.text, style=f"Heading {block.level}") 

169 return [p._p] 

170 if block.kind == "table": 

171 elements: list = [] 

172 if block.text: 

173 style = "Caption" if "Caption" in doc.styles else None 

174 cap = doc.add_paragraph(block.text, style=style) 

175 elements.append(cap._p) 

176 tbl = doc.add_table(rows=0, cols=len(block.rows[0]) if block.rows else 1) 

177 for r in block.rows: 

178 cells = tbl.add_row().cells 

179 for i, val in enumerate(r): 

180 cells[i].text = str(val) 

181 elements.append(tbl._tbl) # type: ignore[attr-defined] 

182 return elements 

183 # 默认 paragraph 

184 p = doc.add_paragraph(block.text) 

185 return [p._p] 

186 

187 def _set_paragraph_text(self, para: Paragraph, text: str) -> None: 

188 # 清空 run,写入单 run(原型简化;保留段落样式) 

189 for run in list(para.runs): 

190 run._r.getparent().remove(run._r) 

191 para.add_run(text) 

192 

193 def _has_residue(self, doc: DocxDocument) -> bool: 

194 for para in doc.paragraphs: 

195 if _INLINE_RE.search(para.text): 

196 return True 

197 return False 

198 

199 def _collect_residue(self, doc: DocxDocument) -> list[str]: 

200 found: list[str] = [] 

201 for para in doc.paragraphs: 

202 for m in _INLINE_RE.finditer(para.text): 

203 found.append(m.group(0)) 

204 return found