马一丁

Allows for Fixing Graphic Colors and Styles When Rendering HTML

1 """ 1 """
2 基于章节IR的HTML/PDF渲染器,实现与示例报告一致的交互与视觉。 2 基于章节IR的HTML/PDF渲染器,实现与示例报告一致的交互与视觉。
  3 +
  4 +新增要点:
  5 +1. 内置Chart.js数据验证/修复(ChartValidator+LLM兜底),杜绝非法配置导致的注入或崩溃;
  6 +2. 将MathJax/Chart.js/html2canvas/jspdf等依赖内联并带CDN fallback,适配离线或被墙环境;
  7 +3. 预置思源宋体子集的Base64字体,用于PDF/HTML一体化导出,避免缺字或额外系统依赖。
3 """ 8 """
4 9
5 from __future__ import annotations 10 from __future__ import annotations
@@ -2554,6 +2559,190 @@ const CHART_TYPE_LABELS = { @@ -2554,6 +2559,190 @@ const CHART_TYPE_LABELS = {
2554 polarArea: '极地区域图' 2559 polarArea: '极地区域图'
2555 }; 2560 };
2556 2561
  2562 +// 与PDF矢量渲染保持一致的颜色替换/提亮规则
  2563 +const DEFAULT_CHART_COLORS = [
  2564 + '#4A90E2', '#E85D75', '#50C878', '#FFB347',
  2565 + '#9B59B6', '#3498DB', '#E67E22', '#16A085',
  2566 + '#F39C12', '#D35400', '#27AE60', '#8E44AD'
  2567 +];
  2568 +const CSS_VAR_COLOR_MAP = {
  2569 + 'var(--color-accent)': '#4A90E2',
  2570 + 'var(--re-accent-color)': '#4A90E2',
  2571 + 'var(--re-accent-color-translucent)': 'rgba(74, 144, 226, 0.08)',
  2572 + 'var(--color-kpi-down)': '#E85D75',
  2573 + 'var(--re-danger-color)': '#E85D75',
  2574 + 'var(--re-danger-color-translucent)': 'rgba(232, 93, 117, 0.08)',
  2575 + 'var(--color-warning)': '#FFB347',
  2576 + 'var(--re-warning-color)': '#FFB347',
  2577 + 'var(--re-warning-color-translucent)': 'rgba(255, 179, 71, 0.08)',
  2578 + 'var(--color-success)': '#50C878',
  2579 + 'var(--re-success-color)': '#50C878',
  2580 + 'var(--re-success-color-translucent)': 'rgba(80, 200, 120, 0.08)',
  2581 + 'var(--color-primary)': '#3498DB',
  2582 + 'var(--color-secondary)': '#95A5A6'
  2583 +};
  2584 +
  2585 +function normalizeColorToken(color) {
  2586 + if (typeof color !== 'string') return color;
  2587 + const trimmed = color.trim();
  2588 + if (!trimmed) return null;
  2589 + if (CSS_VAR_COLOR_MAP[trimmed]) {
  2590 + return CSS_VAR_COLOR_MAP[trimmed];
  2591 + }
  2592 + if (trimmed.startsWith('var(')) {
  2593 + if (/accent|primary/i.test(trimmed)) return '#4A90E2';
  2594 + if (/danger|down|error/i.test(trimmed)) return '#E85D75';
  2595 + if (/warning/i.test(trimmed)) return '#FFB347';
  2596 + if (/success|up/i.test(trimmed)) return '#50C878';
  2597 + return '#3498DB';
  2598 + }
  2599 + return trimmed;
  2600 +}
  2601 +
  2602 +function hexToRgb(color) {
  2603 + if (typeof color !== 'string') return null;
  2604 + const normalized = color.replace('#', '');
  2605 + if (!(normalized.length === 3 || normalized.length === 6)) return null;
  2606 + const hex = normalized.length === 3 ? normalized.split('').map(c => c + c).join('') : normalized;
  2607 + const intVal = parseInt(hex, 16);
  2608 + if (Number.isNaN(intVal)) return null;
  2609 + return [(intVal >> 16) & 255, (intVal >> 8) & 255, intVal & 255];
  2610 +}
  2611 +
  2612 +function parseRgbString(color) {
  2613 + if (typeof color !== 'string') return null;
  2614 + const match = color.match(/rgba?\s*\(([^)]+)\)/i);
  2615 + if (!match) return null;
  2616 + const parts = match[1].split(',').map(p => parseFloat(p.trim())).filter(v => !Number.isNaN(v));
  2617 + if (parts.length < 3) return null;
  2618 + return [parts[0], parts[1], parts[2]].map(v => Math.max(0, Math.min(255, v)));
  2619 +}
  2620 +
  2621 +function rgbFromColor(color) {
  2622 + const normalized = normalizeColorToken(color);
  2623 + return hexToRgb(normalized) || parseRgbString(normalized);
  2624 +}
  2625 +
  2626 +function colorLuminance(color) {
  2627 + const rgb = rgbFromColor(color);
  2628 + if (!rgb) return null;
  2629 + const [r, g, b] = rgb.map(v => {
  2630 + const c = v / 255;
  2631 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  2632 + });
  2633 + return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  2634 +}
  2635 +
  2636 +function lightenColor(color, ratio) {
  2637 + const rgb = rgbFromColor(color);
  2638 + if (!rgb) return color;
  2639 + const factor = Math.min(1, Math.max(0, ratio || 0.25));
  2640 + const mixed = rgb.map(v => Math.round(v + (255 - v) * factor));
  2641 + return `rgb(${mixed[0]}, ${mixed[1]}, ${mixed[2]})`;
  2642 +}
  2643 +
  2644 +function ensureAlpha(color, alpha) {
  2645 + const rgb = rgbFromColor(color);
  2646 + if (!rgb) return color;
  2647 + const clamped = Math.min(1, Math.max(0, alpha));
  2648 + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamped})`;
  2649 +}
  2650 +
  2651 +function liftDarkColor(color) {
  2652 + const normalized = normalizeColorToken(color);
  2653 + const lum = colorLuminance(normalized);
  2654 + if (lum !== null && lum < 0.12) {
  2655 + return lightenColor(normalized, 0.35);
  2656 + }
  2657 + return normalized;
  2658 +}
  2659 +
  2660 +function normalizeDatasetColors(payload, chartType) {
  2661 + const changes = [];
  2662 + const data = payload && payload.data;
  2663 + if (!data || !Array.isArray(data.datasets)) {
  2664 + return changes;
  2665 + }
  2666 + const type = chartType || 'bar';
  2667 + const needsArrayColors = type === 'pie' || type === 'doughnut' || type === 'polarArea';
  2668 +
  2669 + data.datasets.forEach((dataset, idx) => {
  2670 + if (!isPlainObject(dataset)) return;
  2671 + const paletteColor = normalizeColorToken(DEFAULT_CHART_COLORS[idx % DEFAULT_CHART_COLORS.length]);
  2672 + const baseCandidate = Array.isArray(dataset.borderColor)
  2673 + ? dataset.borderColor[0]
  2674 + : dataset.borderColor || dataset.backgroundColor || dataset.color || paletteColor;
  2675 + const liftedBase = liftDarkColor(baseCandidate || paletteColor);
  2676 +
  2677 + if (needsArrayColors) {
  2678 + const labelCount = Array.isArray(data.labels) ? data.labels.length : 0;
  2679 + const rawColors = Array.isArray(dataset.backgroundColor) ? dataset.backgroundColor : [];
  2680 + const dataLength = Array.isArray(dataset.data) ? dataset.data.length : 0;
  2681 + const total = Math.max(labelCount, rawColors.length, dataLength, 1);
  2682 + const normalizedColors = [];
  2683 + for (let i = 0; i < total; i++) {
  2684 + const fallbackColor = DEFAULT_CHART_COLORS[(idx + i) % DEFAULT_CHART_COLORS.length];
  2685 + const normalizedColor = liftDarkColor(rawColors[i] || fallbackColor);
  2686 + normalizedColors.push(normalizedColor);
  2687 + }
  2688 + dataset.backgroundColor = normalizedColors;
  2689 + changes.push(`dataset${idx}: 标准化扇区颜色(${normalizedColors.length})`);
  2690 + return;
  2691 + }
  2692 +
  2693 + const borderIsArray = Array.isArray(dataset.borderColor);
  2694 + if (!dataset.borderColor) {
  2695 + dataset.borderColor = liftedBase;
  2696 + changes.push(`dataset${idx}: 补全边框色`);
  2697 + } else if (borderIsArray) {
  2698 + dataset.borderColor = dataset.borderColor.map(col => liftDarkColor(col));
  2699 + } else {
  2700 + dataset.borderColor = liftDarkColor(dataset.borderColor);
  2701 + }
  2702 +
  2703 + const bgIsArray = Array.isArray(dataset.backgroundColor);
  2704 + if (bgIsArray) {
  2705 + dataset.backgroundColor = dataset.backgroundColor.map(col => liftDarkColor(col));
  2706 + }
  2707 +
  2708 + const typeAlpha = type === 'line'
  2709 + ? (dataset.fill ? 0.08 : 0.12)
  2710 + : type === 'radar'
  2711 + ? 0.25
  2712 + : type === 'scatter' || type === 'bubble'
  2713 + ? 0.6
  2714 + : type === 'bar'
  2715 + ? 0.85
  2716 + : null;
  2717 +
  2718 + if (typeAlpha !== null) {
  2719 + if (bgIsArray && dataset.backgroundColor.length) {
  2720 + dataset.backgroundColor = dataset.backgroundColor.map(col => ensureAlpha(col, typeAlpha));
  2721 + } else {
  2722 + dataset.backgroundColor = ensureAlpha(liftedBase, typeAlpha);
  2723 + }
  2724 + if (dataset.fill || type !== 'line') {
  2725 + changes.push(`dataset${idx}: 应用淡化填充以避免遮挡`);
  2726 + }
  2727 + } else if (!dataset.backgroundColor) {
  2728 + dataset.backgroundColor = ensureAlpha(liftedBase, 0.85);
  2729 + } else if (!bgIsArray) {
  2730 + dataset.backgroundColor = liftDarkColor(dataset.backgroundColor);
  2731 + }
  2732 +
  2733 + if (type === 'line' && !dataset.pointBackgroundColor) {
  2734 + dataset.pointBackgroundColor = Array.isArray(dataset.borderColor)
  2735 + ? dataset.borderColor[0]
  2736 + : dataset.borderColor;
  2737 + }
  2738 + });
  2739 +
  2740 + if (changes.length) {
  2741 + payload._colorAudit = changes;
  2742 + }
  2743 + return changes;
  2744 +}
  2745 +
2557 function getThemePalette() { 2746 function getThemePalette() {
2558 const styles = getComputedStyle(document.body); 2747 const styles = getComputedStyle(document.body);
2559 return { 2748 return {
@@ -2901,13 +3090,17 @@ function hydrateCharts() { @@ -2901,13 +3090,17 @@ function hydrateCharts() {
2901 3090
2902 // 前端数据验证 3091 // 前端数据验证
2903 const desiredType = chartTypes[0]; 3092 const desiredType = chartTypes[0];
  3093 + const card = canvas.closest('.chart-card') || canvas.parentElement;
  3094 + const colorAdjustments = normalizeDatasetColors(payload, desiredType);
  3095 + if (colorAdjustments.length && card) {
  3096 + card.setAttribute('data-chart-color-fixes', colorAdjustments.join(' | '));
  3097 + }
2904 const validation = validateChartData(payload, desiredType); 3098 const validation = validateChartData(payload, desiredType);
2905 if (!validation.valid) { 3099 if (!validation.valid) {
2906 console.warn('图表数据验证失败:', validation.errors); 3100 console.warn('图表数据验证失败:', validation.errors);
2907 // 验证失败但仍然尝试渲染,因为可能会降级成功 3101 // 验证失败但仍然尝试渲染,因为可能会降级成功
2908 } 3102 }
2909 3103
2910 - const card = canvas.closest('.chart-card') || canvas.parentElement;  
2911 const optionsTemplate = buildChartOptions(payload); 3104 const optionsTemplate = buildChartOptions(payload);
2912 let chartInstance = null; 3105 let chartInstance = null;
2913 let selectedType = null; 3106 let selectedType = null;