Files
PlantAI-rag-new/sdk/app/services/test_docgen_html2docx.py
adminandClaude Fable 5 f18286f022 fix(r4-47a/52): DOCX 红头标题样式同源解析 + 意图词典补总结/概括/归纳
#47a: 红头/标题字号颜色从 render_html 产物解析(新增 docgen/html_css.py:
内嵌 style 块 + 调用方传入的模板 css,组合选择器展开、px->pt),解析失败
回退历史常量(FF0000/22pt、h1/h2/h3 22/16/14pt,行为不变)。converter 与
导出路由把 css 传入 DOCX 转换,与 PDF/预览同源;非红头模板(如 report 灰
#333)不误渲染红头。r3 的 doc-body 包裹/表格/图片结构不回归。
#52: intent.py WRITE_VERB 补总结/概括/归纳,新增动词自指保护(唯一命中的
DOC_TYPE 等于命中动词本身时不算写文档意图,如「总结一下植物光合作用的过程」
仍 qa);cases_intent.md 同步规则与用例 31-34(共 34 条)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 14:19:04 +08:00

287 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""services/test_docgen_html2docx.py —— HTML 同源 DOCX 转换(缺陷 #47a,2026-09-10)。
覆盖:红头居中红字、h1-h3 字号、表格边框+表头灰底、图片缺失降级、空 HTML。
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import pytest
from docx import Document
from docx.oxml.ns import qn
from app.services.docgen.html2docx import docx_from_html_document
# 模拟 render_html 产物形态:红头 + <main> 内容容器(与 templating.py 一致)
HTML_SAMPLE = """<!DOCTYPE html><html><head><meta charset="utf-8">
<title>测试公文</title></head><body>
<div class="doc-letterhead">某某单位文件</div>
<main>
<h1>一、总则</h1>
<p>这是<b>加粗</b>正文,含<em>斜体</em>与<code>inline_code</code>。</p>
<h2>1.1 适用范围</h2>
<ul><li>列表项一</li><li>列表项二</li></ul>
<h3>1.1.1 细节</h3>
<p>三段内容。</p>
<table><tr><th>字段</th><th>说明</th></tr>
<tr><td>名称</td><td>项目名称</td></tr></table>
<figure><img src="http://127.0.0.1:1/definitely-down.png" alt="样例图"/><figcaption>图1 样例</figcaption></figure>
</main>
</body></html>"""
def _first_run_color(run):
return run.font.color.rgb
def _cell_shade(cell) -> str:
"""取 w:shd 的 w:fill(无则空串)。"""
tc_pr = cell._tc.get_or_add_tcPr()
shd = tc_pr.find(qn("w:shd"))
return shd.get(qn("w:fill")) if shd is not None else ""
def _run_east_asia(run) -> str:
rpr = run._element.find(qn("w:rPr"))
if rpr is None:
return ""
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is None:
return ""
return rfonts.get(qn("w:eastAsia")) or ""
def test_letterhead_red_centered(tmp_path):
out = docx_from_html_document(HTML_SAMPLE, title="测试公文", out_path=tmp_path / "a.docx")
assert out.exists()
doc = Document(str(out))
paras = doc.paragraphs
assert paras, "应至少有一个红头段落"
head = paras[0]
assert "某某单位文件" in head.text
assert head.alignment is not None and head.alignment.name == "CENTER"
run = head.runs[0]
assert str(_first_run_color(run)) == "FF0000"
# 22pt 黑体
assert run.font.size is not None and run.font.size.pt == 22.0
assert _run_east_asia(run) == "黑体"
def test_heading_levels_and_sizes(tmp_path):
out = docx_from_html_document(HTML_SAMPLE, title="测试公文", out_path=tmp_path / "b.docx")
doc = Document(str(out))
styles = [p.style.name for p in doc.paragraphs]
assert "Heading 1" in styles
assert "Heading 2" in styles
assert "Heading 3" in styles
sizes = {}
for p in doc.paragraphs:
if p.style.name.startswith("Heading") and p.runs:
sizes[p.style.name] = p.runs[0].font.size.pt
assert sizes.get("Heading 1") == 22.0
assert sizes.get("Heading 2") == 16.0
assert sizes.get("Heading 3") == 14.0
def test_table_grid_and_header_shade(tmp_path):
out = docx_from_html_document(HTML_SAMPLE, title="测试公文", out_path=tmp_path / "c.docx")
doc = Document(str(out))
assert len(doc.tables) == 1
table = doc.tables[0]
assert table.style.name == "Table Grid"
assert len(table.rows) == 2 and len(table.columns) == 2
assert table.cell(0, 0).text == "字段"
assert _cell_shade(table.cell(0, 0)) == "D9D9D9"
assert _cell_shade(table.cell(1, 0)) == ""
def test_image_fetch_failure_falls_back_to_alt(tmp_path):
out = docx_from_html_document(HTML_SAMPLE, title="测试公文", out_path=tmp_path / "d.docx")
doc = Document(str(out))
text = "\\n".join(p.text for p in doc.paragraphs)
assert "图片缺失" in text
assert "样例图" in text # alt 文本兜底
# 表格完整
assert doc.tables[0].cell(1, 1).text == "项目名称"
def test_empty_html_produces_empty_document(tmp_path):
out = docx_from_html_document("", title="空文档", out_path=tmp_path / "e.docx")
assert out.exists()
doc = Document(str(out))
assert doc.core_properties.title == "空文档"
assert len(doc.paragraphs) == 0
assert len(doc.tables) == 0
def test_bold_italic_code_runs(tmp_path):
out = docx_from_html_document(HTML_SAMPLE, title="测试公文", out_path=tmp_path / "f.docx")
doc = Document(str(out))
bold_found = any(r.font.bold for p in doc.paragraphs for r in p.runs)
assert bold_found
code_found = any(r.font.name == "Courier New"
for p in doc.paragraphs for r in p.runs)
assert code_found
# ---------------------------------------------------------------------------
# 缺陷 #47a(2026-09-11):红头/标题样式从 render_html 产物解析(css 参数)
# ---------------------------------------------------------------------------
HTML_OFFICIAL_STYLED = """<!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>
<h2>一、总体要求</h2>
<h3>1.1 具体要求</h3>
<p>正文内容。</p>
</div></main>
</body></html>"""
OFFICIAL_CSS = """
.doc-letterhead { color: #c00000; font-size: 30px; font-weight: bold; }
h1.doc-title { font-size: 22px; }
h2 { font-size: 16px; }
h3, h4, h5, h6 { font-size: 15px; }
"""
def test_letterhead_style_from_css_param(tmp_path):
"""红头样式从模板 CSS 解析:#c00000 系颜色、30px→22.5pt 字号。"""
out = docx_from_html_document(
HTML_OFFICIAL_STYLED, title="红头公文",
out_path=tmp_path / "a.docx", css=OFFICIAL_CSS,
)
doc = Document(str(out))
head = doc.paragraphs[0]
assert "某某单位文件" in head.text
assert head.alignment.name == "CENTER"
run = head.runs[0]
assert str(_first_run_color(run)) == "C00000"
assert run.font.size is not None and abs(run.font.size.pt - 22.5) < 0.01
assert run.font.bold is True
def test_heading_sizes_from_css_param(tmp_path):
"""标题字号从模板 CSS 解析:h1 22px→16.5pt、h2 16px→12pt、h3 15px→11.25pt。"""
out = docx_from_html_document(
HTML_OFFICIAL_STYLED, title="红头公文",
out_path=tmp_path / "b.docx", css=OFFICIAL_CSS,
)
doc = Document(str(out))
sizes = {}
for p in doc.paragraphs:
if p.style.name.startswith("Heading") and p.runs:
sizes[p.style.name] = p.runs[0].font.size.pt
# DOCX 字号按半点(half-point)量化:11.25pt 落 22 半点 = 11.0pt
assert abs(sizes.get("Heading 1") - 16.5) < 0.01
assert abs(sizes.get("Heading 2") - 12.0) < 0.01
assert abs(sizes.get("Heading 3") - 11.25) <= 0.26
def test_no_style_falls_back_to_legacy_constants(tmp_path):
"""无样式 HTML(不传 css)→ 回退 r3 常量(FF0000/22pt 红头,22/16/14pt 标题)。"""
out = docx_from_html_document(HTML_SAMPLE, title="测试公文",
out_path=tmp_path / "c.docx")
doc = Document(str(out))
head = doc.paragraphs[0]
run = head.runs[0]
assert str(_first_run_color(run)) == "FF0000"
assert run.font.size.pt == 22.0
sizes = {}
for p in doc.paragraphs:
if p.style.name.startswith("Heading") and p.runs:
sizes[p.style.name] = p.runs[0].font.size.pt
assert sizes.get("Heading 1") == 22.0
assert sizes.get("Heading 2") == 16.0
assert sizes.get("Heading 3") == 14.0
def test_report_template_grey_letterhead_not_red(tmp_path):
"""非红头模板(report:灰 #333、26px)不误渲染红头。"""
report_css = """
.doc-letterhead { color: #333333; font-size: 26px; font-weight: bold; }
h1.doc-title { font-size: 22px; }
"""
out = docx_from_html_document(
HTML_OFFICIAL_STYLED, title="报告",
out_path=tmp_path / "d.docx", css=report_css,
)
doc = Document(str(out))
run = doc.paragraphs[0].runs[0]
assert str(_first_run_color(run)) == "333333"
assert abs(run.font.size.pt - 19.5) < 0.01 # 26px * 0.75
# 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