diff --git a/ReportEngine/renderers/math_to_svg.py b/ReportEngine/renderers/math_to_svg.py new file mode 100644 index 0000000..5933164 --- /dev/null +++ b/ReportEngine/renderers/math_to_svg.py @@ -0,0 +1,216 @@ +""" +LaTeX 数学公式转 SVG 渲染器 +使用 matplotlib 将 LaTeX 公式渲染为 SVG 格式,用于 PDF 导出 +""" + +import io +import logging +from typing import Optional +import matplotlib +import matplotlib.pyplot as plt +from matplotlib import mathtext + +# 使用非交互式后端 +matplotlib.use('Agg') + +logger = logging.getLogger(__name__) + + +class MathToSVG: + """将 LaTeX 数学公式转换为 SVG 的转换器""" + + def __init__(self, font_size: int = 14, color: str = 'black'): + """ + 初始化公式转换器 + + Args: + font_size: 字体大小(点) + color: 文字颜色 + """ + self.font_size = font_size + self.color = color + + def convert_to_svg(self, latex: str, display_mode: bool = True) -> Optional[str]: + """ + 将 LaTeX 公式转换为 SVG 字符串 + + Args: + latex: LaTeX 公式字符串(不包含 $$ 或 $ 符号) + display_mode: True 为显示模式(块级公式),False 为行内模式 + + Returns: + SVG 字符串,如果转换失败则返回 None + """ + try: + # 清理 LaTeX 字符串 + latex = latex.strip() + if not latex: + logger.warning("空的 LaTeX 公式") + return None + + # 创建图形 + fig = plt.figure(figsize=(10, 2) if display_mode else (6, 1)) + fig.patch.set_alpha(0) # 透明背景 + + # 渲染 LaTeX + # 使用 mathtext 进行渲染 + if display_mode: + # 显示模式:居中,较大字体 + text = fig.text( + 0.5, 0.5, + f'${latex}$', + fontsize=self.font_size * 1.2, + color=self.color, + ha='center', + va='center', + usetex=False # 使用 matplotlib 内置的 mathtext 而非完整 LaTeX + ) + else: + # 行内模式:左对齐,正常字体 + text = fig.text( + 0.1, 0.5, + f'${latex}$', + fontsize=self.font_size, + color=self.color, + ha='left', + va='center', + usetex=False + ) + + # 获取文本边界框 + fig.canvas.draw() + bbox = text.get_window_extent(renderer=fig.canvas.get_renderer()) + + # 转换为英寸(matplotlib 使用的单位) + bbox_inches = bbox.transformed(fig.dpi_scale_trans.inverted()) + + # 调整图形大小以适应文本,添加边距 + margin = 0.1 # 英寸 + fig.set_size_inches( + bbox_inches.width + 2 * margin, + bbox_inches.height + 2 * margin + ) + + # 重新定位文本到中心 + text.set_position((0.5, 0.5)) + + # 保存为 SVG + svg_buffer = io.StringIO() + plt.savefig( + svg_buffer, + format='svg', + bbox_inches='tight', + pad_inches=0.1, + transparent=True, + dpi=300 + ) + plt.close(fig) + + # 获取 SVG 内容 + svg_content = svg_buffer.getvalue() + svg_buffer.close() + + return svg_content + + except Exception as e: + logger.error(f"LaTeX 公式转换失败: {latex[:100]}... 错误: {str(e)}") + return None + + def convert_inline_to_svg(self, latex: str) -> Optional[str]: + """ + 将行内 LaTeX 公式转换为 SVG + + Args: + latex: LaTeX 公式字符串 + + Returns: + SVG 字符串,如果转换失败则返回 None + """ + return self.convert_to_svg(latex, display_mode=False) + + def convert_display_to_svg(self, latex: str) -> Optional[str]: + """ + 将显示模式 LaTeX 公式转换为 SVG + + Args: + latex: LaTeX 公式字符串 + + Returns: + SVG 字符串,如果转换失败则返回 None + """ + return self.convert_to_svg(latex, display_mode=True) + + +def convert_math_block_to_svg( + latex: str, + font_size: int = 16, + color: str = 'black' +) -> Optional[str]: + """ + 便捷函数:将数学公式块转换为 SVG + + Args: + latex: LaTeX 公式字符串 + font_size: 字体大小 + color: 文字颜色 + + Returns: + SVG 字符串,如果转换失败则返回 None + """ + converter = MathToSVG(font_size=font_size, color=color) + return converter.convert_display_to_svg(latex) + + +def convert_math_inline_to_svg( + latex: str, + font_size: int = 14, + color: str = 'black' +) -> Optional[str]: + """ + 便捷函数:将行内数学公式转换为 SVG + + Args: + latex: LaTeX 公式字符串 + font_size: 字体大小 + color: 文字颜色 + + Returns: + SVG 字符串,如果转换失败则返回 None + """ + converter = MathToSVG(font_size=font_size, color=color) + return converter.convert_inline_to_svg(latex) + + +if __name__ == "__main__": + # 测试代码 + import sys + + # 配置日志 + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + # 测试公式 + test_formulas = [ + r"E = mc^2", + r"\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}", + r"\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}", + r"\sum_{i=1}^{n} i = \frac{n(n+1)}{2}", + ] + + converter = MathToSVG(font_size=16) + + for i, formula in enumerate(test_formulas): + logger.info(f"测试公式 {i+1}: {formula}") + svg = converter.convert_display_to_svg(formula) + if svg: + # 保存到文件 + filename = f"test_math_{i+1}.svg" + with open(filename, 'w', encoding='utf-8') as f: + f.write(svg) + logger.info(f"成功保存到 {filename}") + else: + logger.error(f"公式 {i+1} 转换失败") + + logger.info("测试完成") diff --git a/ReportEngine/renderers/pdf_layout_optimizer.py b/ReportEngine/renderers/pdf_layout_optimizer.py index b70294c..0e56ed8 100644 --- a/ReportEngine/renderers/pdf_layout_optimizer.py +++ b/ReportEngine/renderers/pdf_layout_optimizer.py @@ -805,6 +805,13 @@ p {{ line-height: {cfg.callout.line_height}; }} +/* 确保 callout 内部最后一个元素不会溢出底部 */ +.callout > *:last-child, +.callout > *:last-child > *:last-child {{ + margin-bottom: 0 !important; + padding-bottom: 0 !important; +}} + /* 表格优化 - 严格防止溢出 */ table {{ width: 100%; diff --git a/ReportEngine/renderers/pdf_renderer.py b/ReportEngine/renderers/pdf_renderer.py index 936e2fa..8944f7e 100644 --- a/ReportEngine/renderers/pdf_renderer.py +++ b/ReportEngine/renderers/pdf_renderer.py @@ -33,6 +33,7 @@ except Exception as e: from .html_renderer import HTMLRenderer from .pdf_layout_optimizer import PDFLayoutOptimizer, PDFLayoutConfig from .chart_to_svg import create_chart_converter +from .math_to_svg import MathToSVG class PDFRenderer: @@ -71,6 +72,14 @@ class PDFRenderer: except Exception as e: logger.warning(f"图表SVG转换器初始化失败: {e},将使用表格降级") + # 初始化数学公式转换器 + try: + self.math_converter = MathToSVG(font_size=16, color='black') + logger.info("数学公式SVG转换器初始化成功") + except Exception as e: + logger.warning(f"数学公式SVG转换器初始化失败: {e},公式将显示为文本") + self.math_converter = None + @staticmethod def _get_font_path() -> Path: """获取字体文件路径""" @@ -280,6 +289,101 @@ class PDFRenderer: if isinstance(cell_blocks, list): self._extract_and_convert_widgets(cell_blocks, svg_map) + def _convert_math_to_svg(self, document_ir: Dict[str, Any]) -> Dict[str, str]: + """ + 将document_ir中的所有数学公式转换为SVG + + 参数: + document_ir: Document IR数据 + + 返回: + Dict[str, str]: 公式块ID到SVG字符串的映射 + """ + svg_map = {} + + if not hasattr(self, 'math_converter') or not self.math_converter: + logger.warning("数学公式转换器未初始化,跳过公式转换") + return svg_map + + # 遍历所有章节 + chapters = document_ir.get('chapters', []) + for chapter in chapters: + blocks = chapter.get('blocks', []) + self._extract_and_convert_math_blocks(blocks, svg_map) + + logger.info(f"成功转换 {len(svg_map)} 个数学公式为SVG") + return svg_map + + def _extract_and_convert_math_blocks( + self, + blocks: list, + svg_map: Dict[str, str], + block_counter: list = None + ) -> None: + """ + 递归遍历blocks,找到所有math块并转换为SVG + + 参数: + blocks: block列表 + svg_map: 用于存储转换结果的字典 + block_counter: 用于生成唯一ID的计数器 + """ + if block_counter is None: + block_counter = [0] + + for block in blocks: + if not isinstance(block, dict): + continue + + block_type = block.get('type') + + # 处理math类型 + if block_type == 'math': + latex = block.get('latex', '').strip() + if latex: + block_counter[0] += 1 + math_id = f"math-block-{block_counter[0]}" + + try: + svg_content = self.math_converter.convert_display_to_svg(latex) + if svg_content: + svg_map[math_id] = svg_content + # 将ID添加到block中,以便后续注入时识别 + block['mathId'] = math_id + logger.debug(f"公式 {math_id} 转换为SVG成功") + else: + logger.warning(f"公式 {math_id} 转换为SVG失败: {latex[:50]}...") + except Exception as e: + logger.error(f"转换公式 {latex[:50]}... 时出错: {e}") + + # 递归处理嵌套的blocks + nested_blocks = block.get('blocks') + if isinstance(nested_blocks, list): + self._extract_and_convert_math_blocks(nested_blocks, svg_map, block_counter) + + # 处理列表项 + if block_type == 'list': + items = block.get('items', []) + for item in items: + if isinstance(item, list): + self._extract_and_convert_math_blocks(item, svg_map, block_counter) + + # 处理表格单元格 + if block_type == 'table': + rows = block.get('rows', []) + for row in rows: + cells = row.get('cells', []) + for cell in cells: + cell_blocks = cell.get('blocks', []) + if isinstance(cell_blocks, list): + self._extract_and_convert_math_blocks(cell_blocks, svg_map, block_counter) + + # 处理callout内部的blocks + if block_type == 'callout': + callout_blocks = block.get('blocks', []) + if isinstance(callout_blocks, list): + self._extract_and_convert_math_blocks(callout_blocks, svg_map, block_counter) + def _inject_svg_into_html(self, html: str, svg_map: Dict[str, str]) -> str: """ 将SVG内容直接注入到HTML中(不使用JavaScript) @@ -326,6 +430,49 @@ class PDFRenderer: return html + def _inject_math_svg_into_html(self, html: str, svg_map: Dict[str, str]) -> str: + """ + 将数学公式SVG内容注入到HTML中 + + 参数: + html: 原始HTML内容 + svg_map: 公式ID到SVG内容的映射 + + 返回: + str: 注入SVG后的HTML + """ + if not svg_map: + return html + + import re + + # 为每个math block查找对应的div并替换为SVG + for math_id, svg_content in svg_map.items(): + # 清理SVG内容(移除XML声明,因为SVG将嵌入HTML) + svg_content = re.sub(r'<\?xml[^>]+\?>', '', svg_content) + svg_content = re.sub(r']+>', '', svg_content) + svg_content = svg_content.strip() + + # 创建SVG容器HTML + svg_html = f'
{svg_content}
' + + # 查找对应的math-block div + # 格式:
$$ latex $$
+ # 我们需要找到包含特定LaTeX内容的div + # 但由于我们在转换时已经给block添加了mathId,我们可以用另一种方式 + + # 方案:在HTML渲染器中为math-block添加data-math-id属性 + # 但这需要修改HTMLRenderer,暂时我们使用更简单的方法: + # 按顺序替换所有math-block + + # 暂时使用简单的替换方案 + # 找到第一个math-block div并替换 + math_block_pattern = r'
\$\$[^$]*\$\$
' + html = re.sub(math_block_pattern, svg_html, html, count=1) + logger.debug(f"已替换公式 {math_id} 为SVG") + + return html + def _get_pdf_html( self, document_ir: Dict[str, Any], @@ -375,16 +522,25 @@ class PDFRenderer: logger.info("开始转换图表为SVG矢量图形...") svg_map = self._convert_charts_to_svg(preprocessed_ir) + # 转换数学公式为SVG + logger.info("开始转换数学公式为SVG矢量图形...") + math_svg_map = self._convert_math_to_svg(preprocessed_ir) + # 使用HTML渲染器生成基础HTML(使用原始IR,因为HTMLRenderer会自己修复) # 注意:这里仍使用原始document_ir,因为HTMLRenderer内部会进行相同的修复 # 这确保了HTML和SVG使用相同的修复逻辑 html = self.html_renderer.render(document_ir) - # 注入SVG + # 注入图表SVG if svg_map: html = self._inject_svg_into_html(html, svg_map) logger.info(f"已注入 {len(svg_map)} 个SVG图表") + # 注入数学公式SVG + if math_svg_map: + html = self._inject_math_svg_into_html(html, math_svg_map) + logger.info(f"已注入 {len(math_svg_map)} 个SVG公式") + # 获取字体路径并转换为base64(用于嵌入) font_path = self._get_font_path() font_data = font_path.read_bytes() @@ -439,6 +595,26 @@ body {{ height: auto; }} +/* 数学公式SVG容器样式 */ +.math-svg-container {{ + width: 100%; + height: auto; + display: flex; + justify-content: center; + align-items: center; + margin: 20px 0; +}} + +.math-svg-container svg {{ + max-width: 100%; + height: auto; +}} + +/* 隐藏原始的math-block(因为已被SVG替换) */ +.math-block {{ + display: none !important; +}} + /* 隐藏fallback表格(因为现在使用SVG) */ .chart-fallback {{ display: none !important;