Update the PDF Rendering Logic and Add Support for Vector Graphics
Showing
3 changed files
with
958 additions
and
3 deletions
ReportEngine/renderers/chart_to_svg.py
0 → 100644
| 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} |
regenerate_latest_pdf.py
0 → 100644
| 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()) |
-
Please register or login to post a comment