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
+634
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"]