第一批:上传去限制+进度展示、模板HTML预览+按需导出、检索TAB实时更新
第二批:edit护栏移除、导出按钮纯化+file_name落库、编辑失焦还原、
二次预览白底根治(防竞态+渲染兜底)
模板体系:7套公文模板(templates/)+templating渲染器、websearch联网
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
268 lines
9.8 KiB
Python
268 lines
9.8 KiB
Python
"""services/docgen/templating.py —— 内置 HTML 模版系统(2026-09-04 手工测试需求2)。
|
||
|
||
模版资产:templates/ 目录(html wrapper + css,浏览器与 PyMuPDF Story 双渲染)。
|
||
CSS 双渲染纪律:仅 Story 安全子集(block flow/tables/fonts/margins,
|
||
无 flex/grid/position/@media/@page margin——见各 css 文件头注释)。
|
||
|
||
Cython 约束:仅 dataclass/dict/stdlib;不引 pydantic。全部函数不抛异常。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from .md2pdf import _md_to_html # 复用导出同一渲染器(markdown-it-py)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_FALLBACK_KEY = "document"
|
||
|
||
# 分类词典(词序即优先级;复用 intent.py DOC_TYPE 词汇,不修改 intent.py)
|
||
_CLASSIFY_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
("official", (
|
||
"通知", "通告", "公告", "请示", "批复", "函", "纪要", "会议纪要",
|
||
"公示", "决定", "命令", "意见",
|
||
)),
|
||
("report", (
|
||
"报告", "调研", "分析", "总结", "汇报", "述职", "可行性", "评估",
|
||
)),
|
||
("application", (
|
||
"申请书", "申请", "申报", "申报书", "申报材料", "标书", "项目书",
|
||
"策划书", "推荐信",
|
||
)),
|
||
("contract", ("合同", "协议", "声明", "证明")),
|
||
("speech", (
|
||
"讲话稿", "致辞", "演讲稿", "倡议书", "邀请函", "感谢信", "慰问信",
|
||
"介绍信", "发言",
|
||
)),
|
||
("plan", ("方案", "计划", "制度", "规定", "办法", "细则", "章程")),
|
||
)
|
||
|
||
_MAIN_RE = re.compile(r"<main>\s*</main>")
|
||
_TABLE_RE = re.compile(r"<table>")
|
||
_FIRST_H1_RE = re.compile(r"<h1>")
|
||
_FONT_SAFE_RE = re.compile(r"[^\w一-鿿\- ]+") # 字体名白名单(防 css 注入)
|
||
|
||
# 红头指定提取(2026-09-04):「红头用三亚海古纪生物科技有限公司」/
|
||
# 「红头:XX公司」/「红头为XX」→ 捕获公司名(2-20 个中英文字符)
|
||
_LETTERHEAD_PROMPT_RE = re.compile(
|
||
r"红头(?:文字|名称|标题|内容)?\s*(?:用|为|是|写|写成|设为|设定|叫做?|叫作?)?\s*[::,,、]?\s*([一-鿿A-Za-z0-9()()·]{2,20})"
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class TemplateAsset:
|
||
key: str
|
||
title: str # 模版名(面向用户展示,落 template_name)
|
||
description: str
|
||
html: str # wrapper 全文(含 {{TITLE}} 占位)
|
||
css: str # 双渲染安全 css
|
||
source: str = "builtin"
|
||
|
||
|
||
def templates_dir() -> Path:
|
||
"""模版资产目录(同包 templates/,随包 rsync 进编译树)。"""
|
||
return Path(__file__).resolve().parent / "templates"
|
||
|
||
|
||
def load_registry() -> dict[str, Any]:
|
||
"""读 index.json。失败返回空 dict(不影响兜底)。"""
|
||
try:
|
||
raw = (templates_dir() / "index.json").read_text(encoding="utf-8")
|
||
data = json.loads(raw)
|
||
return data if isinstance(data, dict) else {}
|
||
except (OSError, ValueError):
|
||
return {}
|
||
|
||
|
||
def _fallback_key() -> str:
|
||
fb = str((load_registry() or {}).get("fallback") or _FALLBACK_KEY)
|
||
return fb or _FALLBACK_KEY
|
||
|
||
|
||
def _read_asset(name: str) -> str:
|
||
try:
|
||
return (templates_dir() / name).read_text(encoding="utf-8")
|
||
except OSError:
|
||
return ""
|
||
|
||
|
||
def get_template_asset(key: str) -> TemplateAsset | None:
|
||
"""key 无效 → None(调用方回落 fallback)。"""
|
||
entries = (load_registry() or {}).get("templates") or []
|
||
for entry in entries:
|
||
if not isinstance(entry, dict) or entry.get("key") != key:
|
||
continue
|
||
html = _read_asset(f"{key}.html")
|
||
css = _read_asset(str(entry.get("css") or f"{key}.css"))
|
||
if not html:
|
||
return None
|
||
return TemplateAsset(
|
||
key=key,
|
||
title=str(entry.get("title") or key),
|
||
description=str(entry.get("description") or ""),
|
||
html=html,
|
||
css=css,
|
||
)
|
||
return None
|
||
|
||
|
||
def list_templates() -> list[dict[str, str]]:
|
||
"""[{key, title, description}](未来模板选择 UI 用)。"""
|
||
out: list[dict[str, str]] = []
|
||
for entry in (load_registry() or {}).get("templates") or []:
|
||
if isinstance(entry, dict) and entry.get("key"):
|
||
out.append({
|
||
"key": str(entry["key"]),
|
||
"title": str(entry.get("title") or entry["key"]),
|
||
"description": str(entry.get("description") or ""),
|
||
})
|
||
return out
|
||
|
||
|
||
def classify_doc_type(description: str) -> str:
|
||
"""规则分类器 → 模版 key。逐词子串匹配(词序即优先级),均不中 → fallback。
|
||
|
||
确定性、零 LLM 成本;「自设计」= 该类型的内置设计样式。
|
||
"""
|
||
text = description or ""
|
||
for key, words in _CLASSIFY_RULES:
|
||
for word in words:
|
||
if word in text:
|
||
return key
|
||
return _fallback_key()
|
||
|
||
|
||
def _style_overlay_css(style_hints: dict[str, Any] | None) -> str:
|
||
"""style_hints(vstore docx 样式提取)→ css 覆盖行(追加在模版 css 尾部)。"""
|
||
if not isinstance(style_hints, dict) or not style_hints:
|
||
return ""
|
||
lines: list[str] = []
|
||
body_font = str(style_hints.get("body_font") or "").strip()
|
||
body_size = style_hints.get("body_size")
|
||
if body_font and _FONT_SAFE_RE.sub("", body_font):
|
||
lines.append(
|
||
f'body, p, li, td {{ font-family: "{body_font}", serif; }}'
|
||
)
|
||
if isinstance(body_size, (int, float)) and 6 <= float(body_size) <= 72:
|
||
lines.append(f"body {{ font-size: {float(body_size):.1f}pt; }}")
|
||
headings = style_hints.get("headings")
|
||
if isinstance(headings, dict):
|
||
for level, hint in headings.items():
|
||
if not isinstance(hint, dict):
|
||
continue
|
||
try:
|
||
lv = int(level)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if lv < 1 or lv > 6:
|
||
continue
|
||
font = str(hint.get("font") or "").strip()
|
||
size = hint.get("size")
|
||
if font and _FONT_SAFE_RE.sub("", font):
|
||
lines.append(f'h{lv} {{ font-family: "{font}", sans-serif; }}')
|
||
if isinstance(size, (int, float)) and 8 <= float(size) <= 60:
|
||
lines.append(f"h{lv} {{ font-size: {float(size):.1f}pt; }}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _body_html(markdown: str) -> str:
|
||
"""md → body HTML + 模版类标记(表格 doc-table、首 h1 doc-title)。"""
|
||
body = _md_to_html(markdown or "")
|
||
body = _TABLE_RE.sub('<table class="doc-table">', body)
|
||
body = _FIRST_H1_RE.sub('<h1 class="doc-title">', body, count=1)
|
||
return f'<div class="doc-body">{body}</div>'
|
||
|
||
|
||
def _resolve_asset(key: str) -> TemplateAsset | None:
|
||
asset = get_template_asset(key)
|
||
if asset is not None:
|
||
return asset
|
||
if key != _fallback_key():
|
||
return get_template_asset(_fallback_key())
|
||
return None
|
||
|
||
|
||
def _default_letterhead(key: str) -> str:
|
||
"""各模板红头默认文字(可被制品 letterhead 覆盖,2026-09-04)。"""
|
||
return {
|
||
"official": "红头文件",
|
||
"report": "报 告",
|
||
"application": "申 报 材 料",
|
||
}.get(key, "")
|
||
|
||
|
||
def extract_letterhead_from_prompt(text: str) -> str:
|
||
"""从生成描述提取用户指定的红头文字(2026-09-04)。
|
||
|
||
「红头用三亚海古纪生物科技有限公司」「红头:XX公司」「红头为XX」→
|
||
捕获 2-20 个中英文字符;未指定(含默认「红头文件」字样的描述)返回空串。
|
||
"""
|
||
if not text:
|
||
return ""
|
||
m = _LETTERHEAD_PROMPT_RE.search(text)
|
||
return m.group(1).strip() if m else ""
|
||
|
||
|
||
def render_html(
|
||
markdown: str,
|
||
*,
|
||
title: str,
|
||
key: str = "document",
|
||
style_hints: dict[str, Any] | None = None,
|
||
letterhead: str = "",
|
||
) -> tuple[str, str]:
|
||
"""markdown → (完整 html, css)。纯同步纯函数,绝不抛异常。
|
||
|
||
letterhead:红头文字(编辑可改,制品持久化);空串回落模板默认。
|
||
任何环节失败都回落 fallback 模版;再失败给最小可读 HTML。
|
||
"""
|
||
try:
|
||
asset = _resolve_asset(key) or _resolve_asset(_FALLBACK_KEY)
|
||
body = _body_html(markdown)
|
||
if asset is not None:
|
||
wrapper = asset.html
|
||
if _MAIN_RE.search(wrapper):
|
||
html = _MAIN_RE.sub("<main>" + body + "</main>", wrapper, count=1)
|
||
else:
|
||
html = (
|
||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
|
||
f"<title>{_esc(str(title))}</title></head><body>"
|
||
f"{body}</body></html>"
|
||
)
|
||
css = asset.css
|
||
overlay = _style_overlay_css(style_hints)
|
||
if overlay:
|
||
css = f"{css}\n/* style_hints 覆盖 */\n{overlay}"
|
||
else:
|
||
html = (
|
||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
|
||
f"<title>{_esc(str(title))}</title></head><body>{body}</body></html>"
|
||
)
|
||
css = ""
|
||
# 标题/红头占位(wrapper 的 <title> 与 .doc-letterhead 用)
|
||
html = html.replace("{{TITLE}}", _esc(str(title)))
|
||
now = datetime.now().strftime("%Y年%m月%d日")
|
||
html = html.replace("{{DATE}}", now)
|
||
lh = (letterhead or "").strip() or _default_letterhead(key)
|
||
html = html.replace("{{LETTERHEAD}}", _esc(lh))
|
||
return html, css
|
||
except Exception as exc: # 兜底:绝不抛
|
||
logger.warning("docgen: 模版渲染失败(%s),回落最小 HTML", exc)
|
||
return (
|
||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head>"
|
||
f"<body><p>{_esc(str(markdown or ''))}</p></body></html>",
|
||
"",
|
||
)
|
||
|
||
|
||
def _esc(text: str) -> str:
|
||
return (
|
||
str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
||
)
|