Showing
4 changed files
with
303 additions
and
57 deletions
| @@ -16,6 +16,7 @@ from __future__ import annotations | @@ -16,6 +16,7 @@ from __future__ import annotations | ||
| 16 | import base64 | 16 | import base64 |
| 17 | import io | 17 | import io |
| 18 | import re | 18 | import re |
| 19 | +from datetime import datetime | ||
| 19 | from typing import Any, Dict, List, Optional, Tuple | 20 | from typing import Any, Dict, List, Optional, Tuple |
| 20 | from loguru import logger | 21 | from loguru import logger |
| 21 | 22 | ||
| @@ -23,6 +24,7 @@ try: | @@ -23,6 +24,7 @@ try: | ||
| 23 | import matplotlib | 24 | import matplotlib |
| 24 | matplotlib.use('Agg') # 使用非GUI后端 | 25 | matplotlib.use('Agg') # 使用非GUI后端 |
| 25 | import matplotlib.pyplot as plt | 26 | import matplotlib.pyplot as plt |
| 27 | + import matplotlib.dates as mdates | ||
| 26 | import matplotlib.font_manager as fm | 28 | import matplotlib.font_manager as fm |
| 27 | from matplotlib.patches import Wedge, Rectangle | 29 | from matplotlib.patches import Wedge, Rectangle |
| 28 | import numpy as np | 30 | import numpy as np |
| @@ -70,6 +72,15 @@ class ChartToSVGConverter: | @@ -70,6 +72,15 @@ class ChartToSVGConverter: | ||
| 70 | 'var(--color-secondary)': '#95A5A6', # 浅灰色 | 72 | 'var(--color-secondary)': '#95A5A6', # 浅灰色 |
| 71 | } | 73 | } |
| 72 | 74 | ||
| 75 | + # 支持解析 rgba(var(--color-primary-rgb), 0.5) 这类格式的兜底映射 | ||
| 76 | + CSS_VAR_RGB_MAP = { | ||
| 77 | + 'color-primary-rgb': (52, 152, 219), | ||
| 78 | + 'color-tone-up-rgb': (80, 200, 120), | ||
| 79 | + 'color-tone-down-rgb': (232, 93, 117), | ||
| 80 | + 'color-accent-positive-rgb': (80, 200, 120), | ||
| 81 | + 'color-accent-neutral-rgb': (149, 165, 166), | ||
| 82 | + } | ||
| 83 | + | ||
| 73 | def __init__(self, font_path: Optional[str] = None): | 84 | def __init__(self, font_path: Optional[str] = None): |
| 74 | """ | 85 | """ |
| 75 | 初始化转换器 | 86 | 初始化转换器 |
| @@ -192,6 +203,25 @@ class ChartToSVGConverter: | @@ -192,6 +203,25 @@ class ChartToSVGConverter: | ||
| 192 | 203 | ||
| 193 | color = color.strip() | 204 | color = color.strip() |
| 194 | 205 | ||
| 206 | + # 处理 rgba(var(--color-primary-rgb), 0.5) / rgb(var(--color-primary-rgb)) | ||
| 207 | + var_rgba_pattern = r'rgba?\(var\(--([\w-]+)\)\s*(?:,\s*([\d.]+))?\)' | ||
| 208 | + match = re.match(var_rgba_pattern, color) | ||
| 209 | + if match: | ||
| 210 | + var_name, alpha_str = match.groups() | ||
| 211 | + rgb_tuple = self.CSS_VAR_RGB_MAP.get(var_name) | ||
| 212 | + | ||
| 213 | + # 兼容缺少 -rgb 后缀的写法 | ||
| 214 | + if not rgb_tuple: | ||
| 215 | + if var_name.endswith('-rgb'): | ||
| 216 | + rgb_tuple = self.CSS_VAR_RGB_MAP.get(var_name[:-4]) | ||
| 217 | + else: | ||
| 218 | + rgb_tuple = self.CSS_VAR_RGB_MAP.get(f"{var_name}-rgb") | ||
| 219 | + | ||
| 220 | + if rgb_tuple: | ||
| 221 | + r, g, b = rgb_tuple | ||
| 222 | + alpha = float(alpha_str) if alpha_str is not None else 1.0 | ||
| 223 | + return (r / 255, g / 255, b / 255, alpha) | ||
| 224 | + | ||
| 195 | # 【增强】处理CSS变量,例如 var(--color-accent) | 225 | # 【增强】处理CSS变量,例如 var(--color-accent) |
| 196 | # 使用预定义的颜色映射表替代CSS变量,确保不同变量有不同的颜色 | 226 | # 使用预定义的颜色映射表替代CSS变量,确保不同变量有不同的颜色 |
| 197 | if color.startswith('var('): | 227 | if color.startswith('var('): |
| @@ -288,10 +318,17 @@ class ChartToSVGConverter: | @@ -288,10 +318,17 @@ class ChartToSVGConverter: | ||
| 288 | - 线条样式(tension曲线平滑) | 318 | - 线条样式(tension曲线平滑) |
| 289 | """ | 319 | """ |
| 290 | try: | 320 | try: |
| 291 | - labels = data.get('labels', []) | ||
| 292 | - datasets = data.get('datasets', []) | 321 | + labels = data.get('labels') or [] |
| 322 | + datasets = data.get('datasets') or [] | ||
| 323 | + | ||
| 324 | + has_object_points = any( | ||
| 325 | + isinstance(ds, dict) | ||
| 326 | + and isinstance(ds.get('data'), list) | ||
| 327 | + and any(isinstance(pt, dict) and ('x' in pt or 'y' in pt) for pt in ds.get('data')) | ||
| 328 | + for ds in datasets | ||
| 329 | + ) | ||
| 293 | 330 | ||
| 294 | - if not labels or not datasets: | 331 | + if (not datasets) or ((not labels) and not has_object_points): |
| 295 | return None | 332 | return None |
| 296 | 333 | ||
| 297 | # 收集所有唯一的yAxisID | 334 | # 收集所有唯一的yAxisID |
| @@ -312,6 +349,7 @@ class ChartToSVGConverter: | @@ -312,6 +349,7 @@ class ChartToSVGConverter: | ||
| 312 | title = props.get('title') | 349 | title = props.get('title') |
| 313 | options = props.get('options', {}) | 350 | options = props.get('options', {}) |
| 314 | scales = options.get('scales', {}) | 351 | scales = options.get('scales', {}) |
| 352 | + x_tick_labels = list(labels) if isinstance(labels, list) else [] | ||
| 315 | 353 | ||
| 316 | # 创建图表和多个y轴 | 354 | # 创建图表和多个y轴 |
| 317 | fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi) | 355 | fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi) |
| @@ -376,41 +414,90 @@ class ChartToSVGConverter: | @@ -376,41 +414,90 @@ class ChartToSVGConverter: | ||
| 376 | # 选择对应的坐标轴 | 414 | # 选择对应的坐标轴 |
| 377 | ax = axes.get(y_axis_id, ax1) | 415 | ax = axes.get(y_axis_id, ax1) |
| 378 | 416 | ||
| 379 | - # 绘制折线 | ||
| 380 | - x_data = range(len(labels)) | 417 | + is_object_data = isinstance(dataset_data, list) and any( |
| 418 | + isinstance(point, dict) and ('x' in point or 'y' in point) | ||
| 419 | + for point in dataset_data | ||
| 420 | + ) | ||
| 421 | + | ||
| 422 | + if is_object_data: | ||
| 423 | + x_data = [] | ||
| 424 | + y_data = [] | ||
| 425 | + annotations = [] | ||
| 381 | 426 | ||
| 382 | - # 根据tension值决定是否平滑 | ||
| 383 | - if tension > 0 and SCIPY_AVAILABLE: | ||
| 384 | - # 使用样条插值平滑曲线(需要scipy) | ||
| 385 | - if len(dataset_data) >= 4: # 至少需要4个点才能平滑 | 427 | + for idx, point in enumerate(dataset_data): |
| 428 | + if not isinstance(point, dict): | ||
| 429 | + continue | ||
| 430 | + | ||
| 431 | + label_text = str(point.get('x', f"点{idx + 1}")) | ||
| 432 | + if len(x_tick_labels) < len(dataset_data): | ||
| 433 | + x_tick_labels.append(label_text) | ||
| 434 | + | ||
| 435 | + x_data.append(len(x_data)) | ||
| 436 | + | ||
| 437 | + y_val = point.get('y', 0) | ||
| 386 | try: | 438 | try: |
| 387 | - x_smooth = np.linspace(0, len(labels)-1, len(labels)*3) | ||
| 388 | - spl = make_interp_spline(x_data, dataset_data, k=min(3, len(dataset_data)-1)) | ||
| 389 | - y_smooth = spl(x_smooth) | ||
| 390 | - line, = ax.plot(x_smooth, y_smooth, label=label, color=border_color, linewidth=2) | 439 | + y_val = float(y_val) |
| 440 | + except (TypeError, ValueError): | ||
| 441 | + y_val = 0 | ||
| 442 | + y_data.append(y_val) | ||
| 443 | + annotations.append(point.get('event')) | ||
| 391 | 444 | ||
| 392 | - # 如果需要填充(使用极低透明度避免遮挡) | ||
| 393 | - if fill: | ||
| 394 | - ax.fill_between(x_smooth, y_smooth, alpha=0.08, color=background_color) | ||
| 395 | - except: | ||
| 396 | - # 如果平滑失败,使用普通折线 | 445 | + if not x_data: |
| 446 | + continue | ||
| 447 | + | ||
| 448 | + line, = ax.plot(x_data, y_data, marker='o', label=label, | ||
| 449 | + color=border_color, linewidth=2, markersize=6) | ||
| 450 | + | ||
| 451 | + if fill: | ||
| 452 | + ax.fill_between(x_data, y_data, alpha=0.08, color=background_color) | ||
| 453 | + | ||
| 454 | + for pos, y_val, text in zip(x_data, y_data, annotations): | ||
| 455 | + if text: | ||
| 456 | + ax.annotate( | ||
| 457 | + text, | ||
| 458 | + (pos, y_val), | ||
| 459 | + textcoords='offset points', | ||
| 460 | + xytext=(0, 8), | ||
| 461 | + ha='center', | ||
| 462 | + fontsize=8, | ||
| 463 | + rotation=20 | ||
| 464 | + ) | ||
| 465 | + else: | ||
| 466 | + # 绘制折线 | ||
| 467 | + x_data = range(len(labels)) | ||
| 468 | + | ||
| 469 | + # 根据tension值决定是否平滑 | ||
| 470 | + if tension > 0 and SCIPY_AVAILABLE: | ||
| 471 | + # 使用样条插值平滑曲线(需要scipy) | ||
| 472 | + if len(dataset_data) >= 4: # 至少需要4个点才能平滑 | ||
| 473 | + try: | ||
| 474 | + x_smooth = np.linspace(0, len(labels)-1, len(labels)*3) | ||
| 475 | + spl = make_interp_spline(x_data, dataset_data, k=min(3, len(dataset_data)-1)) | ||
| 476 | + y_smooth = spl(x_smooth) | ||
| 477 | + line, = ax.plot(x_smooth, y_smooth, label=label, color=border_color, linewidth=2) | ||
| 478 | + | ||
| 479 | + # 如果需要填充(使用极低透明度避免遮挡) | ||
| 480 | + if fill: | ||
| 481 | + ax.fill_between(x_smooth, y_smooth, alpha=0.08, color=background_color) | ||
| 482 | + except: | ||
| 483 | + # 如果平滑失败,使用普通折线 | ||
| 484 | + line, = ax.plot(x_data, dataset_data, marker='o', label=label, | ||
| 485 | + color=border_color, linewidth=2, markersize=6) | ||
| 486 | + if fill: | ||
| 487 | + ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) | ||
| 488 | + else: | ||
| 397 | line, = ax.plot(x_data, dataset_data, marker='o', label=label, | 489 | line, = ax.plot(x_data, dataset_data, marker='o', label=label, |
| 398 | color=border_color, linewidth=2, markersize=6) | 490 | color=border_color, linewidth=2, markersize=6) |
| 399 | if fill: | 491 | if fill: |
| 400 | ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) | 492 | ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) |
| 401 | else: | 493 | else: |
| 494 | + # 直线连接(tension=0或scipy不可用) | ||
| 402 | line, = ax.plot(x_data, dataset_data, marker='o', label=label, | 495 | line, = ax.plot(x_data, dataset_data, marker='o', label=label, |
| 403 | color=border_color, linewidth=2, markersize=6) | 496 | color=border_color, linewidth=2, markersize=6) |
| 497 | + | ||
| 498 | + # 如果需要填充(使用极低透明度避免遮挡) | ||
| 404 | if fill: | 499 | if fill: |
| 405 | ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) | 500 | ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) |
| 406 | - else: | ||
| 407 | - # 直线连接(tension=0或scipy不可用) | ||
| 408 | - line, = ax.plot(x_data, dataset_data, marker='o', label=label, | ||
| 409 | - color=border_color, linewidth=2, markersize=6) | ||
| 410 | - | ||
| 411 | - # 如果需要填充(使用极低透明度避免遮挡) | ||
| 412 | - if fill: | ||
| 413 | - ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color) | ||
| 414 | 501 | ||
| 415 | # 记录这条线属于哪个轴 | 502 | # 记录这条线属于哪个轴 |
| 416 | axis_lines[y_axis_id].append(line) | 503 | axis_lines[y_axis_id].append(line) |
| @@ -430,8 +517,9 @@ class ChartToSVGConverter: | @@ -430,8 +517,9 @@ class ChartToSVGConverter: | ||
| 430 | legend_labels.append(label) | 517 | legend_labels.append(label) |
| 431 | 518 | ||
| 432 | # 设置x轴标签 | 519 | # 设置x轴标签 |
| 433 | - ax1.set_xticks(range(len(labels))) | ||
| 434 | - ax1.set_xticklabels(labels, rotation=45, ha='right') | 520 | + if x_tick_labels: |
| 521 | + ax1.set_xticks(range(len(x_tick_labels))) | ||
| 522 | + ax1.set_xticklabels(x_tick_labels, rotation=45, ha='right') | ||
| 435 | 523 | ||
| 436 | # 设置y轴标签和标题 | 524 | # 设置y轴标签和标题 |
| 437 | for y_axis_id, ax in axes.items(): | 525 | for y_axis_id, ax in axes.items(): |
| @@ -79,6 +79,7 @@ class HTMLRenderer: | @@ -79,6 +79,7 @@ class HTMLRenderer: | ||
| 79 | self.secondary_heading_index = 0 | 79 | self.secondary_heading_index = 0 |
| 80 | self.toc_rendered = False | 80 | self.toc_rendered = False |
| 81 | self.hero_kpi_signature: tuple | None = None | 81 | self.hero_kpi_signature: tuple | None = None |
| 82 | + self._current_chapter: Dict[str, Any] | None = None | ||
| 82 | self._lib_cache: Dict[str, str] = {} | 83 | self._lib_cache: Dict[str, str] = {} |
| 83 | self._pdf_font_base64: str | None = None | 84 | self._pdf_font_base64: str | None = None |
| 84 | 85 | ||
| @@ -967,7 +968,12 @@ class HTMLRenderer: | @@ -967,7 +968,12 @@ class HTMLRenderer: | ||
| 967 | str: section包裹的HTML。 | 968 | str: section包裹的HTML。 |
| 968 | """ | 969 | """ |
| 969 | section_id = self._escape_attr(chapter.get("anchor") or f"chapter-{chapter.get('chapterId', 'x')}") | 970 | section_id = self._escape_attr(chapter.get("anchor") or f"chapter-{chapter.get('chapterId', 'x')}") |
| 970 | - blocks_html = self._render_blocks(chapter.get("blocks", [])) | 971 | + prev_chapter = self._current_chapter |
| 972 | + self._current_chapter = chapter | ||
| 973 | + try: | ||
| 974 | + blocks_html = self._render_blocks(chapter.get("blocks", [])) | ||
| 975 | + finally: | ||
| 976 | + self._current_chapter = prev_chapter | ||
| 971 | return f'<section id="{section_id}" class="chapter">\n{blocks_html}\n</section>' | 977 | return f'<section id="{section_id}" class="chapter">\n{blocks_html}\n</section>' |
| 972 | 978 | ||
| 973 | def _render_blocks(self, blocks: List[Dict[str, Any]]) -> str: | 979 | def _render_blocks(self, blocks: List[Dict[str, Any]]) -> str: |
| @@ -1406,6 +1412,98 @@ class HTMLRenderer: | @@ -1406,6 +1412,98 @@ class HTMLRenderer: | ||
| 1406 | 1412 | ||
| 1407 | return props, normalized_data | 1413 | return props, normalized_data |
| 1408 | 1414 | ||
| 1415 | + @staticmethod | ||
| 1416 | + def _is_chart_data_empty(data: Dict[str, Any] | None) -> bool: | ||
| 1417 | + """检查图表数据是否为空或缺少有效datasets""" | ||
| 1418 | + if not isinstance(data, dict): | ||
| 1419 | + return True | ||
| 1420 | + | ||
| 1421 | + datasets = data.get("datasets") | ||
| 1422 | + if not isinstance(datasets, list) or len(datasets) == 0: | ||
| 1423 | + return True | ||
| 1424 | + | ||
| 1425 | + for ds in datasets: | ||
| 1426 | + if not isinstance(ds, dict): | ||
| 1427 | + continue | ||
| 1428 | + series = ds.get("data") | ||
| 1429 | + if isinstance(series, list) and len(series) > 0: | ||
| 1430 | + return False | ||
| 1431 | + | ||
| 1432 | + return True | ||
| 1433 | + | ||
| 1434 | + def _normalize_chart_block( | ||
| 1435 | + self, | ||
| 1436 | + block: Dict[str, Any], | ||
| 1437 | + chapter_context: Dict[str, Any] | None = None, | ||
| 1438 | + ) -> None: | ||
| 1439 | + """ | ||
| 1440 | + 补全图表block中的缺失字段(如scales、datasets),提升容错性。 | ||
| 1441 | + | ||
| 1442 | + - 将错误挂在block顶层的scales合并进props.options。 | ||
| 1443 | + - 当data缺失或datasets为空时,尝试使用章节级的data作为兜底。 | ||
| 1444 | + """ | ||
| 1445 | + | ||
| 1446 | + if not isinstance(block, dict): | ||
| 1447 | + return | ||
| 1448 | + | ||
| 1449 | + if block.get("type") != "widget": | ||
| 1450 | + return | ||
| 1451 | + | ||
| 1452 | + widget_type = block.get("widgetType", "") | ||
| 1453 | + if not (isinstance(widget_type, str) and widget_type.startswith("chart.js")): | ||
| 1454 | + return | ||
| 1455 | + | ||
| 1456 | + # 确保props存在 | ||
| 1457 | + props = block.get("props") | ||
| 1458 | + if not isinstance(props, dict): | ||
| 1459 | + block["props"] = {} | ||
| 1460 | + props = block["props"] | ||
| 1461 | + | ||
| 1462 | + # 将顶层scales合并进options,避免配置丢失 | ||
| 1463 | + scales = block.get("scales") | ||
| 1464 | + if isinstance(scales, dict): | ||
| 1465 | + options = props.get("options") if isinstance(props.get("options"), dict) else {} | ||
| 1466 | + props["options"] = self._merge_dicts(options, {"scales": scales}) | ||
| 1467 | + | ||
| 1468 | + # 确保data存在 | ||
| 1469 | + data = block.get("data") | ||
| 1470 | + if not isinstance(data, dict): | ||
| 1471 | + data = {} | ||
| 1472 | + block["data"] = data | ||
| 1473 | + | ||
| 1474 | + # 如果datasets为空,尝试使用章节级data填充 | ||
| 1475 | + if chapter_context and self._is_chart_data_empty(data): | ||
| 1476 | + chapter_data = chapter_context.get("data") if isinstance(chapter_context, dict) else None | ||
| 1477 | + if isinstance(chapter_data, dict): | ||
| 1478 | + fallback_ds = chapter_data.get("datasets") | ||
| 1479 | + if isinstance(fallback_ds, list) and len(fallback_ds) > 0: | ||
| 1480 | + merged_data = copy.deepcopy(data) | ||
| 1481 | + merged_data["datasets"] = copy.deepcopy(fallback_ds) | ||
| 1482 | + | ||
| 1483 | + if not merged_data.get("labels") and isinstance(chapter_data.get("labels"), list): | ||
| 1484 | + merged_data["labels"] = copy.deepcopy(chapter_data["labels"]) | ||
| 1485 | + | ||
| 1486 | + block["data"] = merged_data | ||
| 1487 | + | ||
| 1488 | + # 若仍缺少labels且数据点包含x值,自动生成便于fallback和坐标刻度 | ||
| 1489 | + data_ref = block.get("data") | ||
| 1490 | + if isinstance(data_ref, dict) and not data_ref.get("labels"): | ||
| 1491 | + datasets_ref = data_ref.get("datasets") | ||
| 1492 | + if isinstance(datasets_ref, list) and datasets_ref: | ||
| 1493 | + first_ds = datasets_ref[0] | ||
| 1494 | + ds_data = first_ds.get("data") if isinstance(first_ds, dict) else None | ||
| 1495 | + if isinstance(ds_data, list): | ||
| 1496 | + labels_from_data = [] | ||
| 1497 | + for idx, point in enumerate(ds_data): | ||
| 1498 | + if isinstance(point, dict): | ||
| 1499 | + label_text = point.get("x") or point.get("label") or f"点{idx + 1}" | ||
| 1500 | + else: | ||
| 1501 | + label_text = f"点{idx + 1}" | ||
| 1502 | + labels_from_data.append(str(label_text)) | ||
| 1503 | + | ||
| 1504 | + if labels_from_data: | ||
| 1505 | + data_ref["labels"] = labels_from_data | ||
| 1506 | + | ||
| 1409 | def _render_widget(self, block: Dict[str, Any]) -> str: | 1507 | def _render_widget(self, block: Dict[str, Any]) -> str: |
| 1410 | """ | 1508 | """ |
| 1411 | 渲染Chart.js等交互组件的占位容器,并记录配置JSON。 | 1509 | 渲染Chart.js等交互组件的占位容器,并记录配置JSON。 |
| @@ -1422,6 +1520,9 @@ class HTMLRenderer: | @@ -1422,6 +1520,9 @@ class HTMLRenderer: | ||
| 1422 | 返回: | 1520 | 返回: |
| 1423 | str: 含canvas与配置脚本的HTML。 | 1521 | str: 含canvas与配置脚本的HTML。 |
| 1424 | """ | 1522 | """ |
| 1523 | + # 先在block层面做一次容错补全(scales、章节级数据等) | ||
| 1524 | + self._normalize_chart_block(block, getattr(self, "_current_chapter", None)) | ||
| 1525 | + | ||
| 1425 | # 统计 | 1526 | # 统计 |
| 1426 | widget_type = block.get('widgetType', '') | 1527 | widget_type = block.get('widgetType', '') |
| 1427 | is_chart = isinstance(widget_type, str) and widget_type.startswith('chart.js') | 1528 | is_chart = isinstance(widget_type, str) and widget_type.startswith('chart.js') |
| @@ -1489,7 +1590,7 @@ class HTMLRenderer: | @@ -1489,7 +1590,7 @@ class HTMLRenderer: | ||
| 1489 | 1590 | ||
| 1490 | title = props.get("title") | 1591 | title = props.get("title") |
| 1491 | title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else "" | 1592 | title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else "" |
| 1492 | - fallback_html = self._render_widget_fallback(normalized_data) | 1593 | + fallback_html = self._render_widget_fallback(normalized_data, block.get("widgetId")) |
| 1493 | return f""" | 1594 | return f""" |
| 1494 | <div class="chart-card"> | 1595 | <div class="chart-card"> |
| 1495 | {title_html} | 1596 | {title_html} |
| @@ -1500,7 +1601,7 @@ class HTMLRenderer: | @@ -1500,7 +1601,7 @@ class HTMLRenderer: | ||
| 1500 | </div> | 1601 | </div> |
| 1501 | """ | 1602 | """ |
| 1502 | 1603 | ||
| 1503 | - def _render_widget_fallback(self, data: Dict[str, Any]) -> str: | 1604 | + def _render_widget_fallback(self, data: Dict[str, Any], widget_id: str | None = None) -> str: |
| 1504 | """渲染图表数据的文本兜底视图,避免Chart.js加载失败时出现空白""" | 1605 | """渲染图表数据的文本兜底视图,避免Chart.js加载失败时出现空白""" |
| 1505 | if not isinstance(data, dict): | 1606 | if not isinstance(data, dict): |
| 1506 | return "" | 1607 | return "" |
| @@ -1508,6 +1609,8 @@ class HTMLRenderer: | @@ -1508,6 +1609,8 @@ class HTMLRenderer: | ||
| 1508 | datasets = data.get("datasets") or [] | 1609 | datasets = data.get("datasets") or [] |
| 1509 | if not labels or not datasets: | 1610 | if not labels or not datasets: |
| 1510 | return "" | 1611 | return "" |
| 1612 | + | ||
| 1613 | + widget_attr = f' data-widget-id="{self._escape_attr(widget_id)}"' if widget_id else "" | ||
| 1511 | header_cells = "".join( | 1614 | header_cells = "".join( |
| 1512 | f"<th>{self._escape_html(ds.get('label') or f'系列{idx + 1}')}</th>" | 1615 | f"<th>{self._escape_html(ds.get('label') or f'系列{idx + 1}')}</th>" |
| 1513 | for idx, ds in enumerate(datasets) | 1616 | for idx, ds in enumerate(datasets) |
| @@ -1521,7 +1624,7 @@ class HTMLRenderer: | @@ -1521,7 +1624,7 @@ class HTMLRenderer: | ||
| 1521 | row_cells.append(f"<td>{self._escape_html(value)}</td>") | 1624 | row_cells.append(f"<td>{self._escape_html(value)}</td>") |
| 1522 | body_rows += f"<tr>{''.join(row_cells)}</tr>" | 1625 | body_rows += f"<tr>{''.join(row_cells)}</tr>" |
| 1523 | table_html = f""" | 1626 | table_html = f""" |
| 1524 | - <div class="chart-fallback" data-prebuilt="true"> | 1627 | + <div class="chart-fallback" data-prebuilt="true"{widget_attr}> |
| 1525 | <table> | 1628 | <table> |
| 1526 | <thead> | 1629 | <thead> |
| 1527 | <tr><th>类别</th>{header_cells}</tr> | 1630 | <tr><th>类别</th>{header_cells}</tr> |
| @@ -7,11 +7,22 @@ from __future__ import annotations | @@ -7,11 +7,22 @@ from __future__ import annotations | ||
| 7 | 7 | ||
| 8 | import base64 | 8 | import base64 |
| 9 | import copy | 9 | import copy |
| 10 | +import os | ||
| 11 | +import sys | ||
| 10 | from pathlib import Path | 12 | from pathlib import Path |
| 11 | from typing import Any, Dict | 13 | from typing import Any, Dict |
| 12 | from datetime import datetime | 14 | from datetime import datetime |
| 13 | from loguru import logger | 15 | from loguru import logger |
| 14 | 16 | ||
| 17 | +# 在导入WeasyPrint之前,尝试补充常见的macOS Homebrew动态库路径, | ||
| 18 | +# 避免因未设置DYLD_LIBRARY_PATH而找不到pango/cairo等依赖。 | ||
| 19 | +if sys.platform == 'darwin': | ||
| 20 | + brew_lib = Path('/opt/homebrew/lib') | ||
| 21 | + if brew_lib.exists(): | ||
| 22 | + current = os.environ.get('DYLD_LIBRARY_PATH', '') | ||
| 23 | + if str(brew_lib) not in current.split(':'): | ||
| 24 | + os.environ['DYLD_LIBRARY_PATH'] = f"{brew_lib}{':' + current if current else ''}" | ||
| 25 | + | ||
| 15 | try: | 26 | try: |
| 16 | from weasyprint import HTML, CSS | 27 | from weasyprint import HTML, CSS |
| 17 | from weasyprint.text.fonts import FontConfiguration | 28 | from weasyprint.text.fonts import FontConfiguration |
| @@ -128,7 +139,7 @@ class PDFRenderer: | @@ -128,7 +139,7 @@ class PDFRenderer: | ||
| 128 | 'failed': 0 | 139 | 'failed': 0 |
| 129 | } | 140 | } |
| 130 | 141 | ||
| 131 | - def repair_widgets_in_blocks(blocks: list) -> None: | 142 | + def repair_widgets_in_blocks(blocks: list, chapter_context: Dict[str, Any] | None = None) -> None: |
| 132 | """递归修复blocks中的所有widget""" | 143 | """递归修复blocks中的所有widget""" |
| 133 | for block in blocks: | 144 | for block in blocks: |
| 134 | if not isinstance(block, dict): | 145 | if not isinstance(block, dict): |
| @@ -136,6 +147,12 @@ class PDFRenderer: | @@ -136,6 +147,12 @@ class PDFRenderer: | ||
| 136 | 147 | ||
| 137 | # 处理widget类型 | 148 | # 处理widget类型 |
| 138 | if block.get('type') == 'widget': | 149 | if block.get('type') == 'widget': |
| 150 | + # 先用HTML渲染器的容错逻辑补全字段 | ||
| 151 | + try: | ||
| 152 | + self.html_renderer._normalize_chart_block(block, chapter_context) | ||
| 153 | + except Exception as exc: # 防御性处理,避免单个图表阻断流程 | ||
| 154 | + logger.debug(f"预处理图表 {block.get('widgetId')} 时出错: {exc}") | ||
| 155 | + | ||
| 139 | widget_type = block.get('widgetType', '') | 156 | widget_type = block.get('widgetType', '') |
| 140 | if widget_type.startswith('chart.js'): | 157 | if widget_type.startswith('chart.js'): |
| 141 | repair_stats['total'] += 1 | 158 | repair_stats['total'] += 1 |
| @@ -164,32 +181,32 @@ class PDFRenderer: | @@ -164,32 +181,32 @@ class PDFRenderer: | ||
| 164 | ) | 181 | ) |
| 165 | 182 | ||
| 166 | # 递归处理嵌套的blocks | 183 | # 递归处理嵌套的blocks |
| 167 | - nested_blocks = block.get('blocks') | ||
| 168 | - if isinstance(nested_blocks, list): | ||
| 169 | - repair_widgets_in_blocks(nested_blocks) | 184 | + nested_blocks = block.get('blocks') |
| 185 | + if isinstance(nested_blocks, list): | ||
| 186 | + repair_widgets_in_blocks(nested_blocks, chapter_context) | ||
| 170 | 187 | ||
| 171 | # 处理列表项 | 188 | # 处理列表项 |
| 172 | - if block.get('type') == 'list': | ||
| 173 | - items = block.get('items', []) | ||
| 174 | - for item in items: | ||
| 175 | - if isinstance(item, list): | ||
| 176 | - repair_widgets_in_blocks(item) | 189 | + if block.get('type') == 'list': |
| 190 | + items = block.get('items', []) | ||
| 191 | + for item in items: | ||
| 192 | + if isinstance(item, list): | ||
| 193 | + repair_widgets_in_blocks(item, chapter_context) | ||
| 177 | 194 | ||
| 178 | # 处理表格单元格 | 195 | # 处理表格单元格 |
| 179 | - if block.get('type') == 'table': | ||
| 180 | - rows = block.get('rows', []) | ||
| 181 | - for row in rows: | ||
| 182 | - cells = row.get('cells', []) | ||
| 183 | - for cell in cells: | ||
| 184 | - cell_blocks = cell.get('blocks', []) | ||
| 185 | - if isinstance(cell_blocks, list): | ||
| 186 | - repair_widgets_in_blocks(cell_blocks) | 196 | + if block.get('type') == 'table': |
| 197 | + rows = block.get('rows', []) | ||
| 198 | + for row in rows: | ||
| 199 | + cells = row.get('cells', []) | ||
| 200 | + for cell in cells: | ||
| 201 | + cell_blocks = cell.get('blocks', []) | ||
| 202 | + if isinstance(cell_blocks, list): | ||
| 203 | + repair_widgets_in_blocks(cell_blocks, chapter_context) | ||
| 187 | 204 | ||
| 188 | # 处理所有章节 | 205 | # 处理所有章节 |
| 189 | chapters = ir_copy.get('chapters', []) | 206 | chapters = ir_copy.get('chapters', []) |
| 190 | for chapter in chapters: | 207 | for chapter in chapters: |
| 191 | blocks = chapter.get('blocks', []) | 208 | blocks = chapter.get('blocks', []) |
| 192 | - repair_widgets_in_blocks(blocks) | 209 | + repair_widgets_in_blocks(blocks, chapter) |
| 193 | 210 | ||
| 194 | # 输出统计信息 | 211 | # 输出统计信息 |
| 195 | if repair_stats['total'] > 0: | 212 | if repair_stats['total'] > 0: |
| @@ -425,6 +442,17 @@ class PDFRenderer: | @@ -425,6 +442,17 @@ class PDFRenderer: | ||
| 425 | # 【修复】替换canvas为SVG,使用lambda避免反斜杠转义问题 | 442 | # 【修复】替换canvas为SVG,使用lambda避免反斜杠转义问题 |
| 426 | html = re.sub(canvas_pattern, lambda m: svg_html, html) | 443 | html = re.sub(canvas_pattern, lambda m: svg_html, html) |
| 427 | logger.debug(f"已替换图表 {widget_id} 的canvas为SVG") | 444 | logger.debug(f"已替换图表 {widget_id} 的canvas为SVG") |
| 445 | + | ||
| 446 | + # 将对应fallback标记为隐藏,避免PDF中出现重复表格 | ||
| 447 | + fallback_pattern = rf'<div class="chart-fallback"([^>]*data-widget-id="{re.escape(widget_id)}"[^>]*)>' | ||
| 448 | + | ||
| 449 | + def _hide_fallback(m: re.Match) -> str: | ||
| 450 | + tag = m.group(0) | ||
| 451 | + if 'svg-hidden' in tag: | ||
| 452 | + return tag | ||
| 453 | + return tag.replace('chart-fallback"', 'chart-fallback svg-hidden"', 1) | ||
| 454 | + | ||
| 455 | + html = re.sub(fallback_pattern, _hide_fallback, html, count=1) | ||
| 428 | else: | 456 | else: |
| 429 | logger.warning(f"未找到图表 {widget_id} 对应的配置脚本") | 457 | logger.warning(f"未找到图表 {widget_id} 对应的配置脚本") |
| 430 | 458 | ||
| @@ -617,8 +645,8 @@ body {{ | @@ -617,8 +645,8 @@ body {{ | ||
| 617 | display: none !important; | 645 | display: none !important; |
| 618 | }} | 646 | }} |
| 619 | 647 | ||
| 620 | -/* 隐藏fallback表格(因为现在使用SVG) */ | ||
| 621 | -.chart-fallback {{ | 648 | +/* 当对应SVG成功注入时隐藏fallback表格,失败时继续显示兜底数据 */ |
| 649 | +.chart-fallback.svg-hidden {{ | ||
| 622 | display: none !important; | 650 | display: none !important; |
| 623 | }} | 651 | }} |
| 624 | 652 |
| @@ -133,13 +133,28 @@ class ChartValidator: | @@ -133,13 +133,28 @@ class ChartValidator: | ||
| 133 | errors.append("data字段必须是字典类型") | 133 | errors.append("data字段必须是字典类型") |
| 134 | return ValidationResult(False, errors, warnings) | 134 | return ValidationResult(False, errors, warnings) |
| 135 | 135 | ||
| 136 | + # 检测是否使用了{x, y}形式的数据点(通常用于时间轴/散点) | ||
| 137 | + def contains_object_points(ds_list: List[Any] | None) -> bool: | ||
| 138 | + if not isinstance(ds_list, list): | ||
| 139 | + return False | ||
| 140 | + for point in ds_list: | ||
| 141 | + if isinstance(point, dict) and any(key in point for key in ('x', 'y', 't')): | ||
| 142 | + return True | ||
| 143 | + return False | ||
| 144 | + | ||
| 145 | + datasets_for_detection = data.get('datasets') or [] | ||
| 146 | + uses_object_points = any( | ||
| 147 | + isinstance(ds, dict) and contains_object_points(ds.get('data')) | ||
| 148 | + for ds in datasets_for_detection | ||
| 149 | + ) | ||
| 150 | + | ||
| 136 | # 6. 根据图表类型验证数据 | 151 | # 6. 根据图表类型验证数据 |
| 137 | if chart_type in self.SPECIAL_DATA_TYPES: | 152 | if chart_type in self.SPECIAL_DATA_TYPES: |
| 138 | # 特殊数据格式(scatter, bubble) | 153 | # 特殊数据格式(scatter, bubble) |
| 139 | self._validate_special_data(data, chart_type, errors, warnings) | 154 | self._validate_special_data(data, chart_type, errors, warnings) |
| 140 | else: | 155 | else: |
| 141 | # 标准数据格式(labels + datasets) | 156 | # 标准数据格式(labels + datasets) |
| 142 | - self._validate_standard_data(data, chart_type, errors, warnings) | 157 | + self._validate_standard_data(data, chart_type, errors, warnings, uses_object_points) |
| 143 | 158 | ||
| 144 | # 7. 验证props | 159 | # 7. 验证props |
| 145 | props = widget_block.get('props') | 160 | props = widget_block.get('props') |
| @@ -186,7 +201,8 @@ class ChartValidator: | @@ -186,7 +201,8 @@ class ChartValidator: | ||
| 186 | data: Dict[str, Any], | 201 | data: Dict[str, Any], |
| 187 | chart_type: str, | 202 | chart_type: str, |
| 188 | errors: List[str], | 203 | errors: List[str], |
| 189 | - warnings: List[str] | 204 | + warnings: List[str], |
| 205 | + uses_object_points: bool = False | ||
| 190 | ): | 206 | ): |
| 191 | """验证标准数据格式(labels + datasets)""" | 207 | """验证标准数据格式(labels + datasets)""" |
| 192 | labels = data.get('labels') | 208 | labels = data.get('labels') |
| @@ -195,7 +211,12 @@ class ChartValidator: | @@ -195,7 +211,12 @@ class ChartValidator: | ||
| 195 | # 验证labels | 211 | # 验证labels |
| 196 | if chart_type in self.LABEL_REQUIRED_TYPES: | 212 | if chart_type in self.LABEL_REQUIRED_TYPES: |
| 197 | if not labels: | 213 | if not labels: |
| 198 | - errors.append(f"{chart_type}类型图表必须包含labels字段") | 214 | + if uses_object_points: |
| 215 | + warnings.append( | ||
| 216 | + f"{chart_type}类型图表缺少labels,已根据数据点渲染(使用x值)" | ||
| 217 | + ) | ||
| 218 | + else: | ||
| 219 | + errors.append(f"{chart_type}类型图表必须包含labels字段") | ||
| 199 | elif not isinstance(labels, list): | 220 | elif not isinstance(labels, list): |
| 200 | errors.append("labels必须是数组类型") | 221 | errors.append("labels必须是数组类型") |
| 201 | elif len(labels) == 0: | 222 | elif len(labels) == 0: |
| @@ -234,15 +255,21 @@ class ChartValidator: | @@ -234,15 +255,21 @@ class ChartValidator: | ||
| 234 | warnings.append(f"datasets[{idx}].data数组为空") | 255 | warnings.append(f"datasets[{idx}].data数组为空") |
| 235 | continue | 256 | continue |
| 236 | 257 | ||
| 258 | + # 如果是{x, y}对象形式的数据点,默认允许跳过labels长度和数值校验 | ||
| 259 | + object_points = any( | ||
| 260 | + isinstance(value, dict) and any(key in value for key in ('x', 'y', 't')) | ||
| 261 | + for value in ds_data | ||
| 262 | + ) | ||
| 263 | + | ||
| 237 | # 验证数据长度一致性 | 264 | # 验证数据长度一致性 |
| 238 | - if labels and isinstance(labels, list): | 265 | + if labels and isinstance(labels, list) and not object_points: |
| 239 | if len(ds_data) != len(labels): | 266 | if len(ds_data) != len(labels): |
| 240 | warnings.append( | 267 | warnings.append( |
| 241 | f"datasets[{idx}].data长度({len(ds_data)})与labels长度({len(labels)})不匹配" | 268 | f"datasets[{idx}].data长度({len(ds_data)})与labels长度({len(labels)})不匹配" |
| 242 | ) | 269 | ) |
| 243 | 270 | ||
| 244 | # 验证数值类型 | 271 | # 验证数值类型 |
| 245 | - if chart_type in self.NUMERIC_DATA_TYPES: | 272 | + if chart_type in self.NUMERIC_DATA_TYPES and not object_points: |
| 246 | for data_idx, value in enumerate(ds_data): | 273 | for data_idx, value in enumerate(ds_data): |
| 247 | if value is not None and not isinstance(value, (int, float)): | 274 | if value is not None and not isinstance(value, (int, float)): |
| 248 | errors.append( | 275 | errors.append( |
-
Please register or login to post a comment