#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>
287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""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
|