Update the PDF Rendering Logic and Add Support for Vector Graphics

This commit is contained in:
马一丁
2025-11-19 00:14:40 +08:00
parent d397b98d2b
commit a07d6c5292
3 changed files with 958 additions and 3 deletions

View File

@@ -0,0 +1,634 @@
"""
图表到SVG转换器 - 将Chart.js数据转换为矢量SVG图形
支持的图表类型:
- line: 折线图
- bar: 柱状图
- pie: 饼图
- doughnut: 圆环图
- radar: 雷达图
- polarArea: 极地区域图
- scatter: 散点图
"""
from __future__ import annotations
import base64
import io
import re
from typing import Any, Dict, List, Optional, Tuple
from loguru import logger
try:
import matplotlib
matplotlib.use('Agg') # 使用非GUI后端
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
from matplotlib.patches import Wedge
import numpy as np
MATPLOTLIB_AVAILABLE = True
except ImportError:
MATPLOTLIB_AVAILABLE = False
logger.warning("Matplotlib未安装PDF图表矢量渲染功能将不可用")
class ChartToSVGConverter:
"""
将Chart.js图表数据转换为SVG矢量图形
"""
# 默认颜色调色板与Chart.js默认颜色接近
DEFAULT_COLORS = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
]
def __init__(self, font_path: Optional[str] = None):
"""
初始化转换器
参数:
font_path: 中文字体路径(可选)
"""
if not MATPLOTLIB_AVAILABLE:
raise RuntimeError("Matplotlib未安装请运行: pip install matplotlib")
self.font_path = font_path
self._setup_chinese_font()
def _setup_chinese_font(self):
"""配置中文字体"""
if self.font_path:
try:
# 添加自定义字体
fm.fontManager.addfont(self.font_path)
# 设置默认字体
font_prop = fm.FontProperties(fname=self.font_path)
plt.rcParams['font.family'] = font_prop.get_name()
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
logger.info(f"已加载中文字体: {self.font_path}")
except Exception as e:
logger.warning(f"加载中文字体失败: {e},将使用系统默认字体")
else:
# 尝试使用系统中文字体
try:
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
except Exception as e:
logger.warning(f"配置中文字体失败: {e}")
def convert_widget_to_svg(
self,
widget_data: Dict[str, Any],
width: int = 800,
height: int = 500,
dpi: int = 100
) -> Optional[str]:
"""
将widget数据转换为SVG字符串
参数:
widget_data: widget块数据包含widgetType、data、props
width: 图表宽度(像素)
height: 图表高度(像素)
dpi: DPI设置
返回:
str: SVG字符串失败返回None
"""
try:
# 提取图表类型
widget_type = widget_data.get('widgetType', '')
if not widget_type or not widget_type.startswith('chart.js'):
logger.warning(f"不支持的widget类型: {widget_type}")
return None
# 从widgetType中提取图表类型例如 "chart.js/line" -> "line"
chart_type = widget_type.split('/')[-1] if '/' in widget_type else 'bar'
# 也检查props中的type
props = widget_data.get('props', {})
if props.get('type'):
chart_type = props['type']
# 提取数据
data = widget_data.get('data', {})
if not data:
logger.warning("图表数据为空")
return None
# 根据图表类型调用相应的渲染方法
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)
except Exception as e:
logger.error(f"转换图表为SVG失败: {e}", exc_info=True)
return None
def _create_figure(
self,
width: int,
height: int,
dpi: int,
title: Optional[str] = None
) -> Tuple[Any, Any]:
"""
创建matplotlib图表
返回:
tuple: (fig, ax)
"""
fig, ax = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi)
if title:
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
return fig, ax
def _parse_color(self, color: Any) -> str:
"""
解析颜色值将CSS格式转换为matplotlib支持的格式
参数:
color: 颜色值可能是CSS格式如rgba()或十六进制)
返回:
str: matplotlib支持的颜色格式
"""
if not isinstance(color, str):
return str(color)
color = color.strip()
# 处理rgba(r, g, b, a)格式
rgba_pattern = r'rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)'
match = re.match(rgba_pattern, color)
if match:
r, g, b, a = match.groups()
# 转换为matplotlib格式 (r/255, g/255, b/255, a)
return (int(r)/255, int(g)/255, int(b)/255, float(a))
# 处理rgb(r, g, b)格式
rgb_pattern = r'rgb\((\d+),\s*(\d+),\s*(\d+)\)'
match = re.match(rgb_pattern, color)
if match:
r, g, b = match.groups()
# 转换为matplotlib格式 (r/255, g/255, b/255)
return (int(r)/255, int(g)/255, int(b)/255)
# 其他格式(十六进制、颜色名等)直接返回
return color
def _get_colors(self, datasets: List[Dict[str, Any]]) -> List[str]:
"""
获取图表颜色
优先使用dataset中定义的颜色否则使用默认调色板
"""
colors = []
for i, dataset in enumerate(datasets):
# 尝试获取各种可能的颜色字段
color = (
dataset.get('backgroundColor') or
dataset.get('borderColor') or
dataset.get('color') or
self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
)
# 如果是颜色数组,取第一个
if isinstance(color, list):
color = color[0] if color else self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)]
# 解析颜色格式
color = self._parse_color(color)
colors.append(color)
return colors
def _figure_to_svg(self, fig: Any) -> str:
"""
将matplotlib图表转换为SVG字符串
"""
svg_buffer = io.BytesIO()
fig.savefig(svg_buffer, format='svg', bbox_inches='tight', transparent=False, facecolor='white')
plt.close(fig)
svg_buffer.seek(0)
svg_string = svg_buffer.getvalue().decode('utf-8')
return svg_string
def _render_line(
self,
data: Dict[str, Any],
props: Dict[str, Any],
width: int,
height: int,
dpi: int
) -> Optional[str]:
"""渲染折线图"""
try:
labels = data.get('labels', [])
datasets = data.get('datasets', [])
if not labels or not datasets:
return None
title = props.get('title')
fig, ax = self._create_figure(width, height, dpi, title)
colors = self._get_colors(datasets)
# 绘制每个数据系列
for i, dataset in enumerate(datasets):
dataset_data = dataset.get('data', [])
label = dataset.get('label', f'系列{i+1}')
color = colors[i]
# 绘制折线
ax.plot(
range(len(labels)),
dataset_data,
marker='o',
label=label,
color=color,
linewidth=2,
markersize=6
)
# 设置x轴标签
ax.set_xticks(range(len(labels)))
ax.set_xticklabels(labels, rotation=45, ha='right')
# 显示图例
if len(datasets) > 1:
ax.legend(loc='best', framealpha=0.9)
# 网格
ax.grid(True, alpha=0.3, linestyle='--')
return self._figure_to_svg(fig)
except Exception as e:
logger.error(f"渲染折线图失败: {e}")
return None
def _render_bar(
self,
data: Dict[str, Any],
props: Dict[str, Any],
width: int,
height: int,
dpi: int
) -> Optional[str]:
"""渲染柱状图"""
try:
labels = data.get('labels', [])
datasets = data.get('datasets', [])
if not labels or not datasets:
return None
title = props.get('title')
fig, ax = self._create_figure(width, height, dpi, title)
colors = self._get_colors(datasets)
# 计算柱子位置
x = 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 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_pie(
self,
data: Dict[str, Any],
props: Dict[str, Any],
width: int,
height: int,
dpi: int
) -> Optional[str]:
"""渲染饼图"""
try:
labels = data.get('labels', [])
datasets = data.get('datasets', [])
if not labels or not datasets:
return None
# 饼图只使用第一个数据集
dataset = datasets[0]
dataset_data = dataset.get('data', [])
title = props.get('title')
fig, ax = self._create_figure(width, height, dpi, title)
# 获取颜色
colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)])
if not isinstance(colors, list):
colors = self.DEFAULT_COLORS[:len(labels)]
# 绘制饼图
wedges, texts, autotexts = ax.pie(
dataset_data,
labels=labels,
colors=colors,
autopct='%1.1f%%',
startangle=90,
textprops={'fontsize': 10}
)
# 设置百分比文字为白色
for autotext in autotexts:
autotext.set_color('white')
autotext.set_fontweight('bold')
ax.axis('equal') # 保持圆形
return self._figure_to_svg(fig)
except Exception as e:
logger.error(f"渲染饼图失败: {e}")
return None
def _render_doughnut(
self,
data: Dict[str, Any],
props: Dict[str, Any],
width: int,
height: int,
dpi: int
) -> Optional[str]:
"""渲染圆环图"""
try:
labels = data.get('labels', [])
datasets = data.get('datasets', [])
if not labels or not datasets:
return None
# 圆环图只使用第一个数据集
dataset = datasets[0]
dataset_data = dataset.get('data', [])
title = props.get('title')
fig, ax = self._create_figure(width, height, dpi, title)
# 获取颜色
colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)])
if not isinstance(colors, list):
colors = self.DEFAULT_COLORS[:len(labels)]
# 绘制圆环图通过设置wedgeprops实现中空效果
wedges, texts, autotexts = ax.pie(
dataset_data,
labels=labels,
colors=colors,
autopct='%1.1f%%',
startangle=90,
wedgeprops=dict(width=0.5, edgecolor='white'),
textprops={'fontsize': 10}
)
# 设置百分比文字
for autotext in autotexts:
autotext.set_color('white')
autotext.set_fontweight('bold')
ax.axis('equal')
return self._figure_to_svg(fig)
except Exception as e:
logger.error(f"渲染圆环图失败: {e}")
return None
def _render_radar(
self,
data: Dict[str, Any],
props: Dict[str, Any],
width: int,
height: int,
dpi: int
) -> Optional[str]:
"""渲染雷达图"""
try:
labels = data.get('labels', [])
datasets = data.get('datasets', [])
if not labels or not datasets:
return None
title = props.get('title')
fig = plt.figure(figsize=(width/dpi, height/dpi), dpi=dpi)
# 创建极坐标子图
ax = fig.add_subplot(111, projection='polar')
if title:
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
colors = self._get_colors(datasets)
# 计算角度
angles = np.linspace(0, 2 * np.pi, len(labels), endpoint=False).tolist()
angles += angles[:1] # 闭合图形
# 绘制每个数据系列
for i, dataset in enumerate(datasets):
dataset_data = dataset.get('data', [])
label = dataset.get('label', f'系列{i+1}')
color = colors[i]
# 闭合数据
values = dataset_data + dataset_data[:1]
# 绘制雷达图
ax.plot(angles, values, 'o-', linewidth=2, label=label, color=color)
ax.fill(angles, values, alpha=0.25, color=color)
# 设置标签
ax.set_xticks(angles[:-1])
ax.set_xticklabels(labels)
# 显示图例
if len(datasets) > 1:
ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1))
return self._figure_to_svg(fig)
except Exception as e:
logger.error(f"渲染雷达图失败: {e}")
return None
def _render_scatter(
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)
# 绘制每个数据系列
for i, dataset in enumerate(datasets):
dataset_data = dataset.get('data', [])
label = dataset.get('label', f'系列{i+1}')
color = colors[i]
# 提取x和y坐标
if dataset_data and isinstance(dataset_data[0], dict):
x_values = [point.get('x', 0) for point in dataset_data]
y_values = [point.get('y', 0) for point in dataset_data]
else:
# 如果不是{x,y}格式使用索引作为x
x_values = range(len(dataset_data))
y_values = dataset_data
ax.scatter(
x_values,
y_values,
label=label,
color=color,
s=50,
alpha=0.6,
edgecolors='white',
linewidth=0.5
)
# 显示图例
if len(datasets) > 1:
ax.legend(loc='best', framealpha=0.9)
# 网格
ax.grid(True, alpha=0.3, linestyle='--')
return self._figure_to_svg(fig)
except Exception as e:
logger.error(f"渲染散点图失败: {e}")
return None
def _render_polarArea(
self,
data: Dict[str, Any],
props: Dict[str, Any],
width: int,
height: int,
dpi: int
) -> Optional[str]:
"""渲染极地区域图"""
try:
labels = data.get('labels', [])
datasets = data.get('datasets', [])
if not labels or not datasets:
return None
# 只使用第一个数据集
dataset = datasets[0]
dataset_data = dataset.get('data', [])
title = props.get('title')
fig = plt.figure(figsize=(width/dpi, height/dpi), dpi=dpi)
ax = fig.add_subplot(111, projection='polar')
if title:
ax.set_title(title, fontsize=14, fontweight='bold', pad=20)
# 获取颜色
colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)])
if not isinstance(colors, list):
colors = self.DEFAULT_COLORS[:len(labels)]
# 计算角度
theta = np.linspace(0, 2 * np.pi, len(labels), endpoint=False)
width_bar = 2 * np.pi / len(labels)
# 绘制极地区域图
bars = ax.bar(
theta,
dataset_data,
width=width_bar,
bottom=0.0,
color=colors,
alpha=0.7,
edgecolor='white',
linewidth=1
)
# 设置标签
ax.set_xticks(theta)
ax.set_xticklabels(labels)
return self._figure_to_svg(fig)
except Exception as e:
logger.error(f"渲染极地区域图失败: {e}")
return None
def create_chart_converter(font_path: Optional[str] = None) -> ChartToSVGConverter:
"""
创建图表转换器实例
参数:
font_path: 中文字体路径(可选)
返回:
ChartToSVGConverter: 转换器实例
"""
return ChartToSVGConverter(font_path=font_path)
__all__ = ["ChartToSVGConverter", "create_chart_converter"]

View File

@@ -21,6 +21,7 @@ except ImportError:
from .html_renderer import HTMLRenderer
from .pdf_layout_optimizer import PDFLayoutOptimizer, PDFLayoutConfig
from .chart_to_svg import create_chart_converter
class PDFRenderer:
@@ -51,6 +52,14 @@ class PDFRenderer:
if not WEASYPRINT_AVAILABLE:
raise RuntimeError("WeasyPrint未安装请运行: pip install weasyprint")
# 初始化图表转换器
try:
font_path = self._get_font_path()
self.chart_converter = create_chart_converter(font_path=str(font_path))
logger.info("图表SVG转换器初始化成功")
except Exception as e:
logger.warning(f"图表SVG转换器初始化失败: {e},将使用表格降级")
@staticmethod
def _get_font_path() -> Path:
"""获取字体文件路径"""
@@ -77,6 +86,139 @@ class PDFRenderer:
raise FileNotFoundError(f"未找到字体文件,请检查 {fonts_dir} 目录")
def _convert_charts_to_svg(self, document_ir: Dict[str, Any]) -> Dict[str, str]:
"""
将document_ir中的所有图表转换为SVG
参数:
document_ir: Document IR数据
返回:
Dict[str, str]: widgetId到SVG字符串的映射
"""
svg_map = {}
if not hasattr(self, 'chart_converter') or not self.chart_converter:
logger.warning("图表转换器未初始化,跳过图表转换")
return svg_map
# 遍历所有章节
chapters = document_ir.get('chapters', [])
for chapter in chapters:
blocks = chapter.get('blocks', [])
self._extract_and_convert_widgets(blocks, svg_map)
logger.info(f"成功转换 {len(svg_map)} 个图表为SVG")
return svg_map
def _extract_and_convert_widgets(
self,
blocks: list,
svg_map: Dict[str, str]
) -> None:
"""
递归遍历blocks找到所有widget并转换为SVG
参数:
blocks: block列表
svg_map: 用于存储转换结果的字典
"""
for block in blocks:
if not isinstance(block, dict):
continue
block_type = block.get('type')
# 处理widget类型
if block_type == 'widget':
widget_id = block.get('widgetId')
widget_type = block.get('widgetType', '')
# 只处理chart.js类型的widget
if widget_id and widget_type.startswith('chart.js'):
try:
svg_content = self.chart_converter.convert_widget_to_svg(
block,
width=800,
height=500,
dpi=100
)
if svg_content:
svg_map[widget_id] = svg_content
logger.debug(f"图表 {widget_id} 转换为SVG成功")
else:
logger.warning(f"图表 {widget_id} 转换为SVG失败")
except Exception as e:
logger.error(f"转换图表 {widget_id} 时出错: {e}")
# 递归处理嵌套的blocks
nested_blocks = block.get('blocks')
if isinstance(nested_blocks, list):
self._extract_and_convert_widgets(nested_blocks, svg_map)
# 处理列表项
if block_type == 'list':
items = block.get('items', [])
for item in items:
if isinstance(item, list):
self._extract_and_convert_widgets(item, svg_map)
# 处理表格单元格
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_widgets(cell_blocks, svg_map)
def _inject_svg_into_html(self, html: str, svg_map: Dict[str, str]) -> str:
"""
将SVG内容直接注入到HTML中不使用JavaScript
参数:
html: 原始HTML内容
svg_map: widgetId到SVG内容的映射
返回:
str: 注入SVG后的HTML
"""
if not svg_map:
return html
import re
# 为每个widgetId查找对应的canvas并替换为SVG
for widget_id, svg_content in svg_map.items():
# 清理SVG内容移除XML声明因为SVG将嵌入HTML
svg_content = re.sub(r'<\?xml[^>]+\?>', '', svg_content)
svg_content = re.sub(r'<!DOCTYPE[^>]+>', '', svg_content)
svg_content = svg_content.strip()
# 创建SVG容器HTML
svg_html = f'<div class="chart-svg-container">{svg_content}</div>'
# 查找包含此widgetId的配置脚本
# 格式: <script type="application/json" id="chart-config-N">{"widgetId":"widget_id",...}</script>
config_pattern = rf'<script[^>]+id="([^"]+)"[^>]*>\s*\{{[^}}]*"widgetId"\s*:\s*"{re.escape(widget_id)}"[^}}]*\}}'
match = re.search(config_pattern, html, re.DOTALL)
if match:
config_id = match.group(1)
# 查找对应的canvas元素
# 格式: <canvas id="chart-N" data-config-id="chart-config-N"></canvas>
canvas_pattern = rf'<canvas[^>]+data-config-id="{re.escape(config_id)}"[^>]*></canvas>'
# 替换canvas为SVG
html = re.sub(canvas_pattern, svg_html, html)
logger.debug(f"已替换图表 {widget_id} 的canvas为SVG")
else:
logger.warning(f"未找到图表 {widget_id} 对应的配置脚本")
return html
def _get_pdf_html(
self,
document_ir: Dict[str, Any],
@@ -89,6 +231,7 @@ class PDFRenderer:
- 添加PDF专用样式
- 嵌入字体文件
- 应用布局优化
- 将图表转换为SVG矢量图形
参数:
document_ir: Document IR数据
@@ -117,9 +260,18 @@ class PDFRenderer:
else:
layout_config = self.layout_optimizer.config
# 转换图表为SVG
logger.info("开始转换图表为SVG矢量图形...")
svg_map = self._convert_charts_to_svg(document_ir)
# 使用HTML渲染器生成基础HTML
html = self.html_renderer.render(document_ir)
# 注入SVG
if svg_map:
html = self._inject_svg_into_html(html, svg_map)
logger.info(f"已注入 {len(svg_map)} 个SVG图表")
# 获取字体路径并转换为base64用于嵌入
font_path = self._get_font_path()
font_data = font_path.read_bytes()
@@ -160,13 +312,29 @@ body {{
background: white !important;
}}
/* 隐藏图表canvas显示fallback表格 */
.chart-container {{
/* SVG图表容器样式 */
.chart-svg-container {{
width: 100%;
height: auto;
display: flex;
justify-content: center;
align-items: center;
}}
.chart-svg-container svg {{
max-width: 100%;
height: auto;
}}
/* 隐藏fallback表格因为现在使用SVG */
.chart-fallback {{
display: none !important;
}}
.chart-fallback {{
/* 确保chart-container显示用于放置SVG */
.chart-container {{
display: block !important;
min-height: 400px;
}}
{optimized_css}

153
regenerate_latest_pdf.py Normal file
View File

@@ -0,0 +1,153 @@
"""
使用新的SVG矢量图表功能重新生成最新报告的PDF
"""
import json
import sys
from pathlib import Path
from datetime import datetime
from loguru import logger
# 添加项目路径
sys.path.insert(0, str(Path(__file__).parent))
from ReportEngine.renderers import PDFRenderer
def find_latest_report():
"""找到最新的报告IR文件"""
ir_dir = Path("final_reports/ir")
if not ir_dir.exists():
logger.error(f"报告目录不存在: {ir_dir}")
return None
# 获取所有JSON文件并按修改时间排序
json_files = sorted(ir_dir.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)
if not json_files:
logger.error("未找到报告文件")
return None
latest_file = json_files[0]
logger.info(f"找到最新报告: {latest_file.name}")
return latest_file
def load_document_ir(file_path):
"""加载Document IR"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
document_ir = json.load(f)
logger.info(f"成功加载报告: {file_path.name}")
# 统计图表数量
chart_count = 0
chapters = document_ir.get('chapters', [])
def count_charts(blocks):
count = 0
for block in blocks:
if isinstance(block, dict):
if block.get('type') == 'widget' and block.get('widgetType', '').startswith('chart.js'):
count += 1
# 递归处理嵌套blocks
nested = block.get('blocks')
if isinstance(nested, list):
count += count_charts(nested)
return count
for chapter in chapters:
blocks = chapter.get('blocks', [])
chart_count += count_charts(blocks)
logger.info(f"报告包含 {len(chapters)} 个章节,{chart_count} 个图表")
return document_ir
except Exception as e:
logger.error(f"加载报告失败: {e}")
return None
def generate_pdf_with_vector_charts(document_ir, output_path):
"""使用SVG矢量图表生成PDF"""
try:
logger.info("=" * 60)
logger.info("开始生成PDF带矢量图表")
logger.info("=" * 60)
# 创建PDF渲染器
renderer = PDFRenderer()
# 渲染PDF
result_path = renderer.render_to_pdf(
document_ir,
output_path,
optimize_layout=True
)
logger.info("=" * 60)
logger.info(f"✓ PDF生成成功: {result_path}")
logger.info("=" * 60)
# 显示文件大小
file_size = result_path.stat().st_size
size_mb = file_size / (1024 * 1024)
logger.info(f"文件大小: {size_mb:.2f} MB")
return result_path
except Exception as e:
logger.error(f"生成PDF失败: {e}", exc_info=True)
return None
def main():
"""主函数"""
logger.info("🚀 使用SVG矢量图表重新生成最新报告的PDF")
logger.info("")
# 1. 找到最新报告
latest_report = find_latest_report()
if not latest_report:
logger.error("未找到报告文件")
return 1
# 2. 加载报告数据
document_ir = load_document_ir(latest_report)
if not document_ir:
logger.error("加载报告失败")
return 1
# 3. 生成输出文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_name = latest_report.stem.replace("report_ir_", "")
output_filename = f"report_vector_{report_name}_{timestamp}.pdf"
output_path = Path("final_reports/pdf") / output_filename
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
logger.info(f"输出路径: {output_path}")
logger.info("")
# 4. 生成PDF
result = generate_pdf_with_vector_charts(document_ir, output_path)
if result:
logger.info("")
logger.info("🎉 PDF生成完成")
logger.info("")
logger.info("特性说明:")
logger.info(" ✓ 图表以SVG矢量格式渲染")
logger.info(" ✓ 支持无限缩放不失真")
logger.info(" ✓ 保留完整的图表视觉效果")
logger.info(" ✓ 折线图、柱状图、饼图等均为矢量曲线")
logger.info("")
logger.info(f"PDF文件位置: {result.absolute()}")
return 0
else:
logger.error("❌ PDF生成失败")
return 1
if __name__ == "__main__":
sys.exit(main())