第一批:上传去限制+进度展示、模板HTML预览+按需导出、检索TAB实时更新
第二批:edit护栏移除、导出按钮纯化+file_name落库、编辑失焦还原、
二次预览白底根治(防竞态+渲染兜底)
模板体系:7套公文模板(templates/)+templating渲染器、websearch联网
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
286 lines
12 KiB
Python
286 lines
12 KiB
Python
"""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
|