马一丁

Add an "Export to PDF" Button and Define the Font for Exporting to PDF

@@ -10,6 +10,7 @@ import html @@ -10,6 +10,7 @@ import html
10 import json 10 import json
11 import os 11 import os
12 import re 12 import re
  13 +import base64
13 from pathlib import Path 14 from pathlib import Path
14 from typing import Any, Dict, List 15 from typing import Any, Dict, List
15 from loguru import logger 16 from loguru import logger
@@ -74,6 +75,7 @@ class HTMLRenderer: @@ -74,6 +75,7 @@ class HTMLRenderer:
74 self.toc_rendered = False 75 self.toc_rendered = False
75 self.hero_kpi_signature: tuple | None = None 76 self.hero_kpi_signature: tuple | None = None
76 self._lib_cache: Dict[str, str] = {} 77 self._lib_cache: Dict[str, str] = {}
  78 + self._pdf_font_base64: str | None = None
77 79
78 # 初始化图表验证和修复器 80 # 初始化图表验证和修复器
79 self.chart_validator = create_chart_validator() 81 self.chart_validator = create_chart_validator()
@@ -97,6 +99,11 @@ class HTMLRenderer: @@ -97,6 +99,11 @@ class HTMLRenderer:
97 """获取第三方库文件的目录路径""" 99 """获取第三方库文件的目录路径"""
98 return Path(__file__).parent / "libs" 100 return Path(__file__).parent / "libs"
99 101
  102 + @staticmethod
  103 + def _get_font_path() -> Path:
  104 + """返回PDF导出所需字体的路径"""
  105 + return Path(__file__).parent / "assets" / "fonts" / "SourceHanSerifSC-Medium.otf"
  106 +
100 def _load_lib(self, filename: str) -> str: 107 def _load_lib(self, filename: str) -> str:
101 """ 108 """
102 加载指定的第三方库文件内容 109 加载指定的第三方库文件内容
@@ -123,6 +130,22 @@ class HTMLRenderer: @@ -123,6 +130,22 @@ class HTMLRenderer:
123 print(f"警告: 读取库文件 {filename} 时出错: {e}") 130 print(f"警告: 读取库文件 {filename} 时出错: {e}")
124 return "" 131 return ""
125 132
  133 + def _load_pdf_font_data(self) -> str:
  134 + """加载PDF字体的Base64数据,避免重复读取大型文件"""
  135 + if self._pdf_font_base64 is not None:
  136 + return self._pdf_font_base64
  137 + font_path = self._get_font_path()
  138 + try:
  139 + data = font_path.read_bytes()
  140 + self._pdf_font_base64 = base64.b64encode(data).decode("ascii")
  141 + return self._pdf_font_base64
  142 + except FileNotFoundError:
  143 + logger.warning("PDF字体文件缺失:%s", font_path)
  144 + except Exception as exc:
  145 + logger.warning("读取PDF字体文件失败:%s (%s)", font_path, exc)
  146 + self._pdf_font_base64 = ""
  147 + return self._pdf_font_base64
  148 +
126 # ====== 公共入口 ====== 149 # ====== 公共入口 ======
127 150
128 def render(self, document_ir: Dict[str, Any]) -> str: 151 def render(self, document_ir: Dict[str, Any]) -> str:
@@ -221,6 +244,8 @@ class HTMLRenderer: @@ -221,6 +244,8 @@ class HTMLRenderer:
221 str: head片段HTML。 244 str: head片段HTML。
222 """ 245 """
223 css = self._build_css(theme_tokens) 246 css = self._build_css(theme_tokens)
  247 + pdf_font_b64 = self._load_pdf_font_data()
  248 + pdf_font_literal = json.dumps(pdf_font_b64)
224 249
225 # 加载第三方库 250 # 加载第三方库
226 chartjs = self._load_lib("chart.js") 251 chartjs = self._load_lib("chart.js")
@@ -263,6 +288,10 @@ class HTMLRenderer: @@ -263,6 +288,10 @@ class HTMLRenderer:
263 {css} 288 {css}
264 </style> 289 </style>
265 <script> 290 <script>
  291 + // 预载 PDF 字体 Base64 数据,后续由 jspdf addFileToVFS 使用
  292 + window.pdfFontData = {pdf_font_literal};
  293 + </script>
  294 + <script>
266 document.documentElement.classList.remove('no-js'); 295 document.documentElement.classList.remove('no-js');
267 document.documentElement.classList.add('js-ready'); 296 document.documentElement.classList.add('js-ready');
268 </script> 297 </script>
@@ -330,7 +359,7 @@ class HTMLRenderer: @@ -330,7 +359,7 @@ class HTMLRenderer:
330 <div class="header-actions"> 359 <div class="header-actions">
331 <button id="theme-toggle" class="action-btn" type="button">🌗 主题切换</button> 360 <button id="theme-toggle" class="action-btn" type="button">🌗 主题切换</button>
332 <button id="print-btn" class="action-btn" type="button">🖨️ 打印</button> 361 <button id="print-btn" class="action-btn" type="button">🖨️ 打印</button>
333 - <!-- <button id="export-btn" class="action-btn" type="button">⬇️ 导出PDF</button> --> 362 + <button id="export-btn" class="action-btn" type="button">⬇️ 导出PDF</button>
334 </div> 363 </div>
335 </header> 364 </header>
336 """.strip() 365 """.strip()
@@ -2793,6 +2822,15 @@ function exportPdf() { @@ -2793,6 +2822,15 @@ function exportPdf() {
2793 } 2822 }
2794 showExportOverlay('正在导出PDF,请稍候...'); 2823 showExportOverlay('正在导出PDF,请稍候...');
2795 const pdf = new jspdf.jsPDF('p', 'mm', 'a4'); 2824 const pdf = new jspdf.jsPDF('p', 'mm', 'a4');
  2825 + try {
  2826 + if (window.pdfFontData) {
  2827 + pdf.addFileToVFS('SourceHanSerifSC-Medium.otf', window.pdfFontData);
  2828 + pdf.addFont('SourceHanSerifSC-Medium.otf', 'SourceHanSerif', 'normal');
  2829 + pdf.setFont('SourceHanSerif');
  2830 + }
  2831 + } catch (err) {
  2832 + console.warn('Custom PDF font setup failed, fallback to default', err);
  2833 + }
2796 const pageWidth = pdf.internal.pageSize.getWidth(); 2834 const pageWidth = pdf.internal.pageSize.getWidth();
2797 const pxWidth = Math.max(target.scrollWidth, document.documentElement.scrollWidth); 2835 const pxWidth = Math.max(target.scrollWidth, document.documentElement.scrollWidth);
2798 const restoreButton = () => { 2836 const restoreButton = () => {