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

156 lines
5.8 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/html_css.py —— 自家 render_html 产物的 CSS 解析(缺陷 #47a,2026-09-11)。
render_html 返回 (html, css):html 是模板 wrapper(css 不内联,
css 由调用方单独持有——PDF 路径与预览拼装时注入)。html2docx 的输入
原本只有 html,缺陷 #47a 后调用方(docgen 路由)把 css 一并传入
(converter.docx_from_html_document(html, title, css)),本模块负责:
- 解析 html 内嵌 <style> 块(预览注入后的产物形态);
- 合并调用方传入的模板 CSS 文本(后出现者优先,与调用方拼装语义一致);
- 解析不到声明时回退历史硬编码常量(红头 FF0000 22pt、h1/h2/h3 22/16/14pt),
保证无样式 HTML / 解析失败的行为与 r3 一致。
解析只覆盖 CSS 安全子集(块流/字体/颜色,见 templates 各 css 文件头注释)。
"""
from __future__ import annotations
import re
from bs4 import BeautifulSoup, Tag
# ---- 历史硬编码常量(解析不出声明时的最终回退,行为与 r3 相同)----
_LEGACY_LETTERHEAD_COLOR = (255, 0, 0) # FF0000
_LEGACY_LETTERHEAD_SIZE_PT = 22.0
_LEGACY_HEADING_SIZES_PT = {1: 22.0, 2: 16.0, 3: 14.0}
_ColorRGB = tuple[int, int, int]
# rgb(r, g, b) / rgba(...) / #rgb / #rrggbb(容忍空白)
_RGB_RE = re.compile(
r"rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})", re.IGNORECASE
)
_HEX6_RE = re.compile(r"#([0-9a-fA-F]{6})\b")
_HEX3_RE = re.compile(r"#([0-9a-fA-F]{3})\b")
# 1.2e3 / 12.5 / 12 后接 px|pt|em|rem(匹配声明值中的尺寸)
_SIZE_RE = re.compile(
r"(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*(px|pt|em|rem)\b",
re.IGNORECASE,
)
def _declarations(style: Tag) -> list[dict[str, str]]:
"""解析 <style> 文本为声明块列表:{selector, prop: value...}。
组合选择器("h3, h4, h5, h6")按逗号拆分为独立条目,
让 h3/h4… 各自可被单独查取(模板 CSS 只含简单元素/类选择器)。
"""
out: list[dict[str, str]] = []
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", style.get_text() or ""):
selectors = [sel.strip() for sel in match.group(1).split(",") if sel.strip()]
if not selectors:
continue
block: dict[str, str] = {}
for decl in re.findall(r"([\w-]+)\s*:\s*([^;]+);", match.group(2)):
block[decl[0].strip().lower()] = decl[1].strip()
if not block:
continue
for selector in selectors:
out.append({"selector": selector, "block": dict(block)})
return out
def parse_rgb(value: str) -> _ColorRGB | None:
"""CSS 颜色 → (r,g,b);支持 #rrggbb/#rgb/rgb()/rgba(),其余返回 None。"""
if not value:
return None
m = _HEX6_RE.search(value)
if m:
h = m.group(1)
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
m = _HEX3_RE.search(value)
if m:
h = m.group(1)
return (int(h[0], 16) * 17, int(h[1], 16) * 17, int(h[2], 16) * 17)
m = _RGB_RE.search(value)
if m:
r, g, b = (max(0, min(255, int(c))) for c in m.groups())
return (r, g, b)
return None
def parse_size_pt(value: str) -> float | None:
"""CSS 尺寸 → pt(px 按 96dpi:1px = 0.75pt);非尺寸返回 None。"""
if not value:
return None
m = _SIZE_RE.search(value)
if not m:
return None
number = float(m.group(1))
unit = m.group(2).lower()
if unit == "px":
return number * 0.75
if unit in ("pt", "em", "rem"):
return number
return None
def parse_css_styles(html: str) -> dict[str, dict[str, str]]:
"""HTML 内嵌 <style> 块 → {selector: {prop: value}}。
多个 <style> 按出现顺序合并,后出现者优先(与浏览器级联一致)。
无 <style> 时返回空 dict(调用方回退常量)。
"""
merged: dict[str, dict[str, str]] = {}
if not html:
return merged
soup = BeautifulSoup(html, "html.parser")
for style in soup.find_all("style"):
for decl in _declarations(style):
block = merged.setdefault(decl["selector"], {})
block.update(decl["block"])
return merged
def merge_css_text(
styles: dict[str, dict[str, str]], css: str
) -> dict[str, dict[str, str]]:
"""把调用方持有的模板 CSS 文本(render_html 返回的 css)并入解析结果。
css 文本通常不含 <style> 标签,这里包一层再走同一解析器;
后出现者优先(模板 CSS 覆盖 html 内嵌声明,与调用方拼装语义一致)。
"""
if not css:
return styles
merged = dict(styles)
wrapped = BeautifulSoup(f"<style>{css}</style>", "html.parser")
for decl in _declarations(wrapped.style):
block = merged.setdefault(decl["selector"], {})
block.update(decl["block"])
return merged
def letterhead_style(styles: dict[str, dict[str, str]]) -> tuple[_ColorRGB, float]:
"""红头样式:(rgb 颜色, 字号 pt)。解析不到时回退历史常量。"""
block = styles.get(".doc-letterhead") or {}
color = parse_rgb(block.get("color", "")) or _LEGACY_LETTERHEAD_COLOR
size = parse_size_pt(block.get("font-size", ""))
if size is None:
size = _LEGACY_LETTERHEAD_SIZE_PT
return color, size
def heading_sizes(styles: dict[str, dict[str, str]]) -> dict[int, float]:
"""h1-h3 字号(pt):解析 h1.doc-title、h2、h3 声明;缺失回落历史常量。"""
h1 = styles.get("h1.doc-title") or styles.get("h1") or {}
h2 = styles.get("h2") or {}
h3 = styles.get("h3") or {}
parsed = {
1: parse_size_pt(h1.get("font-size", "")),
2: parse_size_pt(h2.get("font-size", "")),
3: parse_size_pt(h3.get("font-size", "")),
}
return {
level: parsed[level] if parsed[level] is not None else _LEGACY_HEADING_SIZES_PT[level]
for level in (1, 2, 3)
}