#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>
393 lines
15 KiB
Python
393 lines
15 KiB
Python
"""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
|