马一丁

Optimize the Method of Automatically Repairing Charts in PDF

@@ -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(