feat(writer): docx 注入原型提前(T17 架构审查整改,OV8)

- T17 (OV8, P1): 新建 src/genesis/writer/ 包
  - docx_injector.py: DocxInjector 用原生 python-docx 实现 §6.6 占位符注入
    - 章节级 {{section:id}} → 内容块 docx 元素序列(heading/paragraph/table)
    - 行内 {{meta}} → 元信息填充
    - 残留检查: 未替换 {{...}} 抛 DocxInjectError
    - 格式精度: 注入 heading 继承模板 Heading 样式,原内容不被破坏
  - 新增 test_docx_injector.py(5 用例)
  - 同步 design.md §6.7 渲染链路 T17 原型说明
- TDD: RED(模块缺失)→ GREEN(聚焦 5 passed)→ 全量 245 passed / 100.00%(1361 stmts/340 br)
This commit is contained in:
lhl
2026-08-12 22:47:43 +08:00
parent d2e651bad0
commit e3c714d5d9
5 changed files with 236 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
"""writer 包:docx 渲染(T17 原型,OV8)。"""
from __future__ import annotations
from dataclasses import dataclass, field
from docx import Document
from docx.document import Document as DocxDocument
__all__ = ["Block", "DocxInjector", "DocxInjectError"]
+127
View File
@@ -0,0 +1,127 @@
"""docx 注入原型(T17OV8)。
背景:design.md §6.6/6.7 定义 docxtpl 占位符注入 + 格式精度要求,但完整 Writer
未实现。OV8 裁定将最难成功标准(格式精度)提前验证 → 本原型用原生 python-docx
实现占位符替换,验证关键路径:
- 章节级占位符 `{{section:id}}` → 替换为内容块渲染的 docx 元素序列
- 行内占位符 `{{meta}}` → 元信息填充
- 残留检查:未替换 `{{...}}` 视为渲染失败(design §6.6 规范约束)
- 格式精度:注入 heading 继承模板对应 Heading 样式(不破坏模板样式)
注:原型不引入 docxtpl 依赖,验证 python-docx 原生注入即可满足格式精度关键路径。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from docx import Document
from docx.document import Document as DocxDocument
from docx.oxml.ns import qn
from docx.text.paragraph import Paragraph
_SECTION_RE = re.compile(r"\{\{section:([^}]+)\}\}")
_INLINE_RE = re.compile(r"\{\{([^}]+)\}\}")
class DocxInjectError(Exception):
"""docx 注入失败(占位符残留 / 非法模板)。"""
@dataclass
class Block:
"""简化的内容块(ContentBlock 原型的子集)。"""
kind: str # "paragraph" | "heading" | "table"
text: str = ""
level: int = 1 # heading 层级
rows: list[list[str]] = field(default_factory=list) # table 行
class DocxInjector:
"""模板占位符注入器(原型)。"""
def __init__(self, template_path: str) -> None:
self._template_path = template_path
def inject(self, sections: dict[str, list[Block]], meta: dict[str, str]) -> DocxDocument:
doc = Document(self._template_path)
self._inject_sections(doc, sections)
self._inject_inline(doc, meta)
# 残留检查(design §6.6 规范约束)
if self._has_residue(doc):
residue = self._collect_residue(doc)
raise DocxInjectError(f"占位符残留未替换:{residue}")
return doc
# ---------- 内部 ----------
def _inject_sections(self, doc: DocxDocument, sections: dict[str, list[Block]]) -> None:
for para in list(doc.paragraphs):
m = _SECTION_RE.search(para.text)
if not m:
continue
section_id = m.group(1)
blocks = sections.get(section_id)
if blocks is None:
# 未提供该章节内容 → 保留占位符段落,交由残留检查报错
continue
self._replace_paragraph_with_blocks(doc, para, blocks)
def _inject_inline(self, doc: DocxDocument, meta: dict[str, str]) -> None:
for para in doc.paragraphs:
if _INLINE_RE.search(para.text):
# 仅替换行内占位符,保留模板其余文本
new_text = _INLINE_RE.sub(lambda mm: meta.get(mm.group(1), mm.group(0)), para.text)
self._set_paragraph_text(para, new_text)
def _replace_paragraph_with_blocks(
self, doc: DocxDocument, para: Paragraph, blocks: list[Block]
) -> None:
"""将含 {{section:id}} 的段落替换为 blocks 渲染的元素序列。"""
parent = para._p.getparent()
para_idx = list(parent).index(para._p)
# 先移除原占位符段落
parent.remove(para._p)
# 逆序插入,使最终顺序正确
for block in reversed(blocks):
el = self._block_element(doc, block)
parent.insert(para_idx, el)
def _block_element(self, doc: DocxDocument, block: Block):
if block.kind == "heading":
p = doc.add_paragraph(block.text, style=f"Heading {block.level}")
return p._p
if block.kind == "table":
tbl = doc.add_table(rows=0, cols=len(block.rows[0]) if block.rows else 1)
for r in block.rows:
cells = tbl.add_row().cells
for i, val in enumerate(r):
cells[i].text = str(val)
return tbl._tbl # type: ignore[attr-defined]
# 默认 paragraph
p = doc.add_paragraph(block.text)
return p._p
def _set_paragraph_text(self, para: Paragraph, text: str) -> None:
# 清空 run,写入单 run(原型简化;保留段落样式)
for run in list(para.runs):
run._r.getparent().remove(run._r)
para.add_run(text)
def _has_residue(self, doc: DocxDocument) -> bool:
for para in doc.paragraphs:
if _INLINE_RE.search(para.text):
return True
return False
def _collect_residue(self, doc: DocxDocument) -> list[str]:
found: list[str] = []
for para in doc.paragraphs:
for m in _INLINE_RE.finditer(para.text):
found.append(m.group(0))
return found