#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>
156 lines
5.8 KiB
Python
156 lines
5.8 KiB
Python
"""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)
|
||
}
|