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:
@@ -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"]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""docx 注入原型(T17,OV8)。
|
||||
|
||||
背景: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
|
||||
Reference in New Issue
Block a user