马一丁

Added Support for Formulas and Optimize the Rendering of Data Blocks When Exporting to PDF

  1 +"""
  2 +LaTeX 数学公式转 SVG 渲染器
  3 +使用 matplotlib 将 LaTeX 公式渲染为 SVG 格式,用于 PDF 导出
  4 +"""
  5 +
  6 +import io
  7 +import logging
  8 +from typing import Optional
  9 +import matplotlib
  10 +import matplotlib.pyplot as plt
  11 +from matplotlib import mathtext
  12 +
  13 +# 使用非交互式后端
  14 +matplotlib.use('Agg')
  15 +
  16 +logger = logging.getLogger(__name__)
  17 +
  18 +
  19 +class MathToSVG:
  20 + """将 LaTeX 数学公式转换为 SVG 的转换器"""
  21 +
  22 + def __init__(self, font_size: int = 14, color: str = 'black'):
  23 + """
  24 + 初始化公式转换器
  25 +
  26 + Args:
  27 + font_size: 字体大小(点)
  28 + color: 文字颜色
  29 + """
  30 + self.font_size = font_size
  31 + self.color = color
  32 +
  33 + def convert_to_svg(self, latex: str, display_mode: bool = True) -> Optional[str]:
  34 + """
  35 + 将 LaTeX 公式转换为 SVG 字符串
  36 +
  37 + Args:
  38 + latex: LaTeX 公式字符串(不包含 $$ 或 $ 符号)
  39 + display_mode: True 为显示模式(块级公式),False 为行内模式
  40 +
  41 + Returns:
  42 + SVG 字符串,如果转换失败则返回 None
  43 + """
  44 + try:
  45 + # 清理 LaTeX 字符串
  46 + latex = latex.strip()
  47 + if not latex:
  48 + logger.warning("空的 LaTeX 公式")
  49 + return None
  50 +
  51 + # 创建图形
  52 + fig = plt.figure(figsize=(10, 2) if display_mode else (6, 1))
  53 + fig.patch.set_alpha(0) # 透明背景
  54 +
  55 + # 渲染 LaTeX
  56 + # 使用 mathtext 进行渲染
  57 + if display_mode:
  58 + # 显示模式:居中,较大字体
  59 + text = fig.text(
  60 + 0.5, 0.5,
  61 + f'${latex}$',
  62 + fontsize=self.font_size * 1.2,
  63 + color=self.color,
  64 + ha='center',
  65 + va='center',
  66 + usetex=False # 使用 matplotlib 内置的 mathtext 而非完整 LaTeX
  67 + )
  68 + else:
  69 + # 行内模式:左对齐,正常字体
  70 + text = fig.text(
  71 + 0.1, 0.5,
  72 + f'${latex}$',
  73 + fontsize=self.font_size,
  74 + color=self.color,
  75 + ha='left',
  76 + va='center',
  77 + usetex=False
  78 + )
  79 +
  80 + # 获取文本边界框
  81 + fig.canvas.draw()
  82 + bbox = text.get_window_extent(renderer=fig.canvas.get_renderer())
  83 +
  84 + # 转换为英寸(matplotlib 使用的单位)
  85 + bbox_inches = bbox.transformed(fig.dpi_scale_trans.inverted())
  86 +
  87 + # 调整图形大小以适应文本,添加边距
  88 + margin = 0.1 # 英寸
  89 + fig.set_size_inches(
  90 + bbox_inches.width + 2 * margin,
  91 + bbox_inches.height + 2 * margin
  92 + )
  93 +
  94 + # 重新定位文本到中心
  95 + text.set_position((0.5, 0.5))
  96 +
  97 + # 保存为 SVG
  98 + svg_buffer = io.StringIO()
  99 + plt.savefig(
  100 + svg_buffer,
  101 + format='svg',
  102 + bbox_inches='tight',
  103 + pad_inches=0.1,
  104 + transparent=True,
  105 + dpi=300
  106 + )
  107 + plt.close(fig)
  108 +
  109 + # 获取 SVG 内容
  110 + svg_content = svg_buffer.getvalue()
  111 + svg_buffer.close()
  112 +
  113 + return svg_content
  114 +
  115 + except Exception as e:
  116 + logger.error(f"LaTeX 公式转换失败: {latex[:100]}... 错误: {str(e)}")
  117 + return None
  118 +
  119 + def convert_inline_to_svg(self, latex: str) -> Optional[str]:
  120 + """
  121 + 将行内 LaTeX 公式转换为 SVG
  122 +
  123 + Args:
  124 + latex: LaTeX 公式字符串
  125 +
  126 + Returns:
  127 + SVG 字符串,如果转换失败则返回 None
  128 + """
  129 + return self.convert_to_svg(latex, display_mode=False)
  130 +
  131 + def convert_display_to_svg(self, latex: str) -> Optional[str]:
  132 + """
  133 + 将显示模式 LaTeX 公式转换为 SVG
  134 +
  135 + Args:
  136 + latex: LaTeX 公式字符串
  137 +
  138 + Returns:
  139 + SVG 字符串,如果转换失败则返回 None
  140 + """
  141 + return self.convert_to_svg(latex, display_mode=True)
  142 +
  143 +
  144 +def convert_math_block_to_svg(
  145 + latex: str,
  146 + font_size: int = 16,
  147 + color: str = 'black'
  148 +) -> Optional[str]:
  149 + """
  150 + 便捷函数:将数学公式块转换为 SVG
  151 +
  152 + Args:
  153 + latex: LaTeX 公式字符串
  154 + font_size: 字体大小
  155 + color: 文字颜色
  156 +
  157 + Returns:
  158 + SVG 字符串,如果转换失败则返回 None
  159 + """
  160 + converter = MathToSVG(font_size=font_size, color=color)
  161 + return converter.convert_display_to_svg(latex)
  162 +
  163 +
  164 +def convert_math_inline_to_svg(
  165 + latex: str,
  166 + font_size: int = 14,
  167 + color: str = 'black'
  168 +) -> Optional[str]:
  169 + """
  170 + 便捷函数:将行内数学公式转换为 SVG
  171 +
  172 + Args:
  173 + latex: LaTeX 公式字符串
  174 + font_size: 字体大小
  175 + color: 文字颜色
  176 +
  177 + Returns:
  178 + SVG 字符串,如果转换失败则返回 None
  179 + """
  180 + converter = MathToSVG(font_size=font_size, color=color)
  181 + return converter.convert_inline_to_svg(latex)
  182 +
  183 +
  184 +if __name__ == "__main__":
  185 + # 测试代码
  186 + import sys
  187 +
  188 + # 配置日志
  189 + logging.basicConfig(
  190 + level=logging.INFO,
  191 + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
  192 + )
  193 +
  194 + # 测试公式
  195 + test_formulas = [
  196 + r"E = mc^2",
  197 + r"\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}",
  198 + r"\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}",
  199 + r"\sum_{i=1}^{n} i = \frac{n(n+1)}{2}",
  200 + ]
  201 +
  202 + converter = MathToSVG(font_size=16)
  203 +
  204 + for i, formula in enumerate(test_formulas):
  205 + logger.info(f"测试公式 {i+1}: {formula}")
  206 + svg = converter.convert_display_to_svg(formula)
  207 + if svg:
  208 + # 保存到文件
  209 + filename = f"test_math_{i+1}.svg"
  210 + with open(filename, 'w', encoding='utf-8') as f:
  211 + f.write(svg)
  212 + logger.info(f"成功保存到 {filename}")
  213 + else:
  214 + logger.error(f"公式 {i+1} 转换失败")
  215 +
  216 + logger.info("测试完成")
@@ -805,6 +805,13 @@ p {{ @@ -805,6 +805,13 @@ p {{
805 line-height: {cfg.callout.line_height}; 805 line-height: {cfg.callout.line_height};
806 }} 806 }}
807 807
  808 +/* 确保 callout 内部最后一个元素不会溢出底部 */
  809 +.callout > *:last-child,
  810 +.callout > *:last-child > *:last-child {{
  811 + margin-bottom: 0 !important;
  812 + padding-bottom: 0 !important;
  813 +}}
  814 +
808 /* 表格优化 - 严格防止溢出 */ 815 /* 表格优化 - 严格防止溢出 */
809 table {{ 816 table {{
810 width: 100%; 817 width: 100%;
@@ -33,6 +33,7 @@ except Exception as e: @@ -33,6 +33,7 @@ except Exception as e:
33 from .html_renderer import HTMLRenderer 33 from .html_renderer import HTMLRenderer
34 from .pdf_layout_optimizer import PDFLayoutOptimizer, PDFLayoutConfig 34 from .pdf_layout_optimizer import PDFLayoutOptimizer, PDFLayoutConfig
35 from .chart_to_svg import create_chart_converter 35 from .chart_to_svg import create_chart_converter
  36 +from .math_to_svg import MathToSVG
36 37
37 38
38 class PDFRenderer: 39 class PDFRenderer:
@@ -71,6 +72,14 @@ class PDFRenderer: @@ -71,6 +72,14 @@ class PDFRenderer:
71 except Exception as e: 72 except Exception as e:
72 logger.warning(f"图表SVG转换器初始化失败: {e},将使用表格降级") 73 logger.warning(f"图表SVG转换器初始化失败: {e},将使用表格降级")
73 74
  75 + # 初始化数学公式转换器
  76 + try:
  77 + self.math_converter = MathToSVG(font_size=16, color='black')
  78 + logger.info("数学公式SVG转换器初始化成功")
  79 + except Exception as e:
  80 + logger.warning(f"数学公式SVG转换器初始化失败: {e},公式将显示为文本")
  81 + self.math_converter = None
  82 +
74 @staticmethod 83 @staticmethod
75 def _get_font_path() -> Path: 84 def _get_font_path() -> Path:
76 """获取字体文件路径""" 85 """获取字体文件路径"""
@@ -280,6 +289,101 @@ class PDFRenderer: @@ -280,6 +289,101 @@ class PDFRenderer:
280 if isinstance(cell_blocks, list): 289 if isinstance(cell_blocks, list):
281 self._extract_and_convert_widgets(cell_blocks, svg_map) 290 self._extract_and_convert_widgets(cell_blocks, svg_map)
282 291
  292 + def _convert_math_to_svg(self, document_ir: Dict[str, Any]) -> Dict[str, str]:
  293 + """
  294 + 将document_ir中的所有数学公式转换为SVG
  295 +
  296 + 参数:
  297 + document_ir: Document IR数据
  298 +
  299 + 返回:
  300 + Dict[str, str]: 公式块ID到SVG字符串的映射
  301 + """
  302 + svg_map = {}
  303 +
  304 + if not hasattr(self, 'math_converter') or not self.math_converter:
  305 + logger.warning("数学公式转换器未初始化,跳过公式转换")
  306 + return svg_map
  307 +
  308 + # 遍历所有章节
  309 + chapters = document_ir.get('chapters', [])
  310 + for chapter in chapters:
  311 + blocks = chapter.get('blocks', [])
  312 + self._extract_and_convert_math_blocks(blocks, svg_map)
  313 +
  314 + logger.info(f"成功转换 {len(svg_map)} 个数学公式为SVG")
  315 + return svg_map
  316 +
  317 + def _extract_and_convert_math_blocks(
  318 + self,
  319 + blocks: list,
  320 + svg_map: Dict[str, str],
  321 + block_counter: list = None
  322 + ) -> None:
  323 + """
  324 + 递归遍历blocks,找到所有math块并转换为SVG
  325 +
  326 + 参数:
  327 + blocks: block列表
  328 + svg_map: 用于存储转换结果的字典
  329 + block_counter: 用于生成唯一ID的计数器
  330 + """
  331 + if block_counter is None:
  332 + block_counter = [0]
  333 +
  334 + for block in blocks:
  335 + if not isinstance(block, dict):
  336 + continue
  337 +
  338 + block_type = block.get('type')
  339 +
  340 + # 处理math类型
  341 + if block_type == 'math':
  342 + latex = block.get('latex', '').strip()
  343 + if latex:
  344 + block_counter[0] += 1
  345 + math_id = f"math-block-{block_counter[0]}"
  346 +
  347 + try:
  348 + svg_content = self.math_converter.convert_display_to_svg(latex)
  349 + if svg_content:
  350 + svg_map[math_id] = svg_content
  351 + # 将ID添加到block中,以便后续注入时识别
  352 + block['mathId'] = math_id
  353 + logger.debug(f"公式 {math_id} 转换为SVG成功")
  354 + else:
  355 + logger.warning(f"公式 {math_id} 转换为SVG失败: {latex[:50]}...")
  356 + except Exception as e:
  357 + logger.error(f"转换公式 {latex[:50]}... 时出错: {e}")
  358 +
  359 + # 递归处理嵌套的blocks
  360 + nested_blocks = block.get('blocks')
  361 + if isinstance(nested_blocks, list):
  362 + self._extract_and_convert_math_blocks(nested_blocks, svg_map, block_counter)
  363 +
  364 + # 处理列表项
  365 + if block_type == 'list':
  366 + items = block.get('items', [])
  367 + for item in items:
  368 + if isinstance(item, list):
  369 + self._extract_and_convert_math_blocks(item, svg_map, block_counter)
  370 +
  371 + # 处理表格单元格
  372 + if block_type == 'table':
  373 + rows = block.get('rows', [])
  374 + for row in rows:
  375 + cells = row.get('cells', [])
  376 + for cell in cells:
  377 + cell_blocks = cell.get('blocks', [])
  378 + if isinstance(cell_blocks, list):
  379 + self._extract_and_convert_math_blocks(cell_blocks, svg_map, block_counter)
  380 +
  381 + # 处理callout内部的blocks
  382 + if block_type == 'callout':
  383 + callout_blocks = block.get('blocks', [])
  384 + if isinstance(callout_blocks, list):
  385 + self._extract_and_convert_math_blocks(callout_blocks, svg_map, block_counter)
  386 +
283 def _inject_svg_into_html(self, html: str, svg_map: Dict[str, str]) -> str: 387 def _inject_svg_into_html(self, html: str, svg_map: Dict[str, str]) -> str:
284 """ 388 """
285 将SVG内容直接注入到HTML中(不使用JavaScript) 389 将SVG内容直接注入到HTML中(不使用JavaScript)
@@ -326,6 +430,49 @@ class PDFRenderer: @@ -326,6 +430,49 @@ class PDFRenderer:
326 430
327 return html 431 return html
328 432
  433 + def _inject_math_svg_into_html(self, html: str, svg_map: Dict[str, str]) -> str:
  434 + """
  435 + 将数学公式SVG内容注入到HTML中
  436 +
  437 + 参数:
  438 + html: 原始HTML内容
  439 + svg_map: 公式ID到SVG内容的映射
  440 +
  441 + 返回:
  442 + str: 注入SVG后的HTML
  443 + """
  444 + if not svg_map:
  445 + return html
  446 +
  447 + import re
  448 +
  449 + # 为每个math block查找对应的div并替换为SVG
  450 + for math_id, svg_content in svg_map.items():
  451 + # 清理SVG内容(移除XML声明,因为SVG将嵌入HTML)
  452 + svg_content = re.sub(r'<\?xml[^>]+\?>', '', svg_content)
  453 + svg_content = re.sub(r'<!DOCTYPE[^>]+>', '', svg_content)
  454 + svg_content = svg_content.strip()
  455 +
  456 + # 创建SVG容器HTML
  457 + svg_html = f'<div class="math-svg-container">{svg_content}</div>'
  458 +
  459 + # 查找对应的math-block div
  460 + # 格式: <div class="math-block">$$ latex $$</div>
  461 + # 我们需要找到包含特定LaTeX内容的div
  462 + # 但由于我们在转换时已经给block添加了mathId,我们可以用另一种方式
  463 +
  464 + # 方案:在HTML渲染器中为math-block添加data-math-id属性
  465 + # 但这需要修改HTMLRenderer,暂时我们使用更简单的方法:
  466 + # 按顺序替换所有math-block
  467 +
  468 + # 暂时使用简单的替换方案
  469 + # 找到第一个math-block div并替换
  470 + math_block_pattern = r'<div class="math-block">\$\$[^$]*\$\$</div>'
  471 + html = re.sub(math_block_pattern, svg_html, html, count=1)
  472 + logger.debug(f"已替换公式 {math_id} 为SVG")
  473 +
  474 + return html
  475 +
329 def _get_pdf_html( 476 def _get_pdf_html(
330 self, 477 self,
331 document_ir: Dict[str, Any], 478 document_ir: Dict[str, Any],
@@ -375,16 +522,25 @@ class PDFRenderer: @@ -375,16 +522,25 @@ class PDFRenderer:
375 logger.info("开始转换图表为SVG矢量图形...") 522 logger.info("开始转换图表为SVG矢量图形...")
376 svg_map = self._convert_charts_to_svg(preprocessed_ir) 523 svg_map = self._convert_charts_to_svg(preprocessed_ir)
377 524
  525 + # 转换数学公式为SVG
  526 + logger.info("开始转换数学公式为SVG矢量图形...")
  527 + math_svg_map = self._convert_math_to_svg(preprocessed_ir)
  528 +
378 # 使用HTML渲染器生成基础HTML(使用原始IR,因为HTMLRenderer会自己修复) 529 # 使用HTML渲染器生成基础HTML(使用原始IR,因为HTMLRenderer会自己修复)
379 # 注意:这里仍使用原始document_ir,因为HTMLRenderer内部会进行相同的修复 530 # 注意:这里仍使用原始document_ir,因为HTMLRenderer内部会进行相同的修复
380 # 这确保了HTML和SVG使用相同的修复逻辑 531 # 这确保了HTML和SVG使用相同的修复逻辑
381 html = self.html_renderer.render(document_ir) 532 html = self.html_renderer.render(document_ir)
382 533
383 - # 注入SVG 534 + # 注入图表SVG
384 if svg_map: 535 if svg_map:
385 html = self._inject_svg_into_html(html, svg_map) 536 html = self._inject_svg_into_html(html, svg_map)
386 logger.info(f"已注入 {len(svg_map)} 个SVG图表") 537 logger.info(f"已注入 {len(svg_map)} 个SVG图表")
387 538
  539 + # 注入数学公式SVG
  540 + if math_svg_map:
  541 + html = self._inject_math_svg_into_html(html, math_svg_map)
  542 + logger.info(f"已注入 {len(math_svg_map)} 个SVG公式")
  543 +
388 # 获取字体路径并转换为base64(用于嵌入) 544 # 获取字体路径并转换为base64(用于嵌入)
389 font_path = self._get_font_path() 545 font_path = self._get_font_path()
390 font_data = font_path.read_bytes() 546 font_data = font_path.read_bytes()
@@ -439,6 +595,26 @@ body {{ @@ -439,6 +595,26 @@ body {{
439 height: auto; 595 height: auto;
440 }} 596 }}
441 597
  598 +/* 数学公式SVG容器样式 */
  599 +.math-svg-container {{
  600 + width: 100%;
  601 + height: auto;
  602 + display: flex;
  603 + justify-content: center;
  604 + align-items: center;
  605 + margin: 20px 0;
  606 +}}
  607 +
  608 +.math-svg-container svg {{
  609 + max-width: 100%;
  610 + height: auto;
  611 +}}
  612 +
  613 +/* 隐藏原始的math-block(因为已被SVG替换) */
  614 +.math-block {{
  615 + display: none !important;
  616 +}}
  617 +
442 /* 隐藏fallback表格(因为现在使用SVG) */ 618 /* 隐藏fallback表格(因为现在使用SVG) */
443 .chart-fallback {{ 619 .chart-fallback {{
444 display: none !important; 620 display: none !important;