Showing
2 changed files
with
153 additions
and
26 deletions
| @@ -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)) |
| 270 | 335 | ||
| 271 | - # 设置x轴标签 | ||
| 272 | - ax.set_xticks(range(len(labels))) | ||
| 273 | - ax.set_xticklabels(labels, rotation=45, ha='right') | 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) | ||
| 274 | 364 | ||
| 275 | - # 显示图例 | ||
| 276 | - if len(datasets) > 1: | ||
| 277 | - ax.legend(loc='best', framealpha=0.9) | 365 | + # 如果需要填充 |
| 366 | + if fill: | ||
| 367 | + ax.fill_between(x_data, dataset_data, alpha=0.3, color=background_color) | ||
| 278 | 368 | ||
| 279 | - # 网格 | ||
| 280 | - ax.grid(True, alpha=0.3, linestyle='--') | 369 | + # 记录哪个轴有哪些线 |
| 370 | + if ax == ax2: | ||
| 371 | + y2_lines.append(line) | ||
| 372 | + else: | ||
| 373 | + y1_lines.append(line) | ||
| 374 | + | ||
| 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 |
-
Please register or login to post a comment