与裸文本。"""
+ """递归组装行内 run://// 与裸文本。
+
+ 嵌套块级(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
# 未知行内元素:递归子节点(避免把 / 后代塌成纯文本)
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 用 包 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)
diff --git a/sdk/app/services/docgen/pipeline.py b/sdk/app/services/docgen/pipeline.py
index f35e31d..4f2e32e 100644
--- a/sdk/app/services/docgen/pipeline.py
+++ b/sdk/app/services/docgen/pipeline.py
@@ -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)
diff --git a/sdk/app/services/test_docgen_html2docx.py b/sdk/app/services/test_docgen_html2docx.py
index a574bca..4e3fd53 100644
--- a/sdk/app/services/test_docgen_html2docx.py
+++ b/sdk/app/services/test_docgen_html2docx.py
@@ -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 产物形态——
+# … 包裹层 + 嵌套 section
+HTML_WRAPPED = """
+水稻病虫害防治方案
+某某单位文件
+
+水稻病虫害防治方案
+
+一、病害防治
+第一节正文,共 60 段落之一。
+另一个段落。
+1.1 稻瘟病
+- 防治要点一
- 防治要点二
+药剂 用量
+三环唑 20g/亩
+
图1 症状
+
+
+二、虫害防治
+第二节正文。
+
+
+"""
+
+
+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