fix(backend): r3-47-01 DOCX 包裹层递归遍历 + r3-42-01 内置模板 key 回退语义
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
772c7cd688
commit
ad248cf6a8
@@ -84,7 +84,7 @@ from ..services.docgen.editor import apply_edit
|
||||
# is_docgen_intent:路由模块导出(测试 monkeypatch 锚点;护栏用同步 route 判型)
|
||||
from ..services.docgen.intent import is_docgen_intent, route
|
||||
from ..services.docgen.pipeline import generate_document, name_for_prompt
|
||||
from ..services.docgen.templating import render_html
|
||||
from ..services.docgen.templating import get_template_asset, list_templates, render_html
|
||||
from ..services.docgen.templating_bioflow import (
|
||||
BIOFLOW_TEMPLATE_NAME,
|
||||
render_bioflow_report,
|
||||
@@ -371,8 +371,29 @@ async def _intent_guard(text: str, *, has_active_artifact: bool) -> JSONResponse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pick_template(templates: list[dict[str, Any]], template_id: str | None) -> dict[str, Any] | None:
|
||||
"""按 template_id 选模版;未指定时返回 vstore 推荐的第一条。"""
|
||||
"""按 template_id 选模版(r3-42-01,2026-09-10)。
|
||||
|
||||
- template_id 是内置 key(official/report/application/contract/speech/
|
||||
plan/document)→ 直接解析为内置模版(与 templating.py 注册表一致),
|
||||
不依赖 vstore 检索命中——前端选「通用公文(红头)」传 official 时,
|
||||
即使库内无该条目也必中 official,不再回落自动推荐(旧缺陷:产物
|
||||
template_key 与所选不符,实测「方案」+official → plan);
|
||||
- 非内置 key → vstore 检索结果里按 id 命中;
|
||||
- 未指定 template_id → vstore 推荐第一条;
|
||||
- 指定了但哪都未命中 → None(走自动推荐,语义不变)。
|
||||
"""
|
||||
if template_id:
|
||||
tid = str(template_id).strip()
|
||||
for entry in list_templates():
|
||||
if entry.get("key") == tid and get_template_asset(tid) is not None:
|
||||
# 内置模版条目形态与 pipeline 期望一致:id 放 key,
|
||||
# title 放 name(pipeline 读 name 作 template_name 回退)
|
||||
return {
|
||||
"id": tid,
|
||||
"name": entry.get("title") or tid,
|
||||
"description": entry.get("description") or "",
|
||||
"source": "builtin",
|
||||
}
|
||||
for t in templates:
|
||||
if str(t.get("id")) == template_id:
|
||||
return t
|
||||
@@ -527,6 +548,10 @@ async def _run_docgen_task(
|
||||
logger.warning("docgen: 制品落盘失败(不影响返回): %s", exc)
|
||||
|
||||
template_key = str(result.get("template_key") or "")
|
||||
if not template_key and template is not None and template.get("source") == "builtin":
|
||||
# 兜底(r3-42-01):pipeline 理论上已定稿内置 key,此处防
|
||||
# pipeline 结果遗漏——保证 artifact.template_key 与所选一致
|
||||
template_key = str(template.get("id") or "")
|
||||
template_css = str(result.get("css") or "")
|
||||
tpl_display = str(result.get("template_name") or "") or (
|
||||
(template or {}).get("name", "") or (template or {}).get("title", "")
|
||||
|
||||
@@ -800,3 +800,52 @@ def test_startup_cleanup_marks_running_tasks_error(db, tmp_path):
|
||||
|
||||
_check()
|
||||
_run(database.close())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# r3-42-01 内置模板回退语义(2026-09-10)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_pick_template_builtin_key_resolves_without_vstore_hit():
|
||||
"""template_id 是内置 key → 直接解析为内置模版,不依赖 vstore 检索命中。"""
|
||||
from app.services.docgen.templating import get_template_asset, list_templates
|
||||
|
||||
keys = [e["key"] for e in list_templates()]
|
||||
assert "official" in keys and "plan" in keys and "document" in keys
|
||||
|
||||
# vstore 检索结果为空集(库内无条目)
|
||||
tpl = docgen_mod._pick_template([], "official")
|
||||
assert tpl is not None
|
||||
assert tpl["id"] == "official"
|
||||
assert tpl["source"] == "builtin"
|
||||
assert tpl["name"] == "通用公文(红头)"
|
||||
# 内置模版必须真实存在(get_template_asset 非空才解析)
|
||||
assert get_template_asset("official") is not None
|
||||
|
||||
|
||||
def test_pick_template_non_builtin_id_still_vstore():
|
||||
"""非内置 key → vstore 检索按 id 命中;未命中 → None(自动推荐)。"""
|
||||
tpl = docgen_mod._pick_template([{"id": "lib-9", "name": "库内模板"}], "lib-9")
|
||||
assert tpl is not None and tpl["name"] == "库内模板"
|
||||
assert docgen_mod._pick_template([], "lib-9") is None
|
||||
# 未指定 template_id → vstore 推荐第一条
|
||||
assert docgen_mod._pick_template([{"id": "lib-1"}], None)["id"] == "lib-1"
|
||||
|
||||
|
||||
def test_generate_builtin_official_uses_key(client, db, monkeypatch):
|
||||
"""r3-42-01 复现:vstore 检索空 + template_id=official → 产物 key=official。"""
|
||||
async def search_empty(query, *, as_template=None, top_k=20):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(docgen_mod, "search_documents", search_empty)
|
||||
resp = client.post("/api/docgen/generate", json={
|
||||
"description": "撰写一份水稻病虫害防治方案", "session_id": "r3-42",
|
||||
"template_id": "official",
|
||||
})
|
||||
assert resp.status_code == 202, resp.text
|
||||
_drain_pending()
|
||||
task = client.get(f"/api/docgen/tasks/{resp.json()['task_id']}").json()["task"]
|
||||
assert task["status"] == "done", task["error"]
|
||||
artifact = task["artifact"]
|
||||
assert artifact["template_key"] == "official", artifact
|
||||
assert artifact["template_name"] == "通用公文(红头)", artifact
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
HTML(与 PDF 导出/预览同一份产物)用 BeautifulSoup 遍历成 python-docx 文档,
|
||||
保证 DOCX 与 PDF 预览版式一致(红头/标题层级/表格/图片同源)。
|
||||
|
||||
结构适配(r3-47-01,2026-09-10):真实 render_html 产物是
|
||||
<main><div class="doc-body">…包裹层…</div></main>(模板 wrapper 有 doc-cover/
|
||||
doc-body 多层包裹)。遍历必须递归下钻包裹容器(保文档序),只把
|
||||
「叶块」(标题/段落/表格/列表/引用/代码块/图片/图注)转为 docx 元素——
|
||||
修复前只遍历 main 直接子元素,整个 doc-body 被塌成 1 个段落。
|
||||
|
||||
样式约定(与模板 css 视觉对齐):
|
||||
- .doc-letterhead:红字(RGBColor(255,0,0))居中、22pt 黑体(红头文件要件)
|
||||
- h1/h2/h3:黑体分级字号 22/16/14pt 加粗(>h3 折叠到 h3)
|
||||
@@ -44,14 +50,15 @@ _FIGURE_PROXY = "/api/figures/proxy"
|
||||
_IMAGE_HTTP_TIMEOUT = 8.0
|
||||
_IMAGE_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
# 已支持元素外的顶层块元素一律按段落处理(模板 main 内预期就是这些)
|
||||
_BLOCK_TAGS = {
|
||||
# 叶块:直接转 docx 元素、不再下钻的标签
|
||||
_LEAF_BLOCKS = {
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"p", "div", "li", "blockquote", "pre", "table", "figure", "img",
|
||||
"ol", "ul", "section", "article", "header", "footer", "main",
|
||||
"p", "table", "ul", "ol", "li", "blockquote", "pre",
|
||||
"figure", "figcaption", "img", "hr", "br",
|
||||
}
|
||||
|
||||
_VOID_TAGS = {"img", "br", "hr", "meta", "link"}
|
||||
# 遍历时整体跳过的标签
|
||||
_SKIP_TAGS = {"script", "style", "meta", "link", "title", "head"}
|
||||
|
||||
|
||||
def _set_run_font(run, font_name: str, size_pt: float | None = None,
|
||||
@@ -101,7 +108,7 @@ def _abs_image_url(src: str, base_url: str = "") -> str:
|
||||
|
||||
|
||||
def _collect_direct_text(node: Tag) -> str:
|
||||
"""节点的直接文本(不含后代元素),用于 alt 兜底。"""
|
||||
"""节点的直接文本(不含后代元素),用于判断容器是否有自己的文本。"""
|
||||
return "".join(
|
||||
str(child) for child in node.children if isinstance(child, NavigableString)
|
||||
).strip()
|
||||
@@ -112,9 +119,39 @@ def _heading_level(tag_name: str) -> int:
|
||||
return min(int(m.group(1)), 3) if m else 0 # >h3 折叠到 h3
|
||||
|
||||
|
||||
def _walk_blocks(container: Tag):
|
||||
"""递归遍历块级内容(保文档序)。
|
||||
|
||||
规则:
|
||||
- 叶块(_LEAF_BLOCKS)直接产出;
|
||||
- 容器类(div/section/article/header/footer/main/body…)无直接文本 → 下钻;
|
||||
- 容器有直接文本 → 按段落产出(防丢内容);
|
||||
- script/style 等跳过。
|
||||
|
||||
r3-47-01:真实模板是 <main><div class="doc-body">…</div></main>,
|
||||
必须下钻包裹层——只遍历直接子元素会把整个 doc-body 塌成单段。
|
||||
"""
|
||||
for child in container.children:
|
||||
if not isinstance(child, Tag):
|
||||
continue
|
||||
name = (child.name or "").lower()
|
||||
if name in _SKIP_TAGS:
|
||||
continue
|
||||
if name in _LEAF_BLOCKS:
|
||||
yield child
|
||||
continue
|
||||
if _collect_direct_text(child):
|
||||
yield child # 容器自身有文本:按段落
|
||||
else:
|
||||
yield from _walk_blocks(child)
|
||||
|
||||
|
||||
def _add_inline_runs(paragraph, node, font_name: str, size_pt: float,
|
||||
bold: bool | None = None) -> None:
|
||||
"""递归组装行内 run:<b>/<strong>/<em>/<i>/<code> 与裸文本。"""
|
||||
"""递归组装行内 run:<b>/<strong>/<em>/<i>/<code> 与裸文本。
|
||||
|
||||
嵌套块级(ul/ol/img)不在行内渲染——由调用方按块处理。
|
||||
"""
|
||||
if isinstance(node, NavigableString):
|
||||
text = str(node)
|
||||
if text:
|
||||
@@ -123,7 +160,7 @@ def _add_inline_runs(paragraph, node, font_name: str, size_pt: float,
|
||||
if not isinstance(node, Tag):
|
||||
return
|
||||
name = node.name.lower() if node.name else ""
|
||||
if name in ("script", "style"):
|
||||
if name in ("script", "style", "ul", "ol", "img"):
|
||||
return
|
||||
if name in ("br",):
|
||||
paragraph.add_run().add_break()
|
||||
@@ -140,17 +177,13 @@ def _add_inline_runs(paragraph, node, font_name: str, size_pt: float,
|
||||
run = paragraph.add_run(node.get_text())
|
||||
_set_run_font(run, "Courier New", max(size_pt - 1.0, 8.0), bold=bold)
|
||||
return
|
||||
if name in ("span", "a", "small", "sub", "sup"):
|
||||
for child in node.children:
|
||||
_add_inline_runs(paragraph, child, font_name, size_pt, bold)
|
||||
return
|
||||
# 未知行内元素:递归子节点(避免把 <b>/<code> 后代塌成纯文本)
|
||||
for child in node.children:
|
||||
_add_inline_runs(paragraph, child, font_name, size_pt, bold)
|
||||
|
||||
|
||||
def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
|
||||
"""把一个 HTML 块级元素转成 docx 段落/表格/图片。返回是否产出了内容。"""
|
||||
"""把一个 HTML 块级元素转成 docx 段落/表格/图片。"""
|
||||
name = (node.name or "").lower()
|
||||
text = _collect_direct_text(node)
|
||||
|
||||
@@ -161,7 +194,7 @@ def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
|
||||
run = para.add_run(node.get_text(" ", strip=True))
|
||||
_set_run_font(run, _FONT_HEADING, _HEADING_SIZES[level], bold=True)
|
||||
para.paragraph_format.space_before = Pt(10)
|
||||
return True
|
||||
return
|
||||
|
||||
if name in ("ul", "ol"):
|
||||
ordered = name == "ol"
|
||||
@@ -172,51 +205,63 @@ def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
|
||||
para = doc.add_paragraph(style=style)
|
||||
para.paragraph_format.left_indent = Inches(0.25 * (list_level + 1))
|
||||
_add_inline_runs(para, child, _FONT_BODY, 12.0)
|
||||
return True
|
||||
# 嵌套列表(li 内的 ul/ol)按块下钻
|
||||
for nested in child.find_all(["ul", "ol"], recursive=False):
|
||||
_add_rich_block(doc, nested, list_level + 1)
|
||||
return
|
||||
|
||||
if name == "li": # 游离 li(无 ul/ol 父)
|
||||
para = doc.add_paragraph(style="List Bullet")
|
||||
para.paragraph_format.left_indent = Inches(0.25 * (list_level + 1))
|
||||
_add_inline_runs(para, node, _FONT_BODY, 12.0)
|
||||
return True
|
||||
for nested in node.find_all(["ul", "ol"], recursive=False):
|
||||
_add_rich_block(doc, nested, list_level + 1)
|
||||
return
|
||||
|
||||
if name == "table":
|
||||
_add_table(doc, node)
|
||||
return True
|
||||
return
|
||||
|
||||
if name in ("figure", "figcaption") or (name == "div" and node.find("img")):
|
||||
for img in node.find_all("img", recursive=False) or node.find_all("img"):
|
||||
_add_image(doc, img)
|
||||
if name == "figure" and not node.find("img"):
|
||||
_add_image(doc, node.img) # type: ignore[arg-type]
|
||||
# figcaption 文本
|
||||
caption = node.find("figcaption")
|
||||
if caption is not None:
|
||||
if name in ("figure", "figcaption"):
|
||||
if name == "figure":
|
||||
imgs = node.find_all("img", recursive=False) or node.find_all("img")
|
||||
if imgs:
|
||||
for img in imgs:
|
||||
_add_image(doc, img)
|
||||
caption = node.find("figcaption")
|
||||
if caption is not None:
|
||||
para = doc.add_paragraph()
|
||||
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = para.add_run(caption.get_text(" ", strip=True))
|
||||
_set_run_font(run, _FONT_BODY, 10.5)
|
||||
else:
|
||||
para = doc.add_paragraph()
|
||||
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = para.add_run(caption.get_text(" ", strip=True))
|
||||
run = para.add_run(node.get_text(" ", strip=True))
|
||||
_set_run_font(run, _FONT_BODY, 10.5)
|
||||
return True
|
||||
return
|
||||
|
||||
if name == "img":
|
||||
_add_image(doc, node)
|
||||
return True
|
||||
return
|
||||
|
||||
if name == "pre":
|
||||
para = doc.add_paragraph()
|
||||
run = para.add_run(node.get_text())
|
||||
_set_run_font(run, "Courier New", 10.5)
|
||||
return True
|
||||
return
|
||||
|
||||
if name == "blockquote":
|
||||
para = doc.add_paragraph(style="Quote")
|
||||
_add_inline_runs(para, node, _FONT_BODY, 12.0)
|
||||
return True
|
||||
return
|
||||
|
||||
if name in ("hr", "br", "script", "style", "meta", "link"):
|
||||
return False
|
||||
if name in ("hr", "br"):
|
||||
return
|
||||
|
||||
# 默认段落(p/div/section 等):直接文本优先,无直接文本则全文(避免丢内容)
|
||||
# 默认段落(p/带直接文本的 div/section 等):图片后代先按块内嵌,再落文本
|
||||
for img in node.find_all("img", recursive=False):
|
||||
_add_image(doc, img)
|
||||
para = doc.add_paragraph()
|
||||
if text:
|
||||
_add_inline_runs(para, node, _FONT_BODY, 12.0)
|
||||
@@ -225,7 +270,6 @@ def _add_rich_block(doc: Document, node: Tag, list_level: int = 0) -> None:
|
||||
if body_text:
|
||||
run = para.add_run(body_text)
|
||||
_set_run_font(run, _FONT_BODY, 12.0)
|
||||
return True
|
||||
|
||||
|
||||
def _add_table(doc: Document, table_el: Tag) -> None:
|
||||
@@ -308,9 +352,9 @@ def docx_from_html_document(html: str, title: str = "", out_path: str | Path | N
|
||||
doc.core_properties.title = (title or "document")[:200]
|
||||
|
||||
body = soup.body or soup
|
||||
# 红头(.doc-letterhead):红字居中 22pt 黑体;空串不产出段落
|
||||
# 红头(.doc-letterhead):红字居中 22pt 黑体;处理后从树中摘除
|
||||
# (红头常包在 doc-cover 内、main 外,避免遍历时重复落段)
|
||||
letterhead_el = body.find(class_="doc-letterhead")
|
||||
letterhead = ""
|
||||
if letterhead_el is not None:
|
||||
letterhead = letterhead_el.get_text(" ", strip=True)
|
||||
if letterhead:
|
||||
@@ -319,19 +363,13 @@ def docx_from_html_document(html: str, title: str = "", out_path: str | Path | N
|
||||
run = para.add_run(letterhead)
|
||||
_set_run_font(run, _FONT_HEADING, _LETTERHEAD_SIZE, bold=True,
|
||||
color=RGBColor(255, 0, 0))
|
||||
letterhead_el.decompose()
|
||||
|
||||
# 主内容容器(render_html 用 <main> 包 body;bioflow 模板同理)
|
||||
main = body.find("main") or body
|
||||
# 遍历 main 的直接子元素(跳过已处理的红头与 meta)
|
||||
for child in main.children:
|
||||
if not isinstance(child, Tag):
|
||||
continue
|
||||
name = (child.name or "").lower()
|
||||
if name in ("script", "style", "meta", "link", "title"):
|
||||
continue
|
||||
if "doc-letterhead" in (child.get("class") or []):
|
||||
continue # 红头已处理
|
||||
_add_rich_block(doc, child)
|
||||
# 主内容:main 优先;递归下钻包裹容器(doc-body 等),保文档序
|
||||
main = body.find("main") if body is not None else None
|
||||
root = main if main is not None else body
|
||||
for block in _walk_blocks(root):
|
||||
_add_rich_block(doc, block)
|
||||
|
||||
out = Path(out_path) if out_path is not None else Path(f"{title or 'document'}.docx")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -312,19 +312,31 @@ async def generate_document(
|
||||
outline_source = "llm"
|
||||
if template:
|
||||
tpl_id = str(template.get("id") or "")
|
||||
try:
|
||||
content = await client.get_document(tpl_id)
|
||||
except client.DocgenUnavailable:
|
||||
content = None
|
||||
if content:
|
||||
template_outline = _template_outline_from_content(content)
|
||||
if template_outline:
|
||||
outline_source = "library"
|
||||
template_name = str(template.get("name") or "库内模版")
|
||||
# 内置模版(r3-42-01,2026-09-10):_pick_template 已解析为
|
||||
# {id: key, name: title, source: "builtin"}——无库内大纲,直接定稿
|
||||
# template_key/template_name,不再走 vstore 拉取(拉「official」这个
|
||||
# id 会 404);库内模版(id 为 vstore document_id)才拉明文大纲。
|
||||
if template.get("source") == "builtin":
|
||||
template_key = tpl_id
|
||||
template_name = str(template.get("name") or tpl_id)
|
||||
else:
|
||||
try:
|
||||
content = await client.get_document(tpl_id)
|
||||
except client.DocgenUnavailable:
|
||||
content = None
|
||||
if content:
|
||||
template_outline = _template_outline_from_content(content)
|
||||
if template_outline:
|
||||
outline_source = "library"
|
||||
template_name = str(template.get("name") or "库内模版")
|
||||
|
||||
# 3. 库内未命中:内置模版分类 + 联网结构参考(best-effort,默认启用)
|
||||
if not template_outline:
|
||||
template_key = templating.classify_doc_type(description)
|
||||
# r3-42-01:内置模版已定稿 template_key(step 2 builtin 分支),
|
||||
# 这里不得用分类器覆盖(修复前「方案」+official → classify 覆盖成
|
||||
# plan,产物 key 与所选不符)
|
||||
if not template_key:
|
||||
template_key = templating.classify_doc_type(description)
|
||||
if web_search:
|
||||
try:
|
||||
web_outline = await websearch.search_template_outline(description)
|
||||
|
||||
@@ -125,3 +125,72 @@ def test_bold_italic_code_runs(tmp_path):
|
||||
code_found = any(r.font.name == "Courier New"
|
||||
for p in doc.paragraphs for r in p.runs)
|
||||
assert code_found
|
||||
|
||||
|
||||
# r3-47-01 复现样例:真实 render_html 产物形态——
|
||||
# <main><div class="doc-body">…</div></main> 包裹层 + 嵌套 section
|
||||
HTML_WRAPPED = """<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<title>水稻病虫害防治方案</title></head><body>
|
||||
<div class="doc-cover"><div class="doc-letterhead">某某单位文件</div></div>
|
||||
<main><div class="doc-body">
|
||||
<h1 class="doc-title">水稻病虫害防治方案</h1>
|
||||
<section>
|
||||
<h2>一、病害防治</h2>
|
||||
<p>第一节正文,共 60 段落之一。</p>
|
||||
<p>另一个段落。</p>
|
||||
<h3>1.1 稻瘟病</h3>
|
||||
<ul><li>防治要点一</li><li>防治要点二</li></ul>
|
||||
<table class="doc-table"><tr><th>药剂</th><th>用量</th></tr>
|
||||
<tr><td>三环唑</td><td>20g/亩</td></tr></table>
|
||||
<figure><img src="http://127.0.0.1:1/down.png" alt="稻瘟病症状"/><figcaption>图1 症状</figcaption></figure>
|
||||
</section>
|
||||
<section>
|
||||
<h2>二、虫害防治</h2>
|
||||
<p>第二节正文。</p>
|
||||
</section>
|
||||
</div></main>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def test_wrapped_docbody_structure_preserved(tmp_path):
|
||||
"""r3-47-01 回归:doc-body 包裹层下钻——标题/段落/表格/列表/图注全保留,
|
||||
不得塌缩成单段落。"""
|
||||
out = docx_from_html_document(HTML_WRAPPED, title="水稻病虫害防治方案",
|
||||
out_path=tmp_path / "w.docx")
|
||||
doc = Document(str(out))
|
||||
styles = [p.style.name for p in doc.paragraphs]
|
||||
assert "Heading 1" in styles
|
||||
assert styles.count("Heading 2") == 2 # 两个 section 的 h2
|
||||
assert "Heading 3" in styles
|
||||
assert "List Bullet" in styles
|
||||
# 表格完整(2 行 2 列,表头灰底)
|
||||
assert len(doc.tables) == 1
|
||||
table = doc.tables[0]
|
||||
assert len(table.rows) == 2 and table.cell(1, 1).text == "20g/亩"
|
||||
assert _cell_shade(table.cell(0, 0)) == "D9D9D9"
|
||||
# 段落文本保留(不是全塌成一段)
|
||||
texts = "\n".join(p.text for p in doc.paragraphs)
|
||||
assert "第一节正文" in texts
|
||||
assert "第二节正文" in texts
|
||||
# 红头仍正确
|
||||
assert doc.paragraphs[0].text == "某某单位文件"
|
||||
|
||||
|
||||
def test_wrapped_structure_counts_match_html(tmp_path):
|
||||
"""r3-47-01:docx 元素计数与 HTML 结构对应(段落数 >= p 数,标题/表格计数一致)。"""
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
soup = BeautifulSoup(HTML_WRAPPED, "html.parser")
|
||||
n_p = len(soup.find_all("p"))
|
||||
n_h2 = len(soup.find_all("h2"))
|
||||
n_h3 = len(soup.find_all("h3"))
|
||||
n_table = len(soup.find_all("table"))
|
||||
out = docx_from_html_document(HTML_WRAPPED, title="水稻病虫害防治方案",
|
||||
out_path=tmp_path / "c.docx")
|
||||
doc = Document(str(out))
|
||||
styles = [p.style.name for p in doc.paragraphs]
|
||||
assert styles.count("Heading 2") == n_h2
|
||||
assert styles.count("Heading 3") == n_h3
|
||||
assert len(doc.tables) == n_table
|
||||
# 段落:p 落 Normal(含图注段),计数不塌缩(>= p 数,图注/图片段会额外加)
|
||||
assert styles.count("Normal") >= n_p
|
||||
|
||||
Reference in New Issue
Block a user