Files
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

393 lines
15 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/docgen/html2docx.py —— 自家 render_html 产物 → Word(缺陷 #47a,2026-09-10)。
与 md2docx 的关系:md2docx 是 markdown 行级状态机(旧路径,回退用);
本模块是「HTML 同源转换」——把 render_html/render_bioflow_report 生成的
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)
- table:Table Grid + 表头灰底(复用 md2docx 的 _shade_cell)
- figure/img:内嵌图片(拉取失败落 alt 文本),URL 经 BFF 代理端点绝对化
- 列表/段落/加粗/斜体/行内码:递归 run 组装
- eastAsia 字体逻辑复用 md2docx 的 _set_run_font 思路(本地副本,避免
循环依赖 md2docx 私有函数)
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any
import httpx
from bs4 import BeautifulSoup, NavigableString, Tag
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor
from . import html_css # 缺陷 #47a:模板 CSS 解析(预览同源)
logger = logging.getLogger(__name__)
# 字体:正文宋体 / 标题黑体(与 md2docx 一致;Windows 中文环境标准)
_FONT_BODY = "宋体"
_FONT_HEADING = "黑体"
# 缺陷 #47a(2026-09-11):红头/标题样式从 render_html 产物(<style> 内嵌
# CSS)解析,与预览/PDF 同源;以下仅为解析失败时的回退常量(保留 r3 行为)。
_HEADING_SIZES = {1: 22, 2: 16, 3: 14}
_LETTERHEAD_SIZE = 22.0
_IMAGE_MAX_WIDTH_IN = 6.0 # A4 正文可用宽度约 6 英寸
# 图片 URL 相对路径 → BFF 代理绝对化(与服务内部 base_url 一致)
_FIGURE_PROXY = "/api/figures/proxy"
_IMAGE_HTTP_TIMEOUT = 8.0
_IMAGE_MAX_BYTES = 20 * 1024 * 1024
# 叶块:直接转 docx 元素、不再下钻的标签
_LEAF_BLOCKS = {
"h1", "h2", "h3", "h4", "h5", "h6",
"p", "table", "ul", "ol", "li", "blockquote", "pre",
"figure", "figcaption", "img", "hr", "br",
}
# 遍历时整体跳过的标签
_SKIP_TAGS = {"script", "style", "meta", "link", "title", "head"}
# 当前文档解析出的标题字号(docx_from_html_document 设置;单线程转换,无并发问题)
_ACTIVE_HEADING_SIZES: dict[int, float] = dict(_HEADING_SIZES)
def _set_run_font(run, font_name: str, size_pt: float | None = None,
bold: bool | None = None, italic: bool | None = None,
color: RGBColor | None = None) -> None:
"""设置西文 + 中文字体(font.name 与 w:eastAsia 同时设置;与 md2docx 同思路)。"""
run.font.name = font_name
rpr = run._element.get_or_add_rPr()
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
rfonts.set(qn("w:eastAsia"), font_name)
if size_pt is not None:
run.font.size = Pt(size_pt)
if bold is not None:
run.font.bold = bold
if italic is not None:
run.font.italic = italic
if color is not None:
run.font.color.rgb = color
def _shade_cell(cell, fill: str) -> None:
"""单元格底色(表头灰底;与 md2docx 同实现)。"""
tc_pr = cell._tc.get_or_add_tcPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), fill)
tc_pr.append(shd)
def _abs_image_url(src: str, base_url: str = "") -> str:
"""图片 URL 绝对化:fig:// 或相对路径 → BFF 代理端点。"""
if not src:
return ""
if src.startswith("data:"):
return src
if src.startswith("fig://"):
return f"{base_url}{_FIGURE_PROXY}?url={src}"
if src.startswith("/"):
return f"{base_url}{src}"
if src.startswith(("http://", "https://")):
return src
return f"{base_url}{_FIGURE_PROXY}?url={src}"
def _collect_direct_text(node: Tag) -> str:
"""节点的直接文本(不含后代元素),用于判断容器是否有自己的文本。"""
return "".join(
str(child) for child in node.children if isinstance(child, NavigableString)
).strip()
def _heading_level(tag_name: str) -> int:
m = re.match(r"^h([1-6])$", tag_name)
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> 与裸文本。
嵌套块级(ul/ol/img)不在行内渲染——由调用方按块处理。
"""
if isinstance(node, NavigableString):
text = str(node)
if text:
paragraph.add_run(text)
return
if not isinstance(node, Tag):
return
name = node.name.lower() if node.name else ""
if name in ("script", "style", "ul", "ol", "img"):
return
if name in ("br",):
paragraph.add_run().add_break()
return
if name in ("b", "strong"):
for child in node.children:
_add_inline_runs(paragraph, child, font_name, size_pt, bold=True)
return
if name in ("em", "i"):
for child in node.children:
_add_inline_runs(paragraph, child, font_name, size_pt, bold)
return
if name == "code":
run = paragraph.add_run(node.get_text())
_set_run_font(run, "Courier New", max(size_pt - 1.0, 8.0), bold=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 段落/表格/图片。"""
name = (node.name or "").lower()
text = _collect_direct_text(node)
# 标题
level = _heading_level(name)
if level:
para = doc.add_heading("", level=level)
run = para.add_run(node.get_text(" ", strip=True))
_set_run_font(run, _FONT_HEADING, _ACTIVE_HEADING_SIZES.get(level, _HEADING_SIZES[level]), bold=True)
para.paragraph_format.space_before = Pt(10)
return
if name in ("ul", "ol"):
ordered = name == "ol"
for child in node.children:
if not isinstance(child, Tag) or (child.name or "").lower() != "li":
continue
style = "List Number" if ordered else "List Bullet"
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)
# 嵌套列表(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)
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
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(node.get_text(" ", strip=True))
_set_run_font(run, _FONT_BODY, 10.5)
return
if name == "img":
_add_image(doc, node)
return
if name == "pre":
para = doc.add_paragraph()
run = para.add_run(node.get_text())
_set_run_font(run, "Courier New", 10.5)
return
if name == "blockquote":
para = doc.add_paragraph(style="Quote")
_add_inline_runs(para, node, _FONT_BODY, 12.0)
return
if name in ("hr", "br"):
return
# 默认段落(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)
else:
body_text = node.get_text(" ", strip=True)
if body_text:
run = para.add_run(body_text)
_set_run_font(run, _FONT_BODY, 12.0)
def _add_table(doc: Document, table_el: Tag) -> None:
"""<table> → Table Grid;首行灰底加粗(模板表格约定)。"""
rows: list[list[str]] = []
trs = table_el.find_all("tr")
for tr in trs:
cells = tr.find_all(["td", "th"]) if tr.find(["td", "th"]) else []
rows.append([c.get_text(" ", strip=True) for c in cells])
rows = [r for r in rows if any(c.strip() for c in r)]
if not rows:
return
width = max(len(r) for r in rows)
rows = [r + [""] * (width - len(r)) for r in rows]
table = doc.add_table(rows=len(rows), cols=width)
table.style = "Table Grid"
for r_idx, row in enumerate(rows):
for c_idx, cell_text in enumerate(row):
cell = table.cell(r_idx, c_idx)
cell.text = ""
para = cell.paragraphs[0]
run = para.add_run(cell_text)
_set_run_font(run, _FONT_BODY, 10.5)
if r_idx == 0: # 表头:加粗灰底(与 md2docx 一致)
_set_run_font(run, _FONT_BODY, 10.5, bold=True)
_shade_cell(cell, "D9D9D9")
def _add_image(doc: Document, img: Tag) -> None:
"""<img> → 内嵌图片(拉取失败落 alt 文本)。"""
src = (img.get("src") or "") if isinstance(img, Tag) else ""
alt = (img.get("alt") or "") if isinstance(img, Tag) else ""
para = doc.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
if not src:
if alt:
run = para.add_run(f"[图片缺失:{alt}]")
_set_run_font(run, _FONT_BODY, 10.5)
return
try:
if src.startswith("data:"):
import base64
m = re.match(r"^data:image/[^;]+;base64,(.+)$", src, re.IGNORECASE | re.DOTALL)
if m:
data = base64.b64decode(m.group(1))
else:
raise ValueError("非 base64 图片 data URL")
else:
resp = httpx.get(_abs_image_url(src), timeout=_IMAGE_HTTP_TIMEOUT)
resp.raise_for_status()
if len(resp.content) > _IMAGE_MAX_BYTES:
raise ValueError(f"图片超过 {_IMAGE_MAX_BYTES // (1024 * 1024)}MB 上限")
data = resp.content
from io import BytesIO
run = para.add_run()
run.add_picture(BytesIO(data), width=Inches(_IMAGE_MAX_WIDTH_IN))
except Exception as exc:
logger.info("docgen: DOCX 图片内嵌失败(落 alt 文本): %s", exc)
run = para.add_run(f"[图片缺失:{alt or src}]")
_set_run_font(run, _FONT_BODY, 10.5)
def docx_from_html_document(html: str, title: str = "", out_path: str | Path | None = None, *, css: str = "") -> Path:
"""HTML → docx。返回写入路径(out_path 为 None 时按 title 命名到当前目录)。
- 红头:.doc-letterhead 颜色/字号从 render_html 产物解析(css 参数 +
html 内嵌 <style>),解析失败回退历史常量(FF0000/22pt)
- h1-h3:黑体,字号同样解析优先、回退 22/16/14pt
- 表格 Table Grid + 表头灰底;figure/img 内嵌(失败落 alt)
- 空 HTML 产出空文档(不抛)
"""
doc = Document()
soup = BeautifulSoup(html or "", "html.parser")
# 缺陷 #47a:红头/标题样式同源解析(render_html 产物 -> DOCX 与预览一致)
styles = html_css.parse_css_styles(html)
if css:
styles = html_css.merge_css_text(styles, css)
letterhead_color, letterhead_size = html_css.letterhead_style(styles)
global _ACTIVE_HEADING_SIZES
_ACTIVE_HEADING_SIZES = html_css.heading_sizes(styles)
# 标题:<title> 优先(render_html 已回填 {{TITLE}})
if not title:
t = soup.find("title")
title = t.get_text(strip=True) if t else ""
doc.core_properties.title = (title or "document")[:200]
body = soup.body or soup
# 红头(.doc-letterhead):红字居中 22pt 黑体;处理后从树中摘除
# (红头常包在 doc-cover 内、main 外,避免遍历时重复落段)
letterhead_el = body.find(class_="doc-letterhead")
if letterhead_el is not None:
letterhead = letterhead_el.get_text(" ", strip=True)
if letterhead:
para = doc.add_paragraph()
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = para.add_run(letterhead)
_set_run_font(run, _FONT_HEADING, letterhead_size, bold=True,
color=RGBColor(*letterhead_color))
letterhead_el.decompose()
# 主内容: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)
doc.save(out)
return out