""" PDF 生成服务 - 基于 WeasyPrint + 系统字体 """ import io import os import platform from pathlib import Path class PDFService: def __init__(self): self.markdown = None self.html_class = None self.css_class = None self.font_config = None def _configure_macos_library_path(self): """为 macOS 上的 Homebrew 动态库补充兜底搜索路径。""" if platform.system() != "Darwin": return candidate_paths = [ Path("/opt/homebrew/lib"), Path("/usr/local/lib"), ] existing_paths = [ path for path in os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "").split(":") if path ] for candidate in candidate_paths: candidate_str = str(candidate) if candidate.exists() and candidate_str not in existing_paths: existing_paths.append(candidate_str) if existing_paths: os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join(existing_paths) def _ensure_dependencies(self): """按需加载 PDF 依赖,避免系统库缺失时影响整个服务启动。""" if self.markdown and self.html_class and self.css_class and self.font_config: return try: self._configure_macos_library_path() import markdown from weasyprint import HTML, CSS from weasyprint.text.fonts import FontConfiguration except Exception as exc: raise RuntimeError( "PDF 导出依赖不可用,请先安装 backend/requirements.txt 中的 Python 依赖," "并为 WeasyPrint 安装系统库(如 glib、pango、cairo)。" ) from exc self.markdown = markdown self.html_class = HTML self.css_class = CSS self.font_config = FontConfiguration() def get_css(self): return """ @page { margin: 2cm; @bottom-right { content: counter(page); font-size: 9pt; } } /* 全局:macOS 用 Hiragino Sans GB / Heiti SC,Docker 用 WenQuanYi Micro Hei */ * { font-family: "Hiragino Sans GB", "Heiti SC", "WenQuanYi Micro Hei", "Arial Unicode MS", sans-serif !important; } body { font-size: 11pt; line-height: 1.6; color: #333; background-color: #fff; } h1, h2, h3, h4, h5, h6 { border-bottom: 1px solid #eee; padding-bottom: 0.3em; margin-top: 1.5em; margin-bottom: 1em; font-weight: bold; } /* 代码块:等宽字体优先,中文回退到对应平台的 CJK 字体 */ pre, code, pre *, code * { font-family: "Menlo", "DejaVu Sans Mono", "Courier New", "Hiragino Sans GB", "Heiti SC", "WenQuanYi Micro Hei", monospace !important; background-color: transparent; } pre { background-color: #f6f8fa !important; padding: 16px; border-radius: 6px; white-space: pre-wrap; word-break: break-all; display: block; } code { background-color: rgba(175, 184, 193, 0.2); padding: 0.2em 0.4em; border-radius: 6px; } table { border-collapse: collapse; width: 100%; margin-bottom: 1em; } th, td { border: 1px solid #dfe2e5; padding: 6px 13px; } tr:nth-child(2n) { background-color: #f6f8fa; } img { max-width: 100%; } blockquote { border-left: 0.25em solid #dfe2e5; color: #6a737d; padding: 0 1em; margin-left: 0; } """ async def md_to_pdf(self, md_content: str, title: str = "Document", base_url: str = None) -> io.BytesIO: """将 Markdown 转换为 PDF 字节流""" self._ensure_dependencies() html_content = self.markdown.markdown( md_content, extensions=['extra', 'codehilite', 'toc', 'tables'] ) full_html = f""" {title} {html_content} """ pdf_buffer = io.BytesIO() css = self.css_class(string=self.get_css(), font_config=self.font_config) self.html_class(string=full_html, base_url=base_url).write_pdf( pdf_buffer, stylesheets=[css], font_config=self.font_config ) pdf_buffer.seek(0) return pdf_buffer pdf_service = PDFService()