马一丁

Optimize the Rendering of Charts in HTML and PDF

@@ -31,6 +31,14 @@ except ImportError: @@ -31,6 +31,14 @@ except ImportError:
31 MATPLOTLIB_AVAILABLE = False 31 MATPLOTLIB_AVAILABLE = False
32 logger.warning("Matplotlib未安装,PDF图表矢量渲染功能将不可用") 32 logger.warning("Matplotlib未安装,PDF图表矢量渲染功能将不可用")
33 33
  34 +# 可选依赖:scipy用于曲线平滑
  35 +try:
  36 + from scipy.interpolate import make_interp_spline
  37 + SCIPY_AVAILABLE = True
  38 +except ImportError:
  39 + SCIPY_AVAILABLE = False
  40 + logger.info("Scipy未安装,折线图将不支持曲线平滑功能(不影响基本渲染)")
  41 +
34 42
35 class ChartToSVGConverter: 43 class ChartToSVGConverter:
36 """ 44 """
@@ -43,6 +51,20 @@ class ChartToSVGConverter: @@ -43,6 +51,20 @@ class ChartToSVGConverter:
43 '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF' 51 '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
44 ] 52 ]
45 53
  54 + # CSS变量到颜色的映射表(支持常见的Chart.js主题变量)
  55 + CSS_VAR_COLOR_MAP = {
  56 + 'var(--color-accent)': '#007AFF', # 蓝色(强调色)
  57 + 'var(--re-accent-color)': '#007AFF', # 蓝色
  58 + 'var(--color-kpi-down)': '#DC3545', # 红色(下降/危险)
  59 + 'var(--re-danger-color)': '#DC3545', # 红色(危险)
  60 + 'var(--color-warning)': '#FFC107', # 黄色(警告)
  61 + 'var(--re-warning-color)': '#FFC107', # 黄色
  62 + 'var(--color-success)': '#28A745', # 绿色(成功)
  63 + 'var(--re-success-color)': '#28A745', # 绿色
  64 + 'var(--color-primary)': '#007BFF', # 主色
  65 + 'var(--color-secondary)': '#6C757D', # 次要色
  66 + }
  67 +
46 def __init__(self, font_path: Optional[str] = None): 68 def __init__(self, font_path: Optional[str] = None):
47 """ 69 """
48 初始化转换器 70 初始化转换器
@@ -165,10 +187,23 @@ class ChartToSVGConverter: @@ -165,10 +187,23 @@ class ChartToSVGConverter:
165 187
166 color = color.strip() 188 color = color.strip()
167 189
168 - # 【修复】处理CSS变量,例如 var(--color-accent)  
169 - # 使用默认颜色替代CSS变量 190 + # 【增强】处理CSS变量,例如 var(--color-accent)
  191 + # 使用预定义的颜色映射表替代CSS变量,确保不同变量有不同的颜色
170 if color.startswith('var('): 192 if color.startswith('var('):
171 - # 返回默认的蓝色 193 + # 尝试从映射表中查找对应的颜色
  194 + mapped_color = self.CSS_VAR_COLOR_MAP.get(color)
  195 + if mapped_color:
  196 + return mapped_color
  197 + # 如果映射表中没有,尝试从变量名推断颜色类型
  198 + if 'accent' in color or 'primary' in color:
  199 + return '#007AFF' # 蓝色
  200 + elif 'danger' in color or 'down' in color or 'error' in color:
  201 + return '#DC3545' # 红色
  202 + elif 'warning' in color:
  203 + return '#FFC107' # 黄色
  204 + elif 'success' in color or 'up' in color:
  205 + return '#28A745' # 绿色
  206 + # 默认返回蓝色
172 return '#36A2EB' 207 return '#36A2EB'
173 208
174 # 处理rgba(r, g, b, a)格式 209 # 处理rgba(r, g, b, a)格式
@@ -238,7 +273,15 @@ class ChartToSVGConverter: @@ -238,7 +273,15 @@ class ChartToSVGConverter:
238 height: int, 273 height: int,
239 dpi: int 274 dpi: int
240 ) -> Optional[str]: 275 ) -> Optional[str]:
241 - """渲染折线图""" 276 + """
  277 + 渲染折线图(增强版)
  278 +
  279 + 支持特性:
  280 + - 双y轴(yAxisID: 'y' 和 'y1')
  281 + - 填充区域(fill: true)
  282 + - 透明度(backgroundColor中的alpha通道)
  283 + - 线条样式(tension曲线平滑)
  284 + """
242 try: 285 try:
243 labels = data.get('labels', []) 286 labels = data.get('labels', [])
244 datasets = data.get('datasets', []) 287 datasets = data.get('datasets', [])
@@ -246,43 +289,128 @@ class ChartToSVGConverter: @@ -246,43 +289,128 @@ class ChartToSVGConverter:
246 if not labels or not datasets: 289 if not labels or not datasets:
247 return None 290 return None
248 291
  292 + # 检查是否有双y轴
  293 + has_dual_axis = any(
  294 + dataset.get('yAxisID') == 'y1' for dataset in datasets
  295 + )
  296 +
249 title = props.get('title') 297 title = props.get('title')
250 - fig, ax = self._create_figure(width, height, dpi, title) 298 + options = props.get('options', {})
  299 +
  300 + # 创建图表,如果有双y轴则创建双y轴布局
  301 + if has_dual_axis:
  302 + fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi)
  303 + ax2 = ax1.twinx() # 创建共享x轴的第二个y轴
  304 + else:
  305 + fig, ax1 = self._create_figure(width, height, dpi, title)
  306 + ax2 = None
  307 +
  308 + if title and has_dual_axis:
  309 + ax1.set_title(title, fontsize=14, fontweight='bold', pad=20)
251 310
252 colors = self._get_colors(datasets) 311 colors = self._get_colors(datasets)
253 312
  313 + # 分别收集两个y轴的数据系列
  314 + y1_lines = []
  315 + y2_lines = []
  316 +
254 # 绘制每个数据系列 317 # 绘制每个数据系列
255 for i, dataset in enumerate(datasets): 318 for i, dataset in enumerate(datasets):
256 dataset_data = dataset.get('data', []) 319 dataset_data = dataset.get('data', [])
257 label = dataset.get('label', f'系列{i+1}') 320 label = dataset.get('label', f'系列{i+1}')
258 color = colors[i] 321 color = colors[i]
259 322
  323 + # 获取配置
  324 + y_axis_id = dataset.get('yAxisID', 'y')
  325 + fill = dataset.get('fill', False)
  326 + tension = dataset.get('tension', 0) # 0表示直线,0.4表示平滑曲线
  327 + border_color = self._parse_color(dataset.get('borderColor', color))
  328 + background_color = self._parse_color(dataset.get('backgroundColor', color))
  329 +
  330 + # 选择对应的坐标轴
  331 + ax = ax2 if (y_axis_id == 'y1' and ax2 is not None) else ax1
  332 +
260 # 绘制折线 333 # 绘制折线
261 - ax.plot(  
262 - range(len(labels)),  
263 - dataset_data,  
264 - marker='o',  
265 - label=label,  
266 - color=color,  
267 - linewidth=2,  
268 - markersize=6  
269 - ) 334 + x_data = range(len(labels))
  335 +
  336 + # 根据tension值决定是否平滑
  337 + if tension > 0 and SCIPY_AVAILABLE:
  338 + # 使用样条插值平滑曲线(需要scipy)
  339 + if len(dataset_data) >= 4: # 至少需要4个点才能平滑
  340 + try:
  341 + x_smooth = np.linspace(0, len(labels)-1, len(labels)*3)
  342 + spl = make_interp_spline(x_data, dataset_data, k=min(3, len(dataset_data)-1))
  343 + y_smooth = spl(x_smooth)
  344 + line, = ax.plot(x_smooth, y_smooth, label=label, color=border_color, linewidth=2)
  345 +
  346 + # 如果需要填充
  347 + if fill:
  348 + ax.fill_between(x_smooth, y_smooth, alpha=0.3, color=background_color)
  349 + except:
  350 + # 如果平滑失败,使用普通折线
  351 + line, = ax.plot(x_data, dataset_data, marker='o', label=label,
  352 + color=border_color, linewidth=2, markersize=6)
  353 + if fill:
  354 + ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color)
  355 + else:
  356 + line, = ax.plot(x_data, dataset_data, marker='o', label=label,
  357 + color=border_color, linewidth=2, markersize=6)
  358 + if fill:
  359 + ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color)
  360 + else:
  361 + # 直线连接(tension=0或scipy不可用)
  362 + line, = ax.plot(x_data, dataset_data, marker='o', label=label,
  363 + color=border_color, linewidth=2, markersize=6)
270 364
271 - # 设置x轴标签  
272 - ax.set_xticks(range(len(labels)))  
273 - ax.set_xticklabels(labels, rotation=45, ha='right') 365 + # 如果需要填充
  366 + if fill:
  367 + ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color)
274 368
275 - # 显示图例  
276 - if len(datasets) > 1:  
277 - ax.legend(loc='best', framealpha=0.9) 369 + # 记录哪个轴有哪些线
  370 + if ax == ax2:
  371 + y2_lines.append(line)
  372 + else:
  373 + y1_lines.append(line)
278 374
279 - # 网格  
280 - ax.grid(True, alpha=0.3, linestyle='--') 375 + # 设置x轴标签
  376 + ax1.set_xticks(range(len(labels)))
  377 + ax1.set_xticklabels(labels, rotation=45, ha='right')
  378 +
  379 + # 设置y轴标签和标题
  380 + if has_dual_axis and ax2:
  381 + # 从options中获取y轴配置
  382 + scales = options.get('scales', {})
  383 + y_config = scales.get('y', {})
  384 + y1_config = scales.get('y1', {})
  385 +
  386 + # 设置左侧y轴
  387 + y_title = y_config.get('title', {}).get('text', '')
  388 + if y_title:
  389 + ax1.set_ylabel(y_title, fontsize=11)
  390 +
  391 + # 设置右侧y轴
  392 + y1_title = y1_config.get('title', {}).get('text', '')
  393 + if y1_title:
  394 + ax2.set_ylabel(y1_title, fontsize=11)
  395 +
  396 + # 设置网格(只在主轴显示)
  397 + ax1.grid(True, alpha=0.3, linestyle='--')
  398 + ax2.grid(False) # 右侧y轴不显示网格
  399 +
  400 + # 合并图例(显示所有数据系列)
  401 + lines = y1_lines + y2_lines
  402 + labels_list = [line.get_label() for line in lines]
  403 + ax1.legend(lines, labels_list, loc='best', framealpha=0.9)
  404 + else:
  405 + # 单y轴的情况
  406 + if len(datasets) > 1:
  407 + ax1.legend(loc='best', framealpha=0.9)
  408 + ax1.grid(True, alpha=0.3, linestyle='--')
281 409
282 return self._figure_to_svg(fig) 410 return self._figure_to_svg(fig)
283 411
284 except Exception as e: 412 except Exception as e:
285 - logger.error(f"渲染折线图失败: {e}") 413 + logger.error(f"渲染折线图失败: {e}", exc_info=True)
286 return None 414 return None
287 415
288 def _render_bar( 416 def _render_bar(
@@ -468,9 +468,8 @@ class PDFRenderer: @@ -468,9 +468,8 @@ class PDFRenderer:
468 # 暂时使用简单的替换方案 468 # 暂时使用简单的替换方案
469 # 找到第一个math-block div并替换 469 # 找到第一个math-block div并替换
470 math_block_pattern = r'<div class="math-block">\$\$[^$]*\$\$</div>' 470 math_block_pattern = r'<div class="math-block">\$\$[^$]*\$\$</div>'
471 - # 【修复】转义svg_html中的反斜杠,避免re.sub将其解释为转义序列  
472 - # 使用re.escape处理替换字符串中的特殊字符  
473 - escaped_svg_html = svg_html.replace('\\', r'\\') 471 + # 【修复】使用lambda函数避免re.sub将SVG内容中的反斜杠解释为转义序列
  472 + # lambda函数中的返回值会被当作字面字符串,不会进行转义处理
474 html = re.sub(math_block_pattern, lambda m: svg_html, html, count=1) 473 html = re.sub(math_block_pattern, lambda m: svg_html, html, count=1)
475 logger.debug(f"已替换公式 {math_id} 为SVG") 474 logger.debug(f"已替换公式 {math_id} 为SVG")
476 475