Update the PDF Rendering Logic and Add Support for Vector Graphics
This commit is contained in:
634
ReportEngine/renderers/chart_to_svg.py
Normal file
634
ReportEngine/renderers/chart_to_svg.py
Normal 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"]
|
||||
@@ -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
153
regenerate_latest_pdf.py
Normal 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())
|
||||
Reference in New Issue
Block a user