nex_docus/backend/app/services/pdf_service.py

161 lines
5.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
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 SCDocker 用 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"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{title}</title>
</head>
<body class="markdown-body">
{html_content}
</body>
</html>
"""
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()