diff --git a/ReportEngine/renderers/chart_to_svg.py b/ReportEngine/renderers/chart_to_svg.py index 94592aa..1b1b9fd 100644 --- a/ReportEngine/renderers/chart_to_svg.py +++ b/ReportEngine/renderers/chart_to_svg.py @@ -160,6 +160,19 @@ class ChartToSVGConverter: if props.get('type'): chart_type = props['type'] + # Chart.js v4已移除horizontalBar类型,这里自动降级为bar并设置横向坐标 + horizontal_bar = False + if chart_type and str(chart_type).lower() == 'horizontalbar': + chart_type = 'bar' + horizontal_bar = True + + # 支持通过indexAxis: 'y' 强制横向柱状图 + if isinstance(props, dict): + options = props.get('options') or {} + index_axis = (options.get('indexAxis') or props.get('indexAxis') or '').lower() + if index_axis == 'y': + horizontal_bar = True + # 提取数据 data = widget_data.get('data', {}) if not data: @@ -172,10 +185,16 @@ class ChartToSVGConverter: logger.debug("检测到词云图表,跳过chart_to_svg转换") return None - render_method = getattr(self, f'_render_{chart_type}', None) - if not render_method: - logger.warning(f"不支持的图表类型: {chart_type}") - return None + # 分派渲染方法,特殊处理横向柱状图 + if chart_type == 'bar': + return self._render_bar(data, props, width, height, dpi, horizontal=horizontal_bar) + elif chart_type == 'bubble': + return self._render_bubble(data, props, width, height, dpi) + else: + render_method = getattr(self, f'_render_{chart_type}', None) + if not render_method: + logger.warning(f"不支持的图表类型: {chart_type}") + return None # 创建图表并转换为SVG return render_method(data, props, width, height, dpi) @@ -687,9 +706,10 @@ class ChartToSVGConverter: props: Dict[str, Any], width: int, height: int, - dpi: int + dpi: int, + horizontal: bool = False ) -> Optional[str]: - """渲染柱状图""" + """渲染柱状图(支持横向barh)""" try: labels = data.get('labels', []) datasets = data.get('datasets', []) @@ -703,44 +723,147 @@ class ChartToSVGConverter: colors = self._get_colors(datasets) # 计算柱子位置 - x = np.arange(len(labels)) + positions = np.arange(len(labels)) width_bar = 0.8 / len(datasets) if len(datasets) > 1 else 0.6 - # 绘制每个数据系列 + # 横向/纵向绘制 for i, dataset in enumerate(datasets): dataset_data = dataset.get('data', []) label = dataset.get('label', f'系列{i+1}') color = colors[i] offset = (i - len(datasets)/2 + 0.5) * width_bar - ax.bar( - x + offset, - dataset_data, - width_bar, - label=label, - color=color, - alpha=0.8, - edgecolor='white', - linewidth=0.5 - ) - # 设置x轴标签 - ax.set_xticks(x) - ax.set_xticklabels(labels, rotation=45, ha='right') + if horizontal: + ax.barh( + positions + offset, + dataset_data, + height=width_bar, + label=label, + color=color, + alpha=0.8, + edgecolor='white', + linewidth=0.5 + ) + else: + ax.bar( + positions + offset, + dataset_data, + width_bar, + label=label, + color=color, + alpha=0.8, + edgecolor='white', + linewidth=0.5 + ) + + # 轴标签/网格 + if horizontal: + ax.set_yticks(positions) + ax.set_yticklabels(labels) + ax.invert_yaxis() # 与Chart.js横向排列保持一致 + ax.grid(True, alpha=0.3, linestyle='--', axis='x') + else: + ax.set_xticks(positions) + ax.set_xticklabels(labels, rotation=45, ha='right') + ax.grid(True, alpha=0.3, linestyle='--', axis='y') # 显示图例 if len(datasets) > 1: ax.legend(loc='best', framealpha=0.9) - # 网格 - ax.grid(True, alpha=0.3, linestyle='--', axis='y') - return self._figure_to_svg(fig) except Exception as e: logger.error(f"渲染柱状图失败: {e}") return None + def _render_bubble( + self, + data: Dict[str, Any], + props: Dict[str, Any], + width: int, + height: int, + dpi: int + ) -> Optional[str]: + """渲染气泡图""" + try: + datasets = data.get('datasets', []) + if not datasets: + return None + + title = props.get('title') + fig, ax = self._create_figure(width, height, dpi, title) + colors = self._get_colors(datasets) + + def _safe_radius(raw) -> float: + try: + val = float(raw) + return max(val, 0.5) + except Exception: + return 1.0 + + all_x: list[float] = [] + all_y: list[float] = [] + max_r: float = 0.0 + + for i, dataset in enumerate(datasets): + points = dataset.get('data', []) + label = dataset.get('label', f'系列{i+1}') + color = colors[i] + + if points and isinstance(points[0], dict): + xs = [p.get('x', 0) for p in points] + ys = [p.get('y', 0) for p in points] + rs = [_safe_radius(p.get('r', 1)) for p in points] + else: + xs = list(range(len(points))) + ys = points + rs = [1.0 for _ in points] + + all_x.extend(xs) + all_y.extend(ys) + if rs: + max_r = max(max_r, max(rs)) + + # 适度放大半径,近似Chart.js像素尺寸(动态尺度,避免过大遮挡) + size_scale = 8.0 if max_r <= 20 else 6.5 + sizes = [(r * size_scale) ** 2 for r in rs] + + ax.scatter( + xs, + ys, + s=sizes, + label=label, + color=color, + alpha=0.45, + edgecolors='white', + linewidth=0.6 + ) + + if len(datasets) > 1: + ax.legend(loc='best', framealpha=0.9) + + # 适度留白,避免大气泡被裁切 + if all_x and all_y: + x_min, x_max = min(all_x), max(all_x) + y_min, y_max = min(all_y), max(all_y) + x_span = max(x_max - x_min, 1e-6) + y_span = max(y_max - y_min, 1e-6) + pad_x = max(x_span * 0.12, max_r * 1.2) + pad_y = max(y_span * 0.12, max_r * 1.2) + ax.set_xlim(x_min - pad_x, x_max + pad_x) + ax.set_ylim(y_min - pad_y, y_max + pad_y) + # 额外安全边距 + ax.margins(x=0.05, y=0.05) + + ax.grid(True, alpha=0.3, linestyle='--') + return self._figure_to_svg(fig) + + except Exception as e: + logger.error(f"渲染气泡图失败: {e}", exc_info=True) + return None + def _render_pie( self, data: Dict[str, Any], diff --git a/ReportEngine/renderers/html_renderer.py b/ReportEngine/renderers/html_renderer.py index 2376eb6..730e79e 100644 --- a/ReportEngine/renderers/html_renderer.py +++ b/ReportEngine/renderers/html_renderer.py @@ -1263,7 +1263,9 @@ class HTMLRenderer: def _render_math(self, block: Dict[str, Any]) -> str: """渲染数学公式,占位符交给外部MathJax或后处理""" latex = self._escape_html(block.get("latex", "")) - return f'
$$ {latex} $$
' + math_id = self._escape_attr(block.get("mathId", "")) if block.get("mathId") else "" + id_attr = f' data-math-id="{math_id}"' if math_id else "" + return f'
$$ {latex} $$
' def _render_figure(self, block: Dict[str, Any]) -> str: """根据新规范默认不渲染外部图片,改为友好提示""" @@ -2012,7 +2014,9 @@ class HTMLRenderer: latex = math_mark.get("value") if not isinstance(latex, str) or not latex.strip(): latex = text_value - return f'\\( {self._escape_html(latex)} \\)' + math_id = self._escape_attr(run.get("mathId", "")) if run.get("mathId") else "" + id_attr = f' data-math-id="{math_id}"' if math_id else "" + return f'\\( {self._escape_html(latex)} \\)' text = self._escape_html(text_value) styles: List[str] = [] prefix: List[str] = [] diff --git a/ReportEngine/renderers/pdf_renderer.py b/ReportEngine/renderers/pdf_renderer.py index 699125e..3bc7089 100644 --- a/ReportEngine/renderers/pdf_renderer.py +++ b/ReportEngine/renderers/pdf_renderer.py @@ -535,6 +535,33 @@ class PDFRenderer: if block_counter is None: block_counter = [0] + def _extract_inline_math_from_inlines(inlines: list): + """从段落内联节点中提取数学公式""" + if not isinstance(inlines, list): + return + for run in inlines: + if not isinstance(run, dict): + continue + marks = run.get('marks') or [] + math_mark = next((m for m in marks if m.get('type') == 'math'), None) + if not math_mark: + continue + latex = (math_mark.get('value') or run.get('text') or '').strip() + if not latex: + continue + block_counter[0] += 1 + math_id = f"math-inline-{block_counter[0]}" + try: + svg_content = self.math_converter.convert_inline_to_svg(latex) + if svg_content: + svg_map[math_id] = svg_content + run['mathId'] = math_id + logger.debug(f"公式 {math_id} 转换为SVG成功") + else: + logger.warning(f"公式 {math_id} 转换为SVG失败: {latex[:50]}...") + except Exception as exc: + logger.error(f"转换内联公式 {latex[:50]}... 时出错: {exc}") + for block in blocks: if not isinstance(block, dict): continue @@ -547,7 +574,6 @@ class PDFRenderer: 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: @@ -559,6 +585,11 @@ class PDFRenderer: logger.warning(f"公式 {math_id} 转换为SVG失败: {latex[:50]}...") except Exception as e: logger.error(f"转换公式 {latex[:50]}... 时出错: {e}") + else: + # 提取段落、表格等内部的内联公式 + inlines = block.get('inlines') + if inlines: + _extract_inline_math_from_inlines(inlines) # 递归处理嵌套的blocks nested_blocks = block.get('blocks') @@ -614,9 +645,8 @@ class PDFRenderer: # 创建SVG容器HTML svg_html = f'
{svg_content}
' - # 查找包含此widgetId的配置脚本 - # 格式: - config_pattern = rf']+id="([^"]+)"[^>]*>\s*\{{[^}}]*"widgetId"\s*:\s*"{re.escape(widget_id)}"[^}}]*\}}' + # 查找包含此widgetId的配置脚本(限制在同一个内,避免跨标签误配) + config_pattern = rf']+id="([^"]+)"[^>]*>(?:(?!).)*?"widgetId"\s*:\s*"{re.escape(widget_id)}"(?:(?!).)*?' match = re.search(config_pattern, html, re.DOTALL) if match: @@ -627,8 +657,11 @@ class PDFRenderer: canvas_pattern = rf']+data-config-id="{re.escape(config_id)}"[^>]*>' # 【修复】替换canvas为SVG,使用lambda避免反斜杠转义问题 - html = re.sub(canvas_pattern, lambda m: svg_html, html) - logger.debug(f"已替换图表 {widget_id} 的canvas为SVG") + html, replaced = re.subn(canvas_pattern, lambda m: svg_html, html, count=1) + if replaced: + logger.debug(f"已替换图表 {widget_id} 的canvas为SVG") + else: + logger.warning(f"未找到图表 {widget_id} 的canvas进行替换") # 将对应fallback标记为隐藏,避免PDF中出现重复表格 fallback_pattern = rf'
]*data-widget-id="{re.escape(widget_id)}"[^>]*)>' @@ -661,7 +694,7 @@ class PDFRenderer: f'
' ) - config_pattern = rf']+id="([^"]+)"[^>]*>\s*\{{[^}}]*"widgetId"\s*:\s*"{re.escape(widget_id)}"[^}}]*\}}' + config_pattern = rf']+id="([^"]+)"[^>]*>(?:(?!).)*?"widgetId"\s*:\s*"{re.escape(widget_id)}"(?:(?!).)*?' match = re.search(config_pattern, html, re.DOTALL) if not match: logger.debug(f"未找到词云 {widget_id} 的配置脚本,跳过注入") @@ -670,8 +703,11 @@ class PDFRenderer: config_id = match.group(1) canvas_pattern = rf']+data-config-id="{re.escape(config_id)}"[^>]*>' - html = re.sub(canvas_pattern, lambda m: img_html, html) - logger.debug(f"已替换词云 {widget_id} 的canvas为PNG图片") + html, replaced = re.subn(canvas_pattern, lambda m: img_html, html, count=1) + if replaced: + logger.debug(f"已替换词云 {widget_id} 的canvas为PNG图片") + else: + logger.warning(f"未找到词云 {widget_id} 的canvas进行替换") fallback_pattern = rf'
]*data-widget-id="{re.escape(widget_id)}"[^>]*)>' @@ -701,32 +737,40 @@ class PDFRenderer: 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}
' + svg_block_html = f'
{svg_content}
' + svg_inline_html = f'{svg_content}' - # 查找对应的math-block div - # 格式:
$$ latex $$
- # 我们需要找到包含特定LaTeX内容的div - # 但由于我们在转换时已经给block添加了mathId,我们可以用另一种方式 + replaced = False + # 优先按 data-math-id 精确替换 + inline_pattern = rf']*data-math-id="{re.escape(math_id)}"[^>]*>.*?' + if re.search(inline_pattern, html, re.DOTALL): + html = re.sub(inline_pattern, lambda m: svg_inline_html, html, count=1) + replaced = True + else: + block_pattern = rf'
]*data-math-id="{re.escape(math_id)}"[^>]*>.*?
' + if re.search(block_pattern, html, re.DOTALL): + html = re.sub(block_pattern, lambda m: svg_block_html, html, count=1) + replaced = True - # 方案:在HTML渲染器中为math-block添加data-math-id属性 - # 但这需要修改HTMLRenderer,暂时我们使用更简单的方法: - # 按顺序替换所有math-block + # 如果没有找到特定ID,按出现顺序兜底替换 + if not replaced: + html, sub_inline = re.subn(r'[^<]*', lambda m: svg_inline_html, html, count=1) + if sub_inline: + replaced = True + else: + html, sub_block = re.subn(r'
\$\$[^$]*\$\$
', lambda m: svg_block_html, html, count=1) + if sub_block: + replaced = True - # 暂时使用简单的替换方案 - # 找到第一个math-block div并替换 - math_block_pattern = r'
\$\$[^$]*\$\$
' - # 【修复】使用lambda函数避免re.sub将SVG内容中的反斜杠解释为转义序列 - # lambda函数中的返回值会被当作字面字符串,不会进行转义处理 - html = re.sub(math_block_pattern, lambda m: svg_html, html, count=1) - logger.debug(f"已替换公式 {math_id} 为SVG") + if replaced: + logger.debug(f"已替换公式 {math_id} 为SVG") return html @@ -787,10 +831,8 @@ class PDFRenderer: 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) + # 使用HTML渲染器生成基础HTML(使用预处理后的IR,以便复用mathId等标记) + html = self.html_renderer.render(preprocessed_ir) # 注入图表SVG if svg_map: