From 2e0a526d2297b338a3cc459a14215c89f1739a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E4=B8=80=E4=B8=81?= <1769123563@qq.com> Date: Tue, 25 Nov 2025 15:42:00 +0800 Subject: [PATCH] Optimize the Color Replacement Scheme for Pie Charts --- ReportEngine/renderers/chart_to_svg.py | 63 ++++++++++++++++++++----- ReportEngine/renderers/html_renderer.py | 56 +++++++++++++++++++++- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/ReportEngine/renderers/chart_to_svg.py b/ReportEngine/renderers/chart_to_svg.py index f7dc302..ad1d3d6 100644 --- a/ReportEngine/renderers/chart_to_svg.py +++ b/ReportEngine/renderers/chart_to_svg.py @@ -278,6 +278,25 @@ class ChartToSVGConverter: # 其他格式(十六进制、颜色名等)直接返回 return color + def _ensure_visible_color(self, color: Any, fallback: str, min_alpha: float = 0.6) -> Any: + """ + 确保颜色在渲染时可见:避免透明值并提升过低的不透明度 + """ + base_color = fallback if color in (None, "", "transparent") else color + parsed = self._parse_color(base_color) + fallback_parsed = self._parse_color(fallback) + + if isinstance(parsed, tuple): + if len(parsed) == 4: + r, g, b, a = parsed + return (r, g, b, max(a, min_alpha)) + return parsed + + if isinstance(parsed, str) and parsed.lower() == "transparent": + return fallback_parsed + + return parsed if parsed is not None else fallback_parsed + def _get_colors(self, datasets: List[Dict[str, Any]]) -> List[str]: """ 获取图表颜色 @@ -659,12 +678,17 @@ class ChartToSVGConverter: fig, ax = self._create_figure(width, height, dpi, title) # 获取颜色 - colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)]) - if not isinstance(colors, list): - colors = self.DEFAULT_COLORS[:len(labels)] + raw_colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)]) + if not isinstance(raw_colors, list): + raw_colors = self.DEFAULT_COLORS[:len(labels)] - # 【修复】解析每个颜色,将CSS格式转换为matplotlib格式 - colors = [self._parse_color(c) for c in colors] + colors = [ + self._ensure_visible_color( + raw_colors[i] if i < len(raw_colors) else None, + self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)] + ) + for i in range(len(labels)) + ] # 绘制饼图 wedges, texts, autotexts = ax.pie( @@ -713,12 +737,17 @@ class ChartToSVGConverter: fig, ax = self._create_figure(width, height, dpi, title) # 获取颜色 - colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)]) - if not isinstance(colors, list): - colors = self.DEFAULT_COLORS[:len(labels)] + raw_colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)]) + if not isinstance(raw_colors, list): + raw_colors = self.DEFAULT_COLORS[:len(labels)] - # 【修复】解析每个颜色,将CSS格式转换为matplotlib格式 - colors = [self._parse_color(c) for c in colors] + colors = [ + self._ensure_visible_color( + raw_colors[i] if i < len(raw_colors) else None, + self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)] + ) + for i in range(len(labels)) + ] # 绘制圆环图(通过设置wedgeprops实现中空效果) wedges, texts, autotexts = ax.pie( @@ -889,9 +918,17 @@ class ChartToSVGConverter: ax.set_title(title, fontsize=14, fontweight='bold', pad=20) # 获取颜色 - colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)]) - if not isinstance(colors, list): - colors = self.DEFAULT_COLORS[:len(labels)] + raw_colors = dataset.get('backgroundColor', self.DEFAULT_COLORS[:len(labels)]) + if not isinstance(raw_colors, list): + raw_colors = self.DEFAULT_COLORS[:len(labels)] + + colors = [ + self._ensure_visible_color( + raw_colors[i] if i < len(raw_colors) else None, + self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)] + ) + for i in range(len(labels)) + ] # 计算角度 theta = np.linspace(0, 2 * np.pi, len(labels), endpoint=False) diff --git a/ReportEngine/renderers/html_renderer.py b/ReportEngine/renderers/html_renderer.py index b5cf2be..4c21712 100644 --- a/ReportEngine/renderers/html_renderer.py +++ b/ReportEngine/renderers/html_renderer.py @@ -2965,6 +2965,41 @@ function parseRgbString(color) { return [parts[0], parts[1], parts[2]].map(v => Math.max(0, Math.min(255, v))); } +function alphaFromColor(color) { + if (typeof color !== 'string') return null; + const raw = color.trim(); + if (!raw) return null; + if (raw.toLowerCase() === 'transparent') return 0; + + const extractAlpha = (source) => { + const match = source.match(/rgba?\s*\(([^)]+)\)/i); + if (!match) return null; + const parts = match[1].split(',').map(p => p.trim()); + if (source.toLowerCase().startsWith('rgba') && parts.length >= 2) { + const alphaToken = parts[parts.length - 1]; + const isPercent = /%$/.test(alphaToken); + const alphaVal = parseFloat(alphaToken.replace('%', '')); + if (!Number.isNaN(alphaVal)) { + const normalizedAlpha = isPercent ? alphaVal / 100 : alphaVal; + return Math.max(0, Math.min(1, normalizedAlpha)); + } + } + if (parts.length >= 3) return 1; + return null; + }; + + const rawAlpha = extractAlpha(raw); + if (rawAlpha !== null) return rawAlpha; + + const normalized = normalizeColorToken(raw); + if (typeof normalized === 'string' && normalized !== raw) { + const normalizedAlpha = extractAlpha(normalized); + if (normalizedAlpha !== null) return normalizedAlpha; + } + + return null; +} + function rgbFromColor(color) { const normalized = normalizeColorToken(color); return hexToRgb(normalized) || parseRgbString(normalized); @@ -3012,6 +3047,7 @@ function normalizeDatasetColors(payload, chartType) { } const type = chartType || 'bar'; const needsArrayColors = type === 'pie' || type === 'doughnut' || type === 'polarArea'; + const MIN_PIE_ALPHA = 0.6; const pickColor = (value, fallback) => { if (Array.isArray(value) && value.length) return value[0]; return value || fallback; @@ -3036,13 +3072,29 @@ function normalizeDatasetColors(payload, chartType) { const dataLength = Array.isArray(dataset.data) ? dataset.data.length : 0; const total = Math.max(labelCount, rawColors.length, dataLength, 1); const normalizedColors = []; + let fixedTransparentCount = 0; for (let i = 0; i < total; i++) { const fallbackColor = DEFAULT_CHART_COLORS[(idx + i) % DEFAULT_CHART_COLORS.length]; - const normalizedColor = liftDarkColor(rawColors[i] || fallbackColor); + const normalizedRaw = normalizeColorToken(rawColors[i]); + const alpha = alphaFromColor(normalizedRaw); + const isInvisible = typeof normalizedRaw === 'string' && normalizedRaw.toLowerCase() === 'transparent'; + if (alpha === 0 || isInvisible) { + fixedTransparentCount += 1; + } + const baseColor = (!normalizedRaw || isInvisible) ? fallbackColor : normalizedRaw; + const targetAlpha = alpha === null ? 1 : alpha; + const normalizedColor = ensureAlpha( + liftDarkColor(baseColor), + Math.max(MIN_PIE_ALPHA, targetAlpha) + ); normalizedColors.push(normalizedColor); } dataset.backgroundColor = normalizedColors; - changes.push(`dataset${idx}: 标准化扇区颜色(${normalizedColors.length})`); + dataset.borderColor = normalizedColors.map(col => ensureAlpha(liftDarkColor(col), 1)); + const changeLabel = fixedTransparentCount + ? `dataset${idx}: 修正${fixedTransparentCount}个透明扇区` + : `dataset${idx}: 标准化扇区颜色(${normalizedColors.length})`; + changes.push(changeLabel); return; }