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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 14:20 +0800
1"""docx 注入原型(T17,OV8)。
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 样式(不破坏模板样式)
11注:原型不引入 docxtpl 依赖,验证 python-docx 原生注入即可满足格式精度关键路径。
12"""
14from __future__ import annotations
16import re
17from dataclasses import dataclass, field
19from docx import Document
20from docx.document import Document as DocxDocument
21from docx.oxml.ns import qn
22from docx.text.paragraph import Paragraph
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"\{\{([^}]+)\}\}")
29class DocxInjectError(Exception):
30 """docx 注入失败(占位符残留 / 非法模板)。"""
33@dataclass
34class Block:
35 """简化的内容块(ContentBlock 原型的子集)。"""
37 kind: str # "paragraph" | "heading" | "table"
38 text: str = ""
39 level: int = 1 # heading 层级
40 rows: list[list[str]] = field(default_factory=list) # table 行
43class DocxInjector:
44 """模板占位符注入器(原型)。"""
46 def __init__(self, template_path: str) -> None:
47 self._template_path = template_path
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)
55 # 残留检查(design §6.6 规范约束)
56 if self._has_residue(doc):
57 residue = self._collect_residue(doc)
58 raise DocxInjectError(f"占位符残留未替换:{residue}")
59 return doc
61 # ---------- 裸子节去重(design §6.5:H2/H3 归并进父章生成后) ----------
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
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", "", ""
84 def _dedupe_bare_subheadings(self, doc: DocxDocument) -> None:
85 """删除「裸重复子节标题」:同一 H1 章内,与更早的同级同名标题重复、
86 且其后到下一个标题/表格之间无任何实质内容的模板自带 H2/H3。
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)
130 # ---------- 内部 ----------
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)
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)
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)
158 # 先移除原占位符段落
159 parent.remove(para._p)
161 # 逆序插入,使最终顺序正确
162 for block in reversed(blocks):
163 for el in reversed(self._block_element(doc, block)):
164 parent.insert(para_idx, el)
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]
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)
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
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