635 lines
19 KiB
Python
635 lines
19 KiB
Python
"""
|
||
图表到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"]
|