Files
jerryandClaude Fable 5 4749f77544 fix(docgen): 两批手工测试修复——上传/预览/检索 + 护栏/导出/命名/编辑还原/二次预览
第一批:上传去限制+进度展示、模板HTML预览+按需导出、检索TAB实时更新
第二批:edit护栏移除、导出按钮纯化+file_name落库、编辑失焦还原、
       二次预览白底根治(防竞态+渲染兜底)
模板体系:7套公文模板(templates/)+templating渲染器、websearch联网

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 15:49:54 +08:00

286 lines
12 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/md2pdf.py —— Markdown → PDF。
主路线:markdown-it-py 出 HTML → PyMuPDF Story → PDF
字体:/usr/share/fonts 真实文件(NotoSerifCJK 正文 / NotoSansCJK 标题),
禁用 PyMuPDF 内置 CJK(.ttc 经 Archive 加载,渲染走真实字库)。
降级:Story 路线中文异常(渲染失败/无文本)→ 自写排版器(TextWriter
逐块排版,标题/段落/表格画线),保证任何环境都能出 PDF。
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
import pymupdf
from markdown_it import MarkdownIt
logger = logging.getLogger(__name__)
# 候选字体文件(按优先级;wps-office 目录在部分服务器不存在,按序回退)
_FONT_DIRS = (
Path("/usr/share/fonts/opentype/noto"),
Path("/usr/share/fonts/truetype/wps-office"),
)
_SERIF_CANDIDATES = ("NotoSerifCJK-Regular.ttc", "NotoSerifCJK-Medium.ttc", "wpsw.ttf", "simsun.ttc", "NotoSansCJK-Regular.ttc")
_SANS_CANDIDATES = ("NotoSansCJK-Regular.ttc", "NotoSansCJK-Medium.ttc", "NotoSansCJK-Light.ttc", "wpsw.ttf", "NotoSerifCJK-Regular.ttc")
_SANS_BOLD_CANDIDATES = ("NotoSansCJK-Bold.ttc", "NotoSansCJK-Medium.ttc", "NotoSansCJK-Regular.ttc")
_PAGE = pymupdf.paper_rect("a4")
_MARGIN = 50.0
_BODY_SIZE = 12.0
_LINE_HEIGHT = 1.8
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
_TABLE_SEP_RE = re.compile(r"^\s*\|?[\s:|-]+\|?\s*$")
def _resolve_font(dirname: Path, candidates: tuple[str, ...]) -> Path | None:
for name in candidates:
path = dirname / name
if path.exists():
return path
return None
def _find_fonts() -> tuple[Path, Path, Path]:
"""(正文 serif, 标题 sans, 标题粗 sans)。找不到时逐级回退。"""
serif = sans = sans_bold = None
for d in _FONT_DIRS:
if d.exists():
serif = serif or _resolve_font(d, _SERIF_CANDIDATES)
sans = sans or _resolve_font(d, _SANS_CANDIDATES)
sans_bold = sans_bold or _resolve_font(d, _SANS_BOLD_CANDIDATES)
if serif is None and sans is not None:
serif = sans
if sans is None and serif is not None:
sans = serif
if sans_bold is None:
sans_bold = sans
if serif is None:
logger.warning("md2pdf: 未找到 CJK 字体文件,将回退内置字体(可能无中文)")
return serif, sans, sans_bold
def _md_to_html(markdown: str) -> str:
"""markdown → HTML(markdown-it-py:commonmark + table 启用)。
2026-09-04:commonmark 预设默认不启表格(表格被当作纯文本段落)——
.enable("table") 后 | 表格才出 <table>(模板渲染/PDF 导出的表格依赖它)。
"""
md = MarkdownIt("commonmark").enable("table") # gfm-like 需 linkify-it-py(未安装),commonmark+table 够用
return md.render(markdown or "")
def _story_css(serif: Path, sans: Path, sans_bold: Path) -> str:
css = ""
if serif is not None:
css += f"@font-face {{ font-family: serif-cjk; src: url({serif.name}); }}\n"
css += "body, p, li, td { font-family: serif-cjk; }\n"
else:
css += "body, p, li, td { font-family: sans-serif; }\n"
if sans is not None:
css += f"@font-face {{ font-family: sans-cjk; src: url({sans.name}); }}\n"
if sans_bold is not None:
css += f"@font-face {{ font-family: sans-cjk-b; src: url({sans_bold.name}); }}\n"
css += "h1, h2, h3, h4, th { font-family: sans-cjk-b; }\n"
else:
css += "h1, h2, h3, h4, th { font-family: sans-cjk; }\n"
css += f"""
body {{ font-size: {_BODY_SIZE}pt; line-height: {_LINE_HEIGHT}; }}
h1 {{ font-size: 18pt; margin: 14pt 0 8pt 0; }}
h2 {{ font-size: 15pt; margin: 12pt 0 6pt 0; }}
h3, h4 {{ font-size: 13pt; margin: 10pt 0 5pt 0; }}
table {{ border-collapse: collapse; width: 100%; margin: 8pt 0; }}
td, th {{ border: 0.5pt solid #555; padding: 4pt 6pt; font-size: 10.5pt; }}
th {{ background: #e8e8e8; }}
"""
return css
def _story_to_pdf(html: str, css: str, serif: Path | None, out_path: Path) -> None:
"""Story 渲染(Archive 加载真实字体目录,禁用内置 CJK)。"""
archive = None
for d in _FONT_DIRS:
if d.exists():
archive = pymupdf.Archive(str(d))
break
story = pymupdf.Story(html=html, user_css=css, archive=archive)
writer = pymupdf.DocumentWriter(str(out_path))
where = _PAGE + (_MARGIN, _MARGIN, -_MARGIN, -_MARGIN)
more = 1
while more:
dev = writer.begin_page(_PAGE)
more, _ = story.place(where)
story.draw(dev)
writer.end_page()
writer.close()
def _fallback_pdf(markdown: str, out_path: Path,
serif: Path | None, sans_bold: Path | None) -> None:
"""降级自写排版器:TextWriter 逐块排版(标题/段落/表格画线)。"""
doc = pymupdf.open()
body_font = pymupdf.Font(fontfile=str(serif)) if serif else pymupdf.Font("helv")
head_font = pymupdf.Font(fontfile=str(sans_bold)) if sans_bold else body_font
page = doc.new_page(width=_PAGE.width, height=_PAGE.height)
tw = pymupdf.TextWriter(page.rect)
y = _MARGIN
bottom = _PAGE.height - _MARGIN
def _new_page() -> None:
nonlocal page, tw, y
tw.write_text(page)
page = doc.new_page(width=_PAGE.width, height=_PAGE.height)
tw = pymupdf.TextWriter(page.rect)
y = _MARGIN
def _place(text: str, font, size: float, leading: float) -> None:
nonlocal y
rect = pymupdf.Rect(_MARGIN, y, _PAGE.width - _MARGIN, bottom)
used = tw.fill_textbox(rect, text, font=font, fontsize=size, lineheight=leading)
y = used[1] + 8 if used else y + size * leading
if y > bottom:
_new_page()
for raw in (markdown or "").split("\n"):
line = raw.rstrip()
if not line.strip():
continue
if _TABLE_SEP_RE.match(line) and "|" in line:
continue
m = _HEADING_RE.match(line)
if m:
level = len(m.group(1))
size = {1: 18.0, 2: 15.0, 3: 13.0}.get(level, 12.0)
_place(m.group(2).strip(), head_font, size, 1.5)
continue
if line.strip().startswith("|"):
# 表格行:单元格以 | 连接,表头粗体(画线版为纯文本排)
cells = [c.strip() for c in line.strip().strip("|").split("|")]
text = " | ".join(cells)
is_header = all(re.fullmatch(r"-{2,}", c) for c in cells)
if not is_header:
_place(text, body_font, 10.5, 1.4)
continue
text = re.sub(r"^\s*[-*+]\s+", "• ", line)
text = re.sub(r"^\s*\d+[.)、]\s*", "", text)
text = re.sub(r"^\s*>\s?", "", text)
_place(text.strip(), body_font, _BODY_SIZE, _LINE_HEIGHT)
tw.write_text(page)
doc.save(str(out_path))
doc.close()
def pdf_from_markdown(markdown: str, out_path: str | Path) -> Path:
"""Markdown → PDF。Story 主路线,失败/空文本降级自写排版器。"""
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
serif, sans, sans_bold = _find_fonts()
html = _md_to_html(markdown)
css = _story_css(serif, sans, sans_bold)
try:
_story_to_pdf(html, css, serif, out)
# 冒烟校验:正文文本必须可提取(Story 中文异常 → 空文本)
check = pymupdf.open(str(out))
try:
text = "".join(p.get_text() for p in check)
finally:
check.close()
if text.strip():
return out
logger.warning("md2pdf: Story 输出无文本,降级自写排版器")
except Exception as exc:
logger.warning("md2pdf: Story 渲染失败(%s),降级自写排版器", exc)
_fallback_pdf(markdown, out, serif, sans_bold)
return out
# ---------------------------------------------------------------------------
# HTML 模版渲染(2026-09-04 手工测试需求2:预览与导出同源 HTML+CSS)
# ---------------------------------------------------------------------------
_FAMILY_MAP = (
# 映射为不带引号的注册名——MuPDF Story 对带引号家族名解析失败(回退字体
# 导致 CJK 提取乱码,2026-09-04 实测),_story_css 自身即用无引号名。
(re.compile(r'"(Noto Serif CJK SC|SimSun|宋体)"'), "serif-cjk"),
(re.compile(r'"(Noto Sans CJK SC|SimHei|黑体|Microsoft YaHei)"'), "sans-cjk"),
)
_BANNED_CSS_RE = re.compile(
r"@page[^{]*\{[^}]*\}|@media[^{]*\{[^}]*\}|"
r"[^{};]*(?:display\s*:\s*(?:flex|grid)|position\s*:|transform\s*:)[^;}]*;?",
re.IGNORECASE,
)
# lang 属性会触发 MuPDF 按语言选字体(zh-CN → 非 CJK 字体 → 提取乱码,
# 2026-09-04 实测);PDF 路径必须剥掉(预览端浏览器不受影响)
_LANG_ATTR_RE = re.compile(r'\s+lang="[^"]*"', re.IGNORECASE)
def _strip_lang(html: str) -> str:
return _LANG_ATTR_RE.sub("", html or "")
def _adapt_family_names(css: str) -> str:
"""模版 css → Story 兼容:剥注释(MuPDF CSS 解析器对注释内 CJK/全角标点
会报语法错误并丢弃样式,2026-09-04 实测导致回退字体+提取乱码)、
家族名映射到 @font-face 注册名(必须无引号,带引号注册名同样解析失败)。"""
adapted = re.sub(r"/\*[\s\S]*?\*/", "", css or "")
for pattern, mapped in _FAMILY_MAP:
adapted = pattern.sub(mapped, adapted)
adapted = re.sub(r'font-family\s*:\s*serif\b', 'font-family: serif-cjk', adapted)
adapted = re.sub(r'font-family\s*:\s*sans-serif\b', 'font-family: sans-cjk', adapted)
# 残余带引号注册名也归一(保险)
adapted = re.sub(r'"(serif-cjk|sans-cjk)"', r'\1', adapted)
return _BANNED_CSS_RE.sub("", adapted)
def _html_to_plain_lines(html: str) -> str:
"""降级排版器输入适配:HTML → 近似 markdown 行(标题 #、表格 |、段落原文)。"""
lines: list[str] = []
for m in re.finditer(r"<(h[1-6])[^>]*>([\s\S]*?)</\1>", html, re.IGNORECASE):
level = int(m.group(1)[1])
text = re.sub(r"<[^>]+>", "", m.group(2)).strip()
if text:
lines.append(f"{'#' * level} {text}")
for m in re.finditer(r"<tr[^>]*>([\s\S]*?)</tr>", html, re.IGNORECASE):
cells = [
re.sub(r"<[^>]+>", "", c).strip()
for c in re.findall(r"<t[dh][^>]*>([\s\S]*?)</t[dh]>", m.group(1), re.IGNORECASE)
]
if cells:
lines.append("| " + " | ".join(cells) + " |")
for m in re.finditer(r"<p[^>]*>([\s\S]*?)</p>", html, re.IGNORECASE):
text = re.sub(r"<[^>]+>", "", m.group(1)).strip()
if text:
lines.append(text)
return "\n".join(lines)
def pdf_from_html(html: str, css: str, out_path: str | Path) -> Path:
"""HTML(模版渲染产物)→ PDF。Story 主路线;失败/空文本降级自写排版器。
css 为模板双渲染 css(家族名经 _adapt_family_names 映射到 Story 字体)。
"""
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
serif, sans, sans_bold = _find_fonts()
full_css = _story_css(serif, sans, sans_bold) + "\n" + _adapt_family_names(css or "")
story_html = _strip_lang(html)
try:
_story_to_pdf(story_html, full_css, serif, out)
check = pymupdf.open(str(out))
try:
text = "".join(p.get_text() for p in check)
finally:
check.close()
if text.strip():
return out
logger.warning("md2pdf: Story(html) 输出无文本,降级自写排版器")
except Exception as exc:
logger.warning("md2pdf: Story(html) 渲染失败(%s),降级自写排版器", exc)
_fallback_pdf(_html_to_plain_lines(html), out, serif, sans_bold)
return out