Optimize the Method of Automatically Repairing Charts in PDF
This commit is contained in:
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import re
|
import re
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ try:
|
|||||||
import matplotlib
|
import matplotlib
|
||||||
matplotlib.use('Agg') # 使用非GUI后端
|
matplotlib.use('Agg') # 使用非GUI后端
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.dates as mdates
|
||||||
import matplotlib.font_manager as fm
|
import matplotlib.font_manager as fm
|
||||||
from matplotlib.patches import Wedge, Rectangle
|
from matplotlib.patches import Wedge, Rectangle
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -70,6 +72,15 @@ class ChartToSVGConverter:
|
|||||||
'var(--color-secondary)': '#95A5A6', # 浅灰色
|
'var(--color-secondary)': '#95A5A6', # 浅灰色
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 支持解析 rgba(var(--color-primary-rgb), 0.5) 这类格式的兜底映射
|
||||||
|
CSS_VAR_RGB_MAP = {
|
||||||
|
'color-primary-rgb': (52, 152, 219),
|
||||||
|
'color-tone-up-rgb': (80, 200, 120),
|
||||||
|
'color-tone-down-rgb': (232, 93, 117),
|
||||||
|
'color-accent-positive-rgb': (80, 200, 120),
|
||||||
|
'color-accent-neutral-rgb': (149, 165, 166),
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, font_path: Optional[str] = None):
|
def __init__(self, font_path: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
初始化转换器
|
初始化转换器
|
||||||
@@ -192,6 +203,25 @@ class ChartToSVGConverter:
|
|||||||
|
|
||||||
color = color.strip()
|
color = color.strip()
|
||||||
|
|
||||||
|
# 处理 rgba(var(--color-primary-rgb), 0.5) / rgb(var(--color-primary-rgb))
|
||||||
|
var_rgba_pattern = r'rgba?\(var\(--([\w-]+)\)\s*(?:,\s*([\d.]+))?\)'
|
||||||
|
match = re.match(var_rgba_pattern, color)
|
||||||
|
if match:
|
||||||
|
var_name, alpha_str = match.groups()
|
||||||
|
rgb_tuple = self.CSS_VAR_RGB_MAP.get(var_name)
|
||||||
|
|
||||||
|
# 兼容缺少 -rgb 后缀的写法
|
||||||
|
if not rgb_tuple:
|
||||||
|
if var_name.endswith('-rgb'):
|
||||||
|
rgb_tuple = self.CSS_VAR_RGB_MAP.get(var_name[:-4])
|
||||||
|
else:
|
||||||
|
rgb_tuple = self.CSS_VAR_RGB_MAP.get(f"{var_name}-rgb")
|
||||||
|
|
||||||
|
if rgb_tuple:
|
||||||
|
r, g, b = rgb_tuple
|
||||||
|
alpha = float(alpha_str) if alpha_str is not None else 1.0
|
||||||
|
return (r / 255, g / 255, b / 255, alpha)
|
||||||
|
|
||||||
# 【增强】处理CSS变量,例如 var(--color-accent)
|
# 【增强】处理CSS变量,例如 var(--color-accent)
|
||||||
# 使用预定义的颜色映射表替代CSS变量,确保不同变量有不同的颜色
|
# 使用预定义的颜色映射表替代CSS变量,确保不同变量有不同的颜色
|
||||||
if color.startswith('var('):
|
if color.startswith('var('):
|
||||||
@@ -288,10 +318,17 @@ class ChartToSVGConverter:
|
|||||||
- 线条样式(tension曲线平滑)
|
- 线条样式(tension曲线平滑)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
labels = data.get('labels', [])
|
labels = data.get('labels') or []
|
||||||
datasets = data.get('datasets', [])
|
datasets = data.get('datasets') or []
|
||||||
|
|
||||||
if not labels or not datasets:
|
has_object_points = any(
|
||||||
|
isinstance(ds, dict)
|
||||||
|
and isinstance(ds.get('data'), list)
|
||||||
|
and any(isinstance(pt, dict) and ('x' in pt or 'y' in pt) for pt in ds.get('data'))
|
||||||
|
for ds in datasets
|
||||||
|
)
|
||||||
|
|
||||||
|
if (not datasets) or ((not labels) and not has_object_points):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 收集所有唯一的yAxisID
|
# 收集所有唯一的yAxisID
|
||||||
@@ -312,6 +349,7 @@ class ChartToSVGConverter:
|
|||||||
title = props.get('title')
|
title = props.get('title')
|
||||||
options = props.get('options', {})
|
options = props.get('options', {})
|
||||||
scales = options.get('scales', {})
|
scales = options.get('scales', {})
|
||||||
|
x_tick_labels = list(labels) if isinstance(labels, list) else []
|
||||||
|
|
||||||
# 创建图表和多个y轴
|
# 创建图表和多个y轴
|
||||||
fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi)
|
fig, ax1 = plt.subplots(figsize=(width/dpi, height/dpi), dpi=dpi)
|
||||||
@@ -376,41 +414,90 @@ class ChartToSVGConverter:
|
|||||||
# 选择对应的坐标轴
|
# 选择对应的坐标轴
|
||||||
ax = axes.get(y_axis_id, ax1)
|
ax = axes.get(y_axis_id, ax1)
|
||||||
|
|
||||||
# 绘制折线
|
is_object_data = isinstance(dataset_data, list) and any(
|
||||||
x_data = range(len(labels))
|
isinstance(point, dict) and ('x' in point or 'y' in point)
|
||||||
|
for point in dataset_data
|
||||||
|
)
|
||||||
|
|
||||||
# 根据tension值决定是否平滑
|
if is_object_data:
|
||||||
if tension > 0 and SCIPY_AVAILABLE:
|
x_data = []
|
||||||
# 使用样条插值平滑曲线(需要scipy)
|
y_data = []
|
||||||
if len(dataset_data) >= 4: # 至少需要4个点才能平滑
|
annotations = []
|
||||||
|
|
||||||
|
for idx, point in enumerate(dataset_data):
|
||||||
|
if not isinstance(point, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
label_text = str(point.get('x', f"点{idx + 1}"))
|
||||||
|
if len(x_tick_labels) < len(dataset_data):
|
||||||
|
x_tick_labels.append(label_text)
|
||||||
|
|
||||||
|
x_data.append(len(x_data))
|
||||||
|
|
||||||
|
y_val = point.get('y', 0)
|
||||||
try:
|
try:
|
||||||
x_smooth = np.linspace(0, len(labels)-1, len(labels)*3)
|
y_val = float(y_val)
|
||||||
spl = make_interp_spline(x_data, dataset_data, k=min(3, len(dataset_data)-1))
|
except (TypeError, ValueError):
|
||||||
y_smooth = spl(x_smooth)
|
y_val = 0
|
||||||
line, = ax.plot(x_smooth, y_smooth, label=label, color=border_color, linewidth=2)
|
y_data.append(y_val)
|
||||||
|
annotations.append(point.get('event'))
|
||||||
|
|
||||||
# 如果需要填充(使用极低透明度避免遮挡)
|
if not x_data:
|
||||||
if fill:
|
continue
|
||||||
ax.fill_between(x_smooth, y_smooth, alpha=0.08, color=background_color)
|
|
||||||
except:
|
line, = ax.plot(x_data, y_data, marker='o', label=label,
|
||||||
# 如果平滑失败,使用普通折线
|
color=border_color, linewidth=2, markersize=6)
|
||||||
|
|
||||||
|
if fill:
|
||||||
|
ax.fill_between(x_data, y_data, alpha=0.08, color=background_color)
|
||||||
|
|
||||||
|
for pos, y_val, text in zip(x_data, y_data, annotations):
|
||||||
|
if text:
|
||||||
|
ax.annotate(
|
||||||
|
text,
|
||||||
|
(pos, y_val),
|
||||||
|
textcoords='offset points',
|
||||||
|
xytext=(0, 8),
|
||||||
|
ha='center',
|
||||||
|
fontsize=8,
|
||||||
|
rotation=20
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 绘制折线
|
||||||
|
x_data = range(len(labels))
|
||||||
|
|
||||||
|
# 根据tension值决定是否平滑
|
||||||
|
if tension > 0 and SCIPY_AVAILABLE:
|
||||||
|
# 使用样条插值平滑曲线(需要scipy)
|
||||||
|
if len(dataset_data) >= 4: # 至少需要4个点才能平滑
|
||||||
|
try:
|
||||||
|
x_smooth = np.linspace(0, len(labels)-1, len(labels)*3)
|
||||||
|
spl = make_interp_spline(x_data, dataset_data, k=min(3, len(dataset_data)-1))
|
||||||
|
y_smooth = spl(x_smooth)
|
||||||
|
line, = ax.plot(x_smooth, y_smooth, label=label, color=border_color, linewidth=2)
|
||||||
|
|
||||||
|
# 如果需要填充(使用极低透明度避免遮挡)
|
||||||
|
if fill:
|
||||||
|
ax.fill_between(x_smooth, y_smooth, alpha=0.08, color=background_color)
|
||||||
|
except:
|
||||||
|
# 如果平滑失败,使用普通折线
|
||||||
|
line, = ax.plot(x_data, dataset_data, marker='o', label=label,
|
||||||
|
color=border_color, linewidth=2, markersize=6)
|
||||||
|
if fill:
|
||||||
|
ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color)
|
||||||
|
else:
|
||||||
line, = ax.plot(x_data, dataset_data, marker='o', label=label,
|
line, = ax.plot(x_data, dataset_data, marker='o', label=label,
|
||||||
color=border_color, linewidth=2, markersize=6)
|
color=border_color, linewidth=2, markersize=6)
|
||||||
if fill:
|
if fill:
|
||||||
ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color)
|
ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color)
|
||||||
else:
|
else:
|
||||||
|
# 直线连接(tension=0或scipy不可用)
|
||||||
line, = ax.plot(x_data, dataset_data, marker='o', label=label,
|
line, = ax.plot(x_data, dataset_data, marker='o', label=label,
|
||||||
color=border_color, linewidth=2, markersize=6)
|
color=border_color, linewidth=2, markersize=6)
|
||||||
|
|
||||||
|
# 如果需要填充(使用极低透明度避免遮挡)
|
||||||
if fill:
|
if fill:
|
||||||
ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color)
|
ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color)
|
||||||
else:
|
|
||||||
# 直线连接(tension=0或scipy不可用)
|
|
||||||
line, = ax.plot(x_data, dataset_data, marker='o', label=label,
|
|
||||||
color=border_color, linewidth=2, markersize=6)
|
|
||||||
|
|
||||||
# 如果需要填充(使用极低透明度避免遮挡)
|
|
||||||
if fill:
|
|
||||||
ax.fill_between(x_data, dataset_data, alpha=0.08, color=background_color)
|
|
||||||
|
|
||||||
# 记录这条线属于哪个轴
|
# 记录这条线属于哪个轴
|
||||||
axis_lines[y_axis_id].append(line)
|
axis_lines[y_axis_id].append(line)
|
||||||
@@ -430,8 +517,9 @@ class ChartToSVGConverter:
|
|||||||
legend_labels.append(label)
|
legend_labels.append(label)
|
||||||
|
|
||||||
# 设置x轴标签
|
# 设置x轴标签
|
||||||
ax1.set_xticks(range(len(labels)))
|
if x_tick_labels:
|
||||||
ax1.set_xticklabels(labels, rotation=45, ha='right')
|
ax1.set_xticks(range(len(x_tick_labels)))
|
||||||
|
ax1.set_xticklabels(x_tick_labels, rotation=45, ha='right')
|
||||||
|
|
||||||
# 设置y轴标签和标题
|
# 设置y轴标签和标题
|
||||||
for y_axis_id, ax in axes.items():
|
for y_axis_id, ax in axes.items():
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ class HTMLRenderer:
|
|||||||
self.secondary_heading_index = 0
|
self.secondary_heading_index = 0
|
||||||
self.toc_rendered = False
|
self.toc_rendered = False
|
||||||
self.hero_kpi_signature: tuple | None = None
|
self.hero_kpi_signature: tuple | None = None
|
||||||
|
self._current_chapter: Dict[str, Any] | None = None
|
||||||
self._lib_cache: Dict[str, str] = {}
|
self._lib_cache: Dict[str, str] = {}
|
||||||
self._pdf_font_base64: str | None = None
|
self._pdf_font_base64: str | None = None
|
||||||
|
|
||||||
@@ -967,7 +968,12 @@ class HTMLRenderer:
|
|||||||
str: section包裹的HTML。
|
str: section包裹的HTML。
|
||||||
"""
|
"""
|
||||||
section_id = self._escape_attr(chapter.get("anchor") or f"chapter-{chapter.get('chapterId', 'x')}")
|
section_id = self._escape_attr(chapter.get("anchor") or f"chapter-{chapter.get('chapterId', 'x')}")
|
||||||
blocks_html = self._render_blocks(chapter.get("blocks", []))
|
prev_chapter = self._current_chapter
|
||||||
|
self._current_chapter = chapter
|
||||||
|
try:
|
||||||
|
blocks_html = self._render_blocks(chapter.get("blocks", []))
|
||||||
|
finally:
|
||||||
|
self._current_chapter = prev_chapter
|
||||||
return f'<section id="{section_id}" class="chapter">\n{blocks_html}\n</section>'
|
return f'<section id="{section_id}" class="chapter">\n{blocks_html}\n</section>'
|
||||||
|
|
||||||
def _render_blocks(self, blocks: List[Dict[str, Any]]) -> str:
|
def _render_blocks(self, blocks: List[Dict[str, Any]]) -> str:
|
||||||
@@ -1406,6 +1412,98 @@ class HTMLRenderer:
|
|||||||
|
|
||||||
return props, normalized_data
|
return props, normalized_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_chart_data_empty(data: Dict[str, Any] | None) -> bool:
|
||||||
|
"""检查图表数据是否为空或缺少有效datasets"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return True
|
||||||
|
|
||||||
|
datasets = data.get("datasets")
|
||||||
|
if not isinstance(datasets, list) or len(datasets) == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for ds in datasets:
|
||||||
|
if not isinstance(ds, dict):
|
||||||
|
continue
|
||||||
|
series = ds.get("data")
|
||||||
|
if isinstance(series, list) and len(series) > 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _normalize_chart_block(
|
||||||
|
self,
|
||||||
|
block: Dict[str, Any],
|
||||||
|
chapter_context: Dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
补全图表block中的缺失字段(如scales、datasets),提升容错性。
|
||||||
|
|
||||||
|
- 将错误挂在block顶层的scales合并进props.options。
|
||||||
|
- 当data缺失或datasets为空时,尝试使用章节级的data作为兜底。
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
if block.get("type") != "widget":
|
||||||
|
return
|
||||||
|
|
||||||
|
widget_type = block.get("widgetType", "")
|
||||||
|
if not (isinstance(widget_type, str) and widget_type.startswith("chart.js")):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 确保props存在
|
||||||
|
props = block.get("props")
|
||||||
|
if not isinstance(props, dict):
|
||||||
|
block["props"] = {}
|
||||||
|
props = block["props"]
|
||||||
|
|
||||||
|
# 将顶层scales合并进options,避免配置丢失
|
||||||
|
scales = block.get("scales")
|
||||||
|
if isinstance(scales, dict):
|
||||||
|
options = props.get("options") if isinstance(props.get("options"), dict) else {}
|
||||||
|
props["options"] = self._merge_dicts(options, {"scales": scales})
|
||||||
|
|
||||||
|
# 确保data存在
|
||||||
|
data = block.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
block["data"] = data
|
||||||
|
|
||||||
|
# 如果datasets为空,尝试使用章节级data填充
|
||||||
|
if chapter_context and self._is_chart_data_empty(data):
|
||||||
|
chapter_data = chapter_context.get("data") if isinstance(chapter_context, dict) else None
|
||||||
|
if isinstance(chapter_data, dict):
|
||||||
|
fallback_ds = chapter_data.get("datasets")
|
||||||
|
if isinstance(fallback_ds, list) and len(fallback_ds) > 0:
|
||||||
|
merged_data = copy.deepcopy(data)
|
||||||
|
merged_data["datasets"] = copy.deepcopy(fallback_ds)
|
||||||
|
|
||||||
|
if not merged_data.get("labels") and isinstance(chapter_data.get("labels"), list):
|
||||||
|
merged_data["labels"] = copy.deepcopy(chapter_data["labels"])
|
||||||
|
|
||||||
|
block["data"] = merged_data
|
||||||
|
|
||||||
|
# 若仍缺少labels且数据点包含x值,自动生成便于fallback和坐标刻度
|
||||||
|
data_ref = block.get("data")
|
||||||
|
if isinstance(data_ref, dict) and not data_ref.get("labels"):
|
||||||
|
datasets_ref = data_ref.get("datasets")
|
||||||
|
if isinstance(datasets_ref, list) and datasets_ref:
|
||||||
|
first_ds = datasets_ref[0]
|
||||||
|
ds_data = first_ds.get("data") if isinstance(first_ds, dict) else None
|
||||||
|
if isinstance(ds_data, list):
|
||||||
|
labels_from_data = []
|
||||||
|
for idx, point in enumerate(ds_data):
|
||||||
|
if isinstance(point, dict):
|
||||||
|
label_text = point.get("x") or point.get("label") or f"点{idx + 1}"
|
||||||
|
else:
|
||||||
|
label_text = f"点{idx + 1}"
|
||||||
|
labels_from_data.append(str(label_text))
|
||||||
|
|
||||||
|
if labels_from_data:
|
||||||
|
data_ref["labels"] = labels_from_data
|
||||||
|
|
||||||
def _render_widget(self, block: Dict[str, Any]) -> str:
|
def _render_widget(self, block: Dict[str, Any]) -> str:
|
||||||
"""
|
"""
|
||||||
渲染Chart.js等交互组件的占位容器,并记录配置JSON。
|
渲染Chart.js等交互组件的占位容器,并记录配置JSON。
|
||||||
@@ -1422,6 +1520,9 @@ class HTMLRenderer:
|
|||||||
返回:
|
返回:
|
||||||
str: 含canvas与配置脚本的HTML。
|
str: 含canvas与配置脚本的HTML。
|
||||||
"""
|
"""
|
||||||
|
# 先在block层面做一次容错补全(scales、章节级数据等)
|
||||||
|
self._normalize_chart_block(block, getattr(self, "_current_chapter", None))
|
||||||
|
|
||||||
# 统计
|
# 统计
|
||||||
widget_type = block.get('widgetType', '')
|
widget_type = block.get('widgetType', '')
|
||||||
is_chart = isinstance(widget_type, str) and widget_type.startswith('chart.js')
|
is_chart = isinstance(widget_type, str) and widget_type.startswith('chart.js')
|
||||||
@@ -1489,7 +1590,7 @@ class HTMLRenderer:
|
|||||||
|
|
||||||
title = props.get("title")
|
title = props.get("title")
|
||||||
title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else ""
|
title_html = f'<div class="chart-title">{self._escape_html(title)}</div>' if title else ""
|
||||||
fallback_html = self._render_widget_fallback(normalized_data)
|
fallback_html = self._render_widget_fallback(normalized_data, block.get("widgetId"))
|
||||||
return f"""
|
return f"""
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
{title_html}
|
{title_html}
|
||||||
@@ -1500,7 +1601,7 @@ class HTMLRenderer:
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _render_widget_fallback(self, data: Dict[str, Any]) -> str:
|
def _render_widget_fallback(self, data: Dict[str, Any], widget_id: str | None = None) -> str:
|
||||||
"""渲染图表数据的文本兜底视图,避免Chart.js加载失败时出现空白"""
|
"""渲染图表数据的文本兜底视图,避免Chart.js加载失败时出现空白"""
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return ""
|
return ""
|
||||||
@@ -1508,6 +1609,8 @@ class HTMLRenderer:
|
|||||||
datasets = data.get("datasets") or []
|
datasets = data.get("datasets") or []
|
||||||
if not labels or not datasets:
|
if not labels or not datasets:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
widget_attr = f' data-widget-id="{self._escape_attr(widget_id)}"' if widget_id else ""
|
||||||
header_cells = "".join(
|
header_cells = "".join(
|
||||||
f"<th>{self._escape_html(ds.get('label') or f'系列{idx + 1}')}</th>"
|
f"<th>{self._escape_html(ds.get('label') or f'系列{idx + 1}')}</th>"
|
||||||
for idx, ds in enumerate(datasets)
|
for idx, ds in enumerate(datasets)
|
||||||
@@ -1521,7 +1624,7 @@ class HTMLRenderer:
|
|||||||
row_cells.append(f"<td>{self._escape_html(value)}</td>")
|
row_cells.append(f"<td>{self._escape_html(value)}</td>")
|
||||||
body_rows += f"<tr>{''.join(row_cells)}</tr>"
|
body_rows += f"<tr>{''.join(row_cells)}</tr>"
|
||||||
table_html = f"""
|
table_html = f"""
|
||||||
<div class="chart-fallback" data-prebuilt="true">
|
<div class="chart-fallback" data-prebuilt="true"{widget_attr}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>类别</th>{header_cells}</tr>
|
<tr><th>类别</th>{header_cells}</tr>
|
||||||
|
|||||||
@@ -7,11 +7,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import copy
|
import copy
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
# 在导入WeasyPrint之前,尝试补充常见的macOS Homebrew动态库路径,
|
||||||
|
# 避免因未设置DYLD_LIBRARY_PATH而找不到pango/cairo等依赖。
|
||||||
|
if sys.platform == 'darwin':
|
||||||
|
brew_lib = Path('/opt/homebrew/lib')
|
||||||
|
if brew_lib.exists():
|
||||||
|
current = os.environ.get('DYLD_LIBRARY_PATH', '')
|
||||||
|
if str(brew_lib) not in current.split(':'):
|
||||||
|
os.environ['DYLD_LIBRARY_PATH'] = f"{brew_lib}{':' + current if current else ''}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from weasyprint import HTML, CSS
|
from weasyprint import HTML, CSS
|
||||||
from weasyprint.text.fonts import FontConfiguration
|
from weasyprint.text.fonts import FontConfiguration
|
||||||
@@ -128,7 +139,7 @@ class PDFRenderer:
|
|||||||
'failed': 0
|
'failed': 0
|
||||||
}
|
}
|
||||||
|
|
||||||
def repair_widgets_in_blocks(blocks: list) -> None:
|
def repair_widgets_in_blocks(blocks: list, chapter_context: Dict[str, Any] | None = None) -> None:
|
||||||
"""递归修复blocks中的所有widget"""
|
"""递归修复blocks中的所有widget"""
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
if not isinstance(block, dict):
|
if not isinstance(block, dict):
|
||||||
@@ -136,6 +147,12 @@ class PDFRenderer:
|
|||||||
|
|
||||||
# 处理widget类型
|
# 处理widget类型
|
||||||
if block.get('type') == 'widget':
|
if block.get('type') == 'widget':
|
||||||
|
# 先用HTML渲染器的容错逻辑补全字段
|
||||||
|
try:
|
||||||
|
self.html_renderer._normalize_chart_block(block, chapter_context)
|
||||||
|
except Exception as exc: # 防御性处理,避免单个图表阻断流程
|
||||||
|
logger.debug(f"预处理图表 {block.get('widgetId')} 时出错: {exc}")
|
||||||
|
|
||||||
widget_type = block.get('widgetType', '')
|
widget_type = block.get('widgetType', '')
|
||||||
if widget_type.startswith('chart.js'):
|
if widget_type.startswith('chart.js'):
|
||||||
repair_stats['total'] += 1
|
repair_stats['total'] += 1
|
||||||
@@ -164,32 +181,32 @@ class PDFRenderer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 递归处理嵌套的blocks
|
# 递归处理嵌套的blocks
|
||||||
nested_blocks = block.get('blocks')
|
nested_blocks = block.get('blocks')
|
||||||
if isinstance(nested_blocks, list):
|
if isinstance(nested_blocks, list):
|
||||||
repair_widgets_in_blocks(nested_blocks)
|
repair_widgets_in_blocks(nested_blocks, chapter_context)
|
||||||
|
|
||||||
# 处理列表项
|
# 处理列表项
|
||||||
if block.get('type') == 'list':
|
if block.get('type') == 'list':
|
||||||
items = block.get('items', [])
|
items = block.get('items', [])
|
||||||
for item in items:
|
for item in items:
|
||||||
if isinstance(item, list):
|
if isinstance(item, list):
|
||||||
repair_widgets_in_blocks(item)
|
repair_widgets_in_blocks(item, chapter_context)
|
||||||
|
|
||||||
# 处理表格单元格
|
# 处理表格单元格
|
||||||
if block.get('type') == 'table':
|
if block.get('type') == 'table':
|
||||||
rows = block.get('rows', [])
|
rows = block.get('rows', [])
|
||||||
for row in rows:
|
for row in rows:
|
||||||
cells = row.get('cells', [])
|
cells = row.get('cells', [])
|
||||||
for cell in cells:
|
for cell in cells:
|
||||||
cell_blocks = cell.get('blocks', [])
|
cell_blocks = cell.get('blocks', [])
|
||||||
if isinstance(cell_blocks, list):
|
if isinstance(cell_blocks, list):
|
||||||
repair_widgets_in_blocks(cell_blocks)
|
repair_widgets_in_blocks(cell_blocks, chapter_context)
|
||||||
|
|
||||||
# 处理所有章节
|
# 处理所有章节
|
||||||
chapters = ir_copy.get('chapters', [])
|
chapters = ir_copy.get('chapters', [])
|
||||||
for chapter in chapters:
|
for chapter in chapters:
|
||||||
blocks = chapter.get('blocks', [])
|
blocks = chapter.get('blocks', [])
|
||||||
repair_widgets_in_blocks(blocks)
|
repair_widgets_in_blocks(blocks, chapter)
|
||||||
|
|
||||||
# 输出统计信息
|
# 输出统计信息
|
||||||
if repair_stats['total'] > 0:
|
if repair_stats['total'] > 0:
|
||||||
@@ -425,6 +442,17 @@ class PDFRenderer:
|
|||||||
# 【修复】替换canvas为SVG,使用lambda避免反斜杠转义问题
|
# 【修复】替换canvas为SVG,使用lambda避免反斜杠转义问题
|
||||||
html = re.sub(canvas_pattern, lambda m: svg_html, html)
|
html = re.sub(canvas_pattern, lambda m: svg_html, html)
|
||||||
logger.debug(f"已替换图表 {widget_id} 的canvas为SVG")
|
logger.debug(f"已替换图表 {widget_id} 的canvas为SVG")
|
||||||
|
|
||||||
|
# 将对应fallback标记为隐藏,避免PDF中出现重复表格
|
||||||
|
fallback_pattern = rf'<div class="chart-fallback"([^>]*data-widget-id="{re.escape(widget_id)}"[^>]*)>'
|
||||||
|
|
||||||
|
def _hide_fallback(m: re.Match) -> str:
|
||||||
|
tag = m.group(0)
|
||||||
|
if 'svg-hidden' in tag:
|
||||||
|
return tag
|
||||||
|
return tag.replace('chart-fallback"', 'chart-fallback svg-hidden"', 1)
|
||||||
|
|
||||||
|
html = re.sub(fallback_pattern, _hide_fallback, html, count=1)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"未找到图表 {widget_id} 对应的配置脚本")
|
logger.warning(f"未找到图表 {widget_id} 对应的配置脚本")
|
||||||
|
|
||||||
@@ -617,8 +645,8 @@ body {{
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 隐藏fallback表格(因为现在使用SVG) */
|
/* 当对应SVG成功注入时隐藏fallback表格,失败时继续显示兜底数据 */
|
||||||
.chart-fallback {{
|
.chart-fallback.svg-hidden {{
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
|||||||
@@ -133,13 +133,28 @@ class ChartValidator:
|
|||||||
errors.append("data字段必须是字典类型")
|
errors.append("data字段必须是字典类型")
|
||||||
return ValidationResult(False, errors, warnings)
|
return ValidationResult(False, errors, warnings)
|
||||||
|
|
||||||
|
# 检测是否使用了{x, y}形式的数据点(通常用于时间轴/散点)
|
||||||
|
def contains_object_points(ds_list: List[Any] | None) -> bool:
|
||||||
|
if not isinstance(ds_list, list):
|
||||||
|
return False
|
||||||
|
for point in ds_list:
|
||||||
|
if isinstance(point, dict) and any(key in point for key in ('x', 'y', 't')):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
datasets_for_detection = data.get('datasets') or []
|
||||||
|
uses_object_points = any(
|
||||||
|
isinstance(ds, dict) and contains_object_points(ds.get('data'))
|
||||||
|
for ds in datasets_for_detection
|
||||||
|
)
|
||||||
|
|
||||||
# 6. 根据图表类型验证数据
|
# 6. 根据图表类型验证数据
|
||||||
if chart_type in self.SPECIAL_DATA_TYPES:
|
if chart_type in self.SPECIAL_DATA_TYPES:
|
||||||
# 特殊数据格式(scatter, bubble)
|
# 特殊数据格式(scatter, bubble)
|
||||||
self._validate_special_data(data, chart_type, errors, warnings)
|
self._validate_special_data(data, chart_type, errors, warnings)
|
||||||
else:
|
else:
|
||||||
# 标准数据格式(labels + datasets)
|
# 标准数据格式(labels + datasets)
|
||||||
self._validate_standard_data(data, chart_type, errors, warnings)
|
self._validate_standard_data(data, chart_type, errors, warnings, uses_object_points)
|
||||||
|
|
||||||
# 7. 验证props
|
# 7. 验证props
|
||||||
props = widget_block.get('props')
|
props = widget_block.get('props')
|
||||||
@@ -186,7 +201,8 @@ class ChartValidator:
|
|||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
chart_type: str,
|
chart_type: str,
|
||||||
errors: List[str],
|
errors: List[str],
|
||||||
warnings: List[str]
|
warnings: List[str],
|
||||||
|
uses_object_points: bool = False
|
||||||
):
|
):
|
||||||
"""验证标准数据格式(labels + datasets)"""
|
"""验证标准数据格式(labels + datasets)"""
|
||||||
labels = data.get('labels')
|
labels = data.get('labels')
|
||||||
@@ -195,7 +211,12 @@ class ChartValidator:
|
|||||||
# 验证labels
|
# 验证labels
|
||||||
if chart_type in self.LABEL_REQUIRED_TYPES:
|
if chart_type in self.LABEL_REQUIRED_TYPES:
|
||||||
if not labels:
|
if not labels:
|
||||||
errors.append(f"{chart_type}类型图表必须包含labels字段")
|
if uses_object_points:
|
||||||
|
warnings.append(
|
||||||
|
f"{chart_type}类型图表缺少labels,已根据数据点渲染(使用x值)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
errors.append(f"{chart_type}类型图表必须包含labels字段")
|
||||||
elif not isinstance(labels, list):
|
elif not isinstance(labels, list):
|
||||||
errors.append("labels必须是数组类型")
|
errors.append("labels必须是数组类型")
|
||||||
elif len(labels) == 0:
|
elif len(labels) == 0:
|
||||||
@@ -234,15 +255,21 @@ class ChartValidator:
|
|||||||
warnings.append(f"datasets[{idx}].data数组为空")
|
warnings.append(f"datasets[{idx}].data数组为空")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 如果是{x, y}对象形式的数据点,默认允许跳过labels长度和数值校验
|
||||||
|
object_points = any(
|
||||||
|
isinstance(value, dict) and any(key in value for key in ('x', 'y', 't'))
|
||||||
|
for value in ds_data
|
||||||
|
)
|
||||||
|
|
||||||
# 验证数据长度一致性
|
# 验证数据长度一致性
|
||||||
if labels and isinstance(labels, list):
|
if labels and isinstance(labels, list) and not object_points:
|
||||||
if len(ds_data) != len(labels):
|
if len(ds_data) != len(labels):
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"datasets[{idx}].data长度({len(ds_data)})与labels长度({len(labels)})不匹配"
|
f"datasets[{idx}].data长度({len(ds_data)})与labels长度({len(labels)})不匹配"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 验证数值类型
|
# 验证数值类型
|
||||||
if chart_type in self.NUMERIC_DATA_TYPES:
|
if chart_type in self.NUMERIC_DATA_TYPES and not object_points:
|
||||||
for data_idx, value in enumerate(ds_data):
|
for data_idx, value in enumerate(ds_data):
|
||||||
if value is not None and not isinstance(value, (int, float)):
|
if value is not None and not isinstance(value, (int, float)):
|
||||||
errors.append(
|
errors.append(
|
||||||
|
|||||||
Reference in New Issue
Block a user