马一丁

Update the PDF Rendering Logic and Add Support for Vector Graphics

  1 +"""
  2 +图表到SVG转换器 - 将Chart.js数据转换为矢量SVG图形
  3 +
  4 +支持的图表类型:
  5 +- line: 折线图
  6 +- bar: 柱状图
  7 +- pie: 饼图
  8 +- doughnut: 圆环图
  9 +- radar: 雷达图
  10 +- polarArea: 极地区域图
  11 +- scatter: 散点图
  12 +"""
  13 +
  14 +from __future__ import annotations
  15 +
  16 +import base64
  17 +import io
  18 +import re
  19 +from typing import Any, Dict, List, Optional, Tuple
  20 +from loguru import logger
  21 +
  22 +try:
  23 + import matplotlib
  24 + matplotlib.use('Agg') # 使用非GUI后端
  25 + import matplotlib.pyplot as plt
  26 + import matplotlib.font_manager as fm
  27 + from matplotlib.patches import Wedge
  28 + import numpy as np
  29 + MATPLOTLIB_AVAILABLE = True
  30 +except ImportError:
  31 + MATPLOTLIB_AVAILABLE = False
  32 + logger.warning("Matplotlib未安装,PDF图表矢量渲染功能将不可用")
  33 +
  34 +
  35 +class ChartToSVGConverter:
  36 + """
  37 + 将Chart.js图表数据转换为SVG矢量图形
  38 + """
  39 +
  40 + # 默认颜色调色板(与Chart.js默认颜色接近)
  41 + DEFAULT_COLORS = [
  42 + '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
  43 + '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
  44 + ]
  45 +
  46 + def __init__(self, font_path: Optional[str] = None):
  47 + """
  48 + 初始化转换器
  49 +
  50 + 参数:
  51 + font_path: 中文字体路径(可选)
  52 + """
  53 + if not MATPLOTLIB_AVAILABLE:
  54 + raise RuntimeError("Matplotlib未安装,请运行: pip install matplotlib")
  55 +
  56 + self.font_path = font_path
  57 + self._setup_chinese_font()
  58 +
  59 + def _setup_chinese_font(self):
  60 + """配置中文字体"""
  61 + if self.font_path:
  62 + try:
  63 + # 添加自定义字体
  64 + fm.fontManager.addfont(self.font_path)
  65 + # 设置默认字体
  66 + font_prop = fm.FontProperties(fname=self.font_path)
  67 + plt.rcParams['font.family'] = font_prop.get_name()
  68 + plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
  69 + logger.info(f"已加载中文字体: {self.font_path}")
  70 + except Exception as e:
  71 + logger.warning(f"加载中文字体失败: {e},将使用系统默认字体")
  72 + else:
  73 + # 尝试使用系统中文字体
  74 + try:
  75 + plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans']
  76 + plt.rcParams['axes.unicode_minus'] = False
  77 + except Exception as e:
  78 + logger.warning(f"配置中文字体失败: {e}")
  79 +
  80 + def convert_widget_to_svg(
  81 + self,
  82 + widget_data: Dict[str, Any],
  83 + width: int = 800,
  84 + height: int = 500,
  85 + dpi: int = 100
  86 + ) -> Optional[str]:
  87 + """
  88 + 将widget数据转换为SVG字符串
  89 +
  90 + 参数:
  91 + widget_data: widget块数据(包含widgetType、data、props)
  92 + width: 图表宽度(像素)
  93 + height: 图表高度(像素)
  94 + dpi: DPI设置
  95 +
  96 + 返回:
  97 + str: SVG字符串,失败返回None
  98 + """
  99 + try:
  100 + # 提取图表类型
  101 + widget_type = widget_data.get('widgetType', '')
  102 + if not widget_type or not widget_type.startswith('chart.js'):
  103 + logger.warning(f"不支持的widget类型: {widget_type}")
  104 + return None
  105 +
  106 + # 从widgetType中提取图表类型,例如 "chart.js/line" -> "line"
  107 + chart_type = widget_type.split('/')[-1] if '/' in widget_type else 'bar'
  108 +
  109 + # 也检查props中的type
  110 + props = widget_data.get('props', {})
  111 + if props.get('type'):
  112 + chart_type = props['type']
  113 +
  114 + # 提取数据
  115 + data = widget_data.get('data', {})
  116 + if not data:
  117 + logger.warning("图表数据为空")
  118 + return None
  119 +
  120 + # 根据图表类型调用相应的渲染方法
  121 + render_method = getattr(self, f'_render_{chart_type}', None)
  122 + if not render_method:
  123 + logger.warning(f"不支持的图表类型: {chart_type}")
  124 + return None
  125 +
  126 + # 创建图表并转换为SVG
  127 + return render_method(data, props, width, height, dpi)
  128 +
  129 + except Exception as e:
  130 + logger.error(f"转换图表为SVG失败: {e}", exc_info=True)
  131 + return None
  132 +
  133 + def _create_figure(
  134 + self,
  135 + width: int,
  136 + height: int,
  137 + dpi: int,
  138 + title: Optional[str] = None
  139 + ) -> Tuple[Any, Any]:
  140 + """
  141 + 创建matplotlib图表
  142 +
  143 + 返回:
  144 + tuple: (fig, ax)
  145 + """
  146 + fig, ax = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi)
  147 +
  148 + if title:
  149 + ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
  150 +
  151 + return fig, ax
  152 +
  153 + def _parse_color(self, color: Any) -> str:
  154 + """
  155 + 解析颜色值,将CSS格式转换为matplotlib支持的格式
  156 +
  157 + 参数:
  158 + color: 颜色值(可能是CSS格式如rgba()或十六进制)
  159 +
  160 + 返回:
  161 + str: matplotlib支持的颜色格式
  162 + """
  163 + if not isinstance(color, str):
  164 + return str(color)
  165 +
  166 + color = color.strip()
  167 +
  168 + # 处理rgba(r, g, b, a)格式
  169 + rgba_pattern = r'rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)'
  170 + match = re.match(rgba_pattern, color)
  171 + if match:
  172 + r, g, b, a = match.groups()
  173 + # 转换为matplotlib格式 (r/255, g/255, b/255, a)
  174 + return (int(r)/255, int(g)/255, int(b)/255, float(a))
  175 +
  176 + # 处理rgb(r, g, b)格式
  177 + rgb_pattern = r'rgb\((\d+),\s*(\d+),\s*(\d+)\)'
  178 + match = re.match(rgb_pattern, color)
  179 + if match:
  180 + r, g, b = match.groups()
  181 + # 转换为matplotlib格式 (r/255, g/255, b/255)
  182 + return (int(r)/255, int(g)/255, int(b)/255)
  183 +
  184 + # 其他格式(十六进制、颜色名等)直接返回
  185 + return color
  186 +
  187 + def _get_colors(self, datasets: List[Dict[str, Any]]) -> List[str]:
  188 + """
  189 + 获取图表颜色
  190 +
  191 + 优先使用dataset中定义的颜色,否则使用默认调色板
  192 + """
  193 + colors = []
  194 + for i, dataset in enumerate(datasets):
  195 + # 尝试获取各种可能的颜色字段
  196 + color = (
  197 + dataset.get('backgroundColor') or
  198 + dataset.get('borderColor') or
  199 + dataset.get('color') or
  200 + self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
  201 + )
  202 +
  203 + # 如果是颜色数组,取第一个
  204 + if isinstance(color, list):
  205 + color = color[0] if color else self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
  206 +
  207 + # 解析颜色格式
  208 + color = self._parse_color(color)
  209 +
  210 + colors.append(color)
  211 +
  212 + return colors
  213 +
  214 + def _figure_to_svg(self, fig: Any) -> str:
  215 + """
  216 + 将matplotlib图表转换为SVG字符串
  217 + """
  218 + svg_buffer = io.BytesIO()
  219 + fig.savefig(svg_buffer, format='svg', bbox_inches='tight', transparent=False, facecolor='white')
  220 + plt.close(fig)
  221 +
  222 + svg_buffer.seek(0)
  223 + svg_string = svg_buffer.getvalue().decode('utf-8')
  224 +
  225 + return svg_string
  226 +
  227 + def _render_line(
  228 + self,
  229 + data: Dict[str, Any],
  230 + props: Dict[str, Any],
  231 + width: int,
  232 + height: int,
  233 + dpi: int
  234 + ) -> Optional[str]:
  235 + """渲染折线图"""
  236 + try:
  237 + labels = data.get('labels', [])
  238 + datasets = data.get('datasets', [])
  239 +
  240 + if not labels or not datasets:
  241 + return None
  242 +
  243 + title = props.get('title')
  244 + fig, ax = self._create_figure(width, height, dpi, title)
  245 +
  246 + colors = self._get_colors(datasets)
  247 +
  248 + # 绘制每个数据系列
  249 + for i, dataset in enumerate(datasets):
  250 + dataset_data = dataset.get('data', [])
  251 + label = dataset.get('label', f'系列{i+1}')
  252 + color = colors[i]
  253 +
  254 + # 绘制折线
  255 + ax.plot(
  256 + range(len(labels)),
  257 + dataset_data,
  258 + marker='o',
  259 + label=label,
  260 + color=color,
  261 + linewidth=2,
  262 + markersize=6
  263 + )
  264 +
  265 + # 设置x轴标签
  266 + ax.set_xticks(range(len(labels)))
  267 + ax.set_xticklabels(labels, rotation=45, ha='right')
  268 +
  269 + # 显示图例
  270 + if len(datasets) > 1:
  271 + ax.legend(loc='best', framealpha=0.9)
  272 +
  273 + # 网格
  274 + ax.grid(True, alpha=0.3, linestyle='--')
  275 +
  276 + return self._figure_to_svg(fig)
  277 +
  278 + except Exception as e:
  279 + logger.error(f"渲染折线图失败: {e}")
  280 + return None
  281 +
  282 + def _render_bar(
  283 + self,
  284 + data: Dict[str, Any],
  285 + props: Dict[str, Any],
  286 + width: int,
  287 + height: int,
  288 + dpi: int
  289 + ) -> Optional[str]:
  290 + """渲染柱状图"""
  291 + try:
  292 + labels = data.get('labels', [])
  293 + datasets = data.get('datasets', [])
  294 +
  295 + if not labels or not datasets:
  296 + return None
  297 +
  298 + title = props.get('title')
  299 + fig, ax = self._create_figure(width, height, dpi, title)
  300 +
  301 + colors = self._get_colors(datasets)
  302 +
  303 + # 计算柱子位置
  304 + x = np.arange(len(labels))
  305 + width_bar = 0.8 / len(datasets) if len(datasets) > 1 else 0.6
  306 +
  307 + # 绘制每个数据系列
  308 + for i, dataset in enumerate(datasets):
  309 + dataset_data = dataset.get('data', [])
  310 + label = dataset.get('label', f'系列{i+1}')
  311 + color = colors[i]
  312 +
  313 + offset = (i - len(datasets)/2 + 0.5) * width_bar
  314 + ax.bar(
  315 + x + offset,
  316 + dataset_data,
  317 + width_bar,
  318 + label=label,
  319 + color=color,
  320 + alpha=0.8,
  321 + edgecolor='white',
  322 + linewidth=0.5
  323 + )
  324 +
  325 + # 设置x轴标签
  326 + ax.set_xticks(x)
  327 + ax.set_xticklabels(labels, rotation=45, ha='right')
  328 +
  329 + # 显示图例
  330 + if len(datasets) > 1:
  331 + ax.legend(loc='best', framealpha=0.9)
  332 +
  333 + # 网格
  334 + ax.grid(True, alpha=0.3, linestyle='--', axis='y')
  335 +
  336 + return self._figure_to_svg(fig)
  337 +
  338 + except Exception as e:
  339 + logger.error(f"渲染柱状图失败: {e}")
  340 + return None
  341 +
  342 + def _render_pie(
  343 + self,
  344 + data: Dict[str, Any],
  345 + props: Dict[str, Any],
  346 + width: int,
  347 + height: int,
  348 + dpi: int
  349 + ) -> Optional[str]:
  350 + """渲染饼图"""
  351 + try:
  352 + labels = data.get('labels', [])
  353 + datasets = data.get('datasets', [])
  354 +
  355 + if not labels or not datasets:
  356 + return None
  357 +
  358 + # 饼图只使用第一个数据集
  359 + dataset = datasets[0]
  360 + dataset_data = dataset.get('data', [])
  361 +
  362 + title = props.get('title')
  363 + fig, ax = self._create_figure(width, height, dpi, title)
  364 +
  365 + # 获取颜色
  366 + colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)])
  367 + if not isinstance(colors, list):
  368 + colors = self.DEFAULT_COLORS[:len(labels)]
  369 +
  370 + # 绘制饼图
  371 + wedges, texts, autotexts = ax.pie(
  372 + dataset_data,
  373 + labels=labels,
  374 + colors=colors,
  375 + autopct='%1.1f%%',
  376 + startangle=90,
  377 + textprops={'fontsize': 10}
  378 + )
  379 +
  380 + # 设置百分比文字为白色
  381 + for autotext in autotexts:
  382 + autotext.set_color('white')
  383 + autotext.set_fontweight('bold')
  384 +
  385 + ax.axis('equal') # 保持圆形
  386 +
  387 + return self._figure_to_svg(fig)
  388 +
  389 + except Exception as e:
  390 + logger.error(f"渲染饼图失败: {e}")
  391 + return None
  392 +
  393 + def _render_doughnut(
  394 + self,
  395 + data: Dict[str, Any],
  396 + props: Dict[str, Any],
  397 + width: int,
  398 + height: int,
  399 + dpi: int
  400 + ) -> Optional[str]:
  401 + """渲染圆环图"""
  402 + try:
  403 + labels = data.get('labels', [])
  404 + datasets = data.get('datasets', [])
  405 +
  406 + if not labels or not datasets:
  407 + return None
  408 +
  409 + # 圆环图只使用第一个数据集
  410 + dataset = datasets[0]
  411 + dataset_data = dataset.get('data', [])
  412 +
  413 + title = props.get('title')
  414 + fig, ax = self._create_figure(width, height, dpi, title)
  415 +
  416 + # 获取颜色
  417 + colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)])
  418 + if not isinstance(colors, list):
  419 + colors = self.DEFAULT_COLORS[:len(labels)]
  420 +
  421 + # 绘制圆环图(通过设置wedgeprops实现中空效果)
  422 + wedges, texts, autotexts = ax.pie(
  423 + dataset_data,
  424 + labels=labels,
  425 + colors=colors,
  426 + autopct='%1.1f%%',
  427 + startangle=90,
  428 + wedgeprops=dict(width=0.5, edgecolor='white'),
  429 + textprops={'fontsize': 10}
  430 + )
  431 +
  432 + # 设置百分比文字
  433 + for autotext in autotexts:
  434 + autotext.set_color('white')
  435 + autotext.set_fontweight('bold')
  436 +
  437 + ax.axis('equal')
  438 +
  439 + return self._figure_to_svg(fig)
  440 +
  441 + except Exception as e:
  442 + logger.error(f"渲染圆环图失败: {e}")
  443 + return None
  444 +
  445 + def _render_radar(
  446 + self,
  447 + data: Dict[str, Any],
  448 + props: Dict[str, Any],
  449 + width: int,
  450 + height: int,
  451 + dpi: int
  452 + ) -> Optional[str]:
  453 + """渲染雷达图"""
  454 + try:
  455 + labels = data.get('labels', [])
  456 + datasets = data.get('datasets', [])
  457 +
  458 + if not labels or not datasets:
  459 + return None
  460 +
  461 + title = props.get('title')
  462 + fig = plt.figure(figsize=(width/dpi, height/dpi), dpi=dpi)
  463 +
  464 + # 创建极坐标子图
  465 + ax = fig.add_subplot(111, projection='polar')
  466 +
  467 + if title:
  468 + ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
  469 +
  470 + colors = self._get_colors(datasets)
  471 +
  472 + # 计算角度
  473 + angles = np.linspace(0, 2 * np.pi, len(labels), endpoint=False).tolist()
  474 + angles += angles[:1] # 闭合图形
  475 +
  476 + # 绘制每个数据系列
  477 + for i, dataset in enumerate(datasets):
  478 + dataset_data = dataset.get('data', [])
  479 + label = dataset.get('label', f'系列{i+1}')
  480 + color = colors[i]
  481 +
  482 + # 闭合数据
  483 + values = dataset_data + dataset_data[:1]
  484 +
  485 + # 绘制雷达图
  486 + ax.plot(angles, values, 'o-', linewidth=2, label=label, color=color)
  487 + ax.fill(angles, values, alpha=0.25, color=color)
  488 +
  489 + # 设置标签
  490 + ax.set_xticks(angles[:-1])
  491 + ax.set_xticklabels(labels)
  492 +
  493 + # 显示图例
  494 + if len(datasets) > 1:
  495 + ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1))
  496 +
  497 + return self._figure_to_svg(fig)
  498 +
  499 + except Exception as e:
  500 + logger.error(f"渲染雷达图失败: {e}")
  501 + return None
  502 +
  503 + def _render_scatter(
  504 + self,
  505 + data: Dict[str, Any],
  506 + props: Dict[str, Any],
  507 + width: int,
  508 + height: int,
  509 + dpi: int
  510 + ) -> Optional[str]:
  511 + """渲染散点图"""
  512 + try:
  513 + datasets = data.get('datasets', [])
  514 +
  515 + if not datasets:
  516 + return None
  517 +
  518 + title = props.get('title')
  519 + fig, ax = self._create_figure(width, height, dpi, title)
  520 +
  521 + colors = self._get_colors(datasets)
  522 +
  523 + # 绘制每个数据系列
  524 + for i, dataset in enumerate(datasets):
  525 + dataset_data = dataset.get('data', [])
  526 + label = dataset.get('label', f'系列{i+1}')
  527 + color = colors[i]
  528 +
  529 + # 提取x和y坐标
  530 + if dataset_data and isinstance(dataset_data[0], dict):
  531 + x_values = [point.get('x', 0) for point in dataset_data]
  532 + y_values = [point.get('y', 0) for point in dataset_data]
  533 + else:
  534 + # 如果不是{x,y}格式,使用索引作为x
  535 + x_values = range(len(dataset_data))
  536 + y_values = dataset_data
  537 +
  538 + ax.scatter(
  539 + x_values,
  540 + y_values,
  541 + label=label,
  542 + color=color,
  543 + s=50,
  544 + alpha=0.6,
  545 + edgecolors='white',
  546 + linewidth=0.5
  547 + )
  548 +
  549 + # 显示图例
  550 + if len(datasets) > 1:
  551 + ax.legend(loc='best', framealpha=0.9)
  552 +
  553 + # 网格
  554 + ax.grid(True, alpha=0.3, linestyle='--')
  555 +
  556 + return self._figure_to_svg(fig)
  557 +
  558 + except Exception as e:
  559 + logger.error(f"渲染散点图失败: {e}")
  560 + return None
  561 +
  562 + def _render_polarArea(
  563 + self,
  564 + data: Dict[str, Any],
  565 + props: Dict[str, Any],
  566 + width: int,
  567 + height: int,
  568 + dpi: int
  569 + ) -> Optional[str]:
  570 + """渲染极地区域图"""
  571 + try:
  572 + labels = data.get('labels', [])
  573 + datasets = data.get('datasets', [])
  574 +
  575 + if not labels or not datasets:
  576 + return None
  577 +
  578 + # 只使用第一个数据集
  579 + dataset = datasets[0]
  580 + dataset_data = dataset.get('data', [])
  581 +
  582 + title = props.get('title')
  583 + fig = plt.figure(figsize=(width/dpi, height/dpi), dpi=dpi)
  584 + ax = fig.add_subplot(111, projection='polar')
  585 +
  586 + if title:
  587 + ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
  588 +
  589 + # 获取颜色
  590 + colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)])
  591 + if not isinstance(colors, list):
  592 + colors = self.DEFAULT_COLORS[:len(labels)]
  593 +
  594 + # 计算角度
  595 + theta = np.linspace(0, 2 * np.pi, len(labels), endpoint=False)
  596 + width_bar = 2 * np.pi / len(labels)
  597 +
  598 + # 绘制极地区域图
  599 + bars = ax.bar(
  600 + theta,
  601 + dataset_data,
  602 + width=width_bar,
  603 + bottom=0.0,
  604 + color=colors,
  605 + alpha=0.7,
  606 + edgecolor='white',
  607 + linewidth=1
  608 + )
  609 +
  610 + # 设置标签
  611 + ax.set_xticks(theta)
  612 + ax.set_xticklabels(labels)
  613 +
  614 + return self._figure_to_svg(fig)
  615 +
  616 + except Exception as e:
  617 + logger.error(f"渲染极地区域图失败: {e}")
  618 + return None
  619 +
  620 +
  621 +def create_chart_converter(font_path: Optional[str] = None) -> ChartToSVGConverter:
  622 + """
  623 + 创建图表转换器实例
  624 +
  625 + 参数:
  626 + font_path: 中文字体路径(可选)
  627 +
  628 + 返回:
  629 + ChartToSVGConverter: 转换器实例
  630 + """
  631 + return ChartToSVGConverter(font_path=font_path)
  632 +
  633 +
  634 +__all__ = ["ChartToSVGConverter", "create_chart_converter"]
@@ -21,6 +21,7 @@ except ImportError: @@ -21,6 +21,7 @@ except ImportError:
21 21
22 from .html_renderer import HTMLRenderer 22 from .html_renderer import HTMLRenderer
23 from .pdf_layout_optimizer import PDFLayoutOptimizer, PDFLayoutConfig 23 from .pdf_layout_optimizer import PDFLayoutOptimizer, PDFLayoutConfig
  24 +from .chart_to_svg import create_chart_converter
24 25
25 26
26 class PDFRenderer: 27 class PDFRenderer:
@@ -51,6 +52,14 @@ class PDFRenderer: @@ -51,6 +52,14 @@ class PDFRenderer:
51 if not WEASYPRINT_AVAILABLE: 52 if not WEASYPRINT_AVAILABLE:
52 raise RuntimeError("WeasyPrint未安装,请运行: pip install weasyprint") 53 raise RuntimeError("WeasyPrint未安装,请运行: pip install weasyprint")
53 54
  55 + # 初始化图表转换器
  56 + try:
  57 + font_path = self._get_font_path()
  58 + self.chart_converter = create_chart_converter(font_path=str(font_path))
  59 + logger.info("图表SVG转换器初始化成功")
  60 + except Exception as e:
  61 + logger.warning(f"图表SVG转换器初始化失败: {e},将使用表格降级")
  62 +
54 @staticmethod 63 @staticmethod
55 def _get_font_path() -> Path: 64 def _get_font_path() -> Path:
56 """获取字体文件路径""" 65 """获取字体文件路径"""
@@ -77,6 +86,139 @@ class PDFRenderer: @@ -77,6 +86,139 @@ class PDFRenderer:
77 86
78 raise FileNotFoundError(f"未找到字体文件,请检查 {fonts_dir} 目录") 87 raise FileNotFoundError(f"未找到字体文件,请检查 {fonts_dir} 目录")
79 88
  89 + def _convert_charts_to_svg(self, document_ir: Dict[str, Any]) -> Dict[str, str]:
  90 + """
  91 + 将document_ir中的所有图表转换为SVG
  92 +
  93 + 参数:
  94 + document_ir: Document IR数据
  95 +
  96 + 返回:
  97 + Dict[str, str]: widgetId到SVG字符串的映射
  98 + """
  99 + svg_map = {}
  100 +
  101 + if not hasattr(self, 'chart_converter') or not self.chart_converter:
  102 + logger.warning("图表转换器未初始化,跳过图表转换")
  103 + return svg_map
  104 +
  105 + # 遍历所有章节
  106 + chapters = document_ir.get('chapters', [])
  107 + for chapter in chapters:
  108 + blocks = chapter.get('blocks', [])
  109 + self._extract_and_convert_widgets(blocks, svg_map)
  110 +
  111 + logger.info(f"成功转换 {len(svg_map)} 个图表为SVG")
  112 + return svg_map
  113 +
  114 + def _extract_and_convert_widgets(
  115 + self,
  116 + blocks: list,
  117 + svg_map: Dict[str, str]
  118 + ) -> None:
  119 + """
  120 + 递归遍历blocks,找到所有widget并转换为SVG
  121 +
  122 + 参数:
  123 + blocks: block列表
  124 + svg_map: 用于存储转换结果的字典
  125 + """
  126 + for block in blocks:
  127 + if not isinstance(block, dict):
  128 + continue
  129 +
  130 + block_type = block.get('type')
  131 +
  132 + # 处理widget类型
  133 + if block_type == 'widget':
  134 + widget_id = block.get('widgetId')
  135 + widget_type = block.get('widgetType', '')
  136 +
  137 + # 只处理chart.js类型的widget
  138 + if widget_id and widget_type.startswith('chart.js'):
  139 + try:
  140 + svg_content = self.chart_converter.convert_widget_to_svg(
  141 + block,
  142 + width=800,
  143 + height=500,
  144 + dpi=100
  145 + )
  146 + if svg_content:
  147 + svg_map[widget_id] = svg_content
  148 + logger.debug(f"图表 {widget_id} 转换为SVG成功")
  149 + else:
  150 + logger.warning(f"图表 {widget_id} 转换为SVG失败")
  151 + except Exception as e:
  152 + logger.error(f"转换图表 {widget_id} 时出错: {e}")
  153 +
  154 + # 递归处理嵌套的blocks
  155 + nested_blocks = block.get('blocks')
  156 + if isinstance(nested_blocks, list):
  157 + self._extract_and_convert_widgets(nested_blocks, svg_map)
  158 +
  159 + # 处理列表项
  160 + if block_type == 'list':
  161 + items = block.get('items', [])
  162 + for item in items:
  163 + if isinstance(item, list):
  164 + self._extract_and_convert_widgets(item, svg_map)
  165 +
  166 + # 处理表格单元格
  167 + if block_type == 'table':
  168 + rows = block.get('rows', [])
  169 + for row in rows:
  170 + cells = row.get('cells', [])
  171 + for cell in cells:
  172 + cell_blocks = cell.get('blocks', [])
  173 + if isinstance(cell_blocks, list):
  174 + self._extract_and_convert_widgets(cell_blocks, svg_map)
  175 +
  176 + def _inject_svg_into_html(self, html: str, svg_map: Dict[str, str]) -> str:
  177 + """
  178 + 将SVG内容直接注入到HTML中(不使用JavaScript)
  179 +
  180 + 参数:
  181 + html: 原始HTML内容
  182 + svg_map: widgetId到SVG内容的映射
  183 +
  184 + 返回:
  185 + str: 注入SVG后的HTML
  186 + """
  187 + if not svg_map:
  188 + return html
  189 +
  190 + import re
  191 +
  192 + # 为每个widgetId查找对应的canvas并替换为SVG
  193 + for widget_id, svg_content in svg_map.items():
  194 + # 清理SVG内容(移除XML声明,因为SVG将嵌入HTML)
  195 + svg_content = re.sub(r'<\?xml[^>]+\?>', '', svg_content)
  196 + svg_content = re.sub(r'<!DOCTYPE[^>]+>', '', svg_content)
  197 + svg_content = svg_content.strip()
  198 +
  199 + # 创建SVG容器HTML
  200 + svg_html = f'<div class="chart-svg-container">{svg_content}</div>'
  201 +
  202 + # 查找包含此widgetId的配置脚本
  203 + # 格式: <script type="application/json" id="chart-config-N">{"widgetId":"widget_id",...}</script>
  204 + config_pattern = rf'<script[^>]+id="([^"]+)"[^>]*>\s*\{{[^}}]*"widgetId"\s*:\s*"{re.escape(widget_id)}"[^}}]*\}}'
  205 + match = re.search(config_pattern, html, re.DOTALL)
  206 +
  207 + if match:
  208 + config_id = match.group(1)
  209 +
  210 + # 查找对应的canvas元素
  211 + # 格式: <canvas id="chart-N" data-config-id="chart-config-N"></canvas>
  212 + canvas_pattern = rf'<canvas[^>]+data-config-id="{re.escape(config_id)}"[^>]*></canvas>'
  213 +
  214 + # 替换canvas为SVG
  215 + html = re.sub(canvas_pattern, svg_html, html)
  216 + logger.debug(f"已替换图表 {widget_id} 的canvas为SVG")
  217 + else:
  218 + logger.warning(f"未找到图表 {widget_id} 对应的配置脚本")
  219 +
  220 + return html
  221 +
80 def _get_pdf_html( 222 def _get_pdf_html(
81 self, 223 self,
82 document_ir: Dict[str, Any], 224 document_ir: Dict[str, Any],
@@ -89,6 +231,7 @@ class PDFRenderer: @@ -89,6 +231,7 @@ class PDFRenderer:
89 - 添加PDF专用样式 231 - 添加PDF专用样式
90 - 嵌入字体文件 232 - 嵌入字体文件
91 - 应用布局优化 233 - 应用布局优化
  234 + - 将图表转换为SVG矢量图形
92 235
93 参数: 236 参数:
94 document_ir: Document IR数据 237 document_ir: Document IR数据
@@ -117,9 +260,18 @@ class PDFRenderer: @@ -117,9 +260,18 @@ class PDFRenderer:
117 else: 260 else:
118 layout_config = self.layout_optimizer.config 261 layout_config = self.layout_optimizer.config
119 262
  263 + # 转换图表为SVG
  264 + logger.info("开始转换图表为SVG矢量图形...")
  265 + svg_map = self._convert_charts_to_svg(document_ir)
  266 +
120 # 使用HTML渲染器生成基础HTML 267 # 使用HTML渲染器生成基础HTML
121 html = self.html_renderer.render(document_ir) 268 html = self.html_renderer.render(document_ir)
122 269
  270 + # 注入SVG
  271 + if svg_map:
  272 + html = self._inject_svg_into_html(html, svg_map)
  273 + logger.info(f"已注入 {len(svg_map)} 个SVG图表")
  274 +
123 # 获取字体路径并转换为base64(用于嵌入) 275 # 获取字体路径并转换为base64(用于嵌入)
124 font_path = self._get_font_path() 276 font_path = self._get_font_path()
125 font_data = font_path.read_bytes() 277 font_data = font_path.read_bytes()
@@ -160,13 +312,29 @@ body {{ @@ -160,13 +312,29 @@ body {{
160 background: white !important; 312 background: white !important;
161 }} 313 }}
162 314
163 -/* 隐藏图表canvas,显示fallback表格 */  
164 -.chart-container {{  
165 - display: none !important; 315 +/* SVG图表容器样式 */
  316 +.chart-svg-container {{
  317 + width: 100%;
  318 + height: auto;
  319 + display: flex;
  320 + justify-content: center;
  321 + align-items: center;
166 }} 322 }}
167 323
  324 +.chart-svg-container svg {{
  325 + max-width: 100%;
  326 + height: auto;
  327 +}}
  328 +
  329 +/* 隐藏fallback表格(因为现在使用SVG) */
168 .chart-fallback {{ 330 .chart-fallback {{
  331 + display: none !important;
  332 +}}
  333 +
  334 +/* 确保chart-container显示(用于放置SVG) */
  335 +.chart-container {{
169 display: block !important; 336 display: block !important;
  337 + min-height: 400px;
170 }} 338 }}
171 339
172 {optimized_css} 340 {optimized_css}
  1 +"""
  2 +使用新的SVG矢量图表功能重新生成最新报告的PDF
  3 +"""
  4 +
  5 +import json
  6 +import sys
  7 +from pathlib import Path
  8 +from datetime import datetime
  9 +from loguru import logger
  10 +
  11 +# 添加项目路径
  12 +sys.path.insert(0, str(Path(__file__).parent))
  13 +
  14 +from ReportEngine.renderers import PDFRenderer
  15 +
  16 +def find_latest_report():
  17 + """找到最新的报告IR文件"""
  18 + ir_dir = Path("final_reports/ir")
  19 +
  20 + if not ir_dir.exists():
  21 + logger.error(f"报告目录不存在: {ir_dir}")
  22 + return None
  23 +
  24 + # 获取所有JSON文件并按修改时间排序
  25 + json_files = sorted(ir_dir.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)
  26 +
  27 + if not json_files:
  28 + logger.error("未找到报告文件")
  29 + return None
  30 +
  31 + latest_file = json_files[0]
  32 + logger.info(f"找到最新报告: {latest_file.name}")
  33 +
  34 + return latest_file
  35 +
  36 +def load_document_ir(file_path):
  37 + """加载Document IR"""
  38 + try:
  39 + with open(file_path, 'r', encoding='utf-8') as f:
  40 + document_ir = json.load(f)
  41 +
  42 + logger.info(f"成功加载报告: {file_path.name}")
  43 +
  44 + # 统计图表数量
  45 + chart_count = 0
  46 + chapters = document_ir.get('chapters', [])
  47 +
  48 + def count_charts(blocks):
  49 + count = 0
  50 + for block in blocks:
  51 + if isinstance(block, dict):
  52 + if block.get('type') == 'widget' and block.get('widgetType', '').startswith('chart.js'):
  53 + count += 1
  54 + # 递归处理嵌套blocks
  55 + nested = block.get('blocks')
  56 + if isinstance(nested, list):
  57 + count += count_charts(nested)
  58 + return count
  59 +
  60 + for chapter in chapters:
  61 + blocks = chapter.get('blocks', [])
  62 + chart_count += count_charts(blocks)
  63 +
  64 + logger.info(f"报告包含 {len(chapters)} 个章节,{chart_count} 个图表")
  65 +
  66 + return document_ir
  67 +
  68 + except Exception as e:
  69 + logger.error(f"加载报告失败: {e}")
  70 + return None
  71 +
  72 +def generate_pdf_with_vector_charts(document_ir, output_path):
  73 + """使用SVG矢量图表生成PDF"""
  74 + try:
  75 + logger.info("=" * 60)
  76 + logger.info("开始生成PDF(带矢量图表)")
  77 + logger.info("=" * 60)
  78 +
  79 + # 创建PDF渲染器
  80 + renderer = PDFRenderer()
  81 +
  82 + # 渲染PDF
  83 + result_path = renderer.render_to_pdf(
  84 + document_ir,
  85 + output_path,
  86 + optimize_layout=True
  87 + )
  88 +
  89 + logger.info("=" * 60)
  90 + logger.info(f"✓ PDF生成成功: {result_path}")
  91 + logger.info("=" * 60)
  92 +
  93 + # 显示文件大小
  94 + file_size = result_path.stat().st_size
  95 + size_mb = file_size / (1024 * 1024)
  96 + logger.info(f"文件大小: {size_mb:.2f} MB")
  97 +
  98 + return result_path
  99 +
  100 + except Exception as e:
  101 + logger.error(f"生成PDF失败: {e}", exc_info=True)
  102 + return None
  103 +
  104 +def main():
  105 + """主函数"""
  106 + logger.info("🚀 使用SVG矢量图表重新生成最新报告的PDF")
  107 + logger.info("")
  108 +
  109 + # 1. 找到最新报告
  110 + latest_report = find_latest_report()
  111 + if not latest_report:
  112 + logger.error("未找到报告文件")
  113 + return 1
  114 +
  115 + # 2. 加载报告数据
  116 + document_ir = load_document_ir(latest_report)
  117 + if not document_ir:
  118 + logger.error("加载报告失败")
  119 + return 1
  120 +
  121 + # 3. 生成输出文件名
  122 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  123 + report_name = latest_report.stem.replace("report_ir_", "")
  124 + output_filename = f"report_vector_{report_name}_{timestamp}.pdf"
  125 + output_path = Path("final_reports/pdf") / output_filename
  126 +
  127 + # 确保输出目录存在
  128 + output_path.parent.mkdir(parents=True, exist_ok=True)
  129 +
  130 + logger.info(f"输出路径: {output_path}")
  131 + logger.info("")
  132 +
  133 + # 4. 生成PDF
  134 + result = generate_pdf_with_vector_charts(document_ir, output_path)
  135 +
  136 + if result:
  137 + logger.info("")
  138 + logger.info("🎉 PDF生成完成!")
  139 + logger.info("")
  140 + logger.info("特性说明:")
  141 + logger.info(" ✓ 图表以SVG矢量格式渲染")
  142 + logger.info(" ✓ 支持无限缩放不失真")
  143 + logger.info(" ✓ 保留完整的图表视觉效果")
  144 + logger.info(" ✓ 折线图、柱状图、饼图等均为矢量曲线")
  145 + logger.info("")
  146 + logger.info(f"PDF文件位置: {result.absolute()}")
  147 + return 0
  148 + else:
  149 + logger.error("❌ PDF生成失败")
  150 + return 1
  151 +
  152 +if __name__ == "__main__":
  153 + sys.exit(main())