| 1 | import type { Chart } from '@arcsin1/pptx2json' |
| 2 | import { escapeHtml } from '../../presentation/html/escape' |
| 3 | import type { ImportedElementAnimation } from './animation-import' |
| 4 | import { buildAnimationAttrs, buildBlockStyle, clampNumber } from './render-shared' |
| 5 | import type { ImportWarning } from './types' |
| 6 | |
| 7 | type ChartSeries = { |
| 8 | key?: string |
| 9 | values?: Array<{ x?: string; y?: number }> |
| 10 | } |
| 11 | |
| 12 | type MappedChartType = { |
| 13 | type: string |
| 14 | indexAxis?: 'x' | 'y' |
| 15 | fill?: boolean |
| 16 | showLine?: boolean |
| 17 | } |
| 18 | |
| 19 | type ChartTypeMapping = { |
| 20 | pattern: RegExp |
| 21 | map: (chartType: string, barDir?: string) => MappedChartType |
| 22 | } |
| 23 | |
| 24 | const PPTX_CHART_TYPE_MAPPINGS: ChartTypeMapping[] = [ |
| 25 | { pattern: /bubble/i, map: () => ({ type: 'bubble' }) }, |
| 26 | { pattern: /scatter/i, map: () => ({ type: 'scatter', showLine: true }) }, |
| 27 | { pattern: /doughnut/i, map: () => ({ type: 'doughnut' }) }, |
| 28 | { pattern: /pie/i, map: () => ({ type: 'pie' }) }, |
| 29 | { pattern: /area/i, map: () => ({ type: 'line', fill: true }) }, |
| 30 | { pattern: /line/i, map: () => ({ type: 'line' }) }, |
| 31 | { pattern: /radar/i, map: () => ({ type: 'radar' }) }, |
| 32 | { |
| 33 | pattern: /bar/i, |
| 34 | map: (_chartType, barDir) => ({ |
| 35 | type: 'bar', |
| 36 | // pptx2json uses barDir="bar" for horizontal bars and "col" for vertical columns. |
| 37 | indexAxis: barDir === 'bar' ? 'y' : 'x' |
| 38 | }) |
| 39 | } |
| 40 | ] |
| 41 | |
| 42 | const mapChartType = (chartType: string, barDir?: string): MappedChartType | null => { |
| 43 | const mapping = PPTX_CHART_TYPE_MAPPINGS.find((item) => item.pattern.test(chartType)) |
| 44 | return mapping ? mapping.map(chartType, barDir) : null |
| 45 | } |
| 46 | |
| 47 | const isNumericArray = (value: unknown): value is number[] => |
| 48 | Array.isArray(value) && value.every((item) => Number.isFinite(Number(item))) |
| 49 | |
| 50 | export const chartCanvasId = (pageId: string, chartIndex: number): string => `chart-${pageId}-${chartIndex}` |
| 51 | |
| 52 | export const unsupportedChartWarning = (blockId: string, chartType: string): string => |
| 53 | `图表 ${blockId}(${chartType || 'unknown'})暂不支持结构化导入,已作为占位导入` |
| 54 | |
| 55 | export const buildChartFrameStyle = (args: { |
| 56 | element: Chart |
| 57 | scaleX: number |
| 58 | scaleY: number |
| 59 | zIndex: number |
| 60 | offsetX: number |
| 61 | offsetY: number |
| 62 | }): string => |
| 63 | buildBlockStyle({ |
| 64 | element: args.element as unknown as Record<string, unknown>, |
| 65 | scaleX: args.scaleX, |
| 66 | scaleY: args.scaleY, |
| 67 | zIndex: args.zIndex, |
| 68 | offsetX: args.offsetX, |
| 69 | offsetY: args.offsetY, |
| 70 | overflow: 'hidden', |
| 71 | extra: ['background:#fff'] |
| 72 | }) |
| 73 | |
| 74 | export const buildChartHtmlFromConfig = (args: { |
| 75 | element: Chart |
| 76 | blockId: string |
| 77 | canvasId: string |
| 78 | frameStyle: string |
| 79 | animationAttrText: string |
| 80 | config: Record<string, unknown> |
| 81 | }): string => `<section data-block-id="${escapeHtml(args.blockId)}" data-pptx-kind="chart" data-pptx-import-mode="editable" data-pptx-chart-type="${escapeHtml(args.element.chartType)}" class="ppt-chart-frame"${args.animationAttrText} style="${args.frameStyle}"> |
| 82 | <canvas id="${args.canvasId}" class="h-full w-full"></canvas> |
| 83 | </section> |
| 84 | <script> |
| 85 | window.addEventListener("DOMContentLoaded", function () { |
| 86 | var el = document.getElementById("${args.canvasId}"); |
| 87 | if (!el || !window.PPT || !window.PPT.createChart) return; |
| 88 | window.PPT.createChart(el, ${JSON.stringify(args.config).replace(/</g, '\\u003c')}); |
| 89 | }); |
| 90 | </script>` |
| 91 | |
| 92 | const buildChartPlaceholderHtml = (args: { |
| 93 | element: Chart |
| 94 | blockId: string |
| 95 | frameStyle: string |
| 96 | animationAttrText: string |
| 97 | }): string => |
| 98 | `<section data-block-id="${escapeHtml(args.blockId)}" data-pptx-kind="chart" data-pptx-import-mode="placeholder" data-pptx-chart-type="${escapeHtml(args.element.chartType || 'unknown')}"${args.animationAttrText} style="${args.frameStyle};display:flex;align-items:center;justify-content:center;color:#6b7280;">图表已作为占位导入</section>` |
| 99 | |
| 100 | export const buildChartBlock = (args: { |
| 101 | element: Chart |
| 102 | blockId: string |
| 103 | animation?: ImportedElementAnimation |
| 104 | pageId: string |
| 105 | chartIndex: number |
| 106 | scaleX: number |
| 107 | scaleY: number |
| 108 | zIndex: number |
| 109 | offsetX: number |
| 110 | offsetY: number |
| 111 | pageNumber?: number |
| 112 | warnings?: ImportWarning[] |
| 113 | suppressUnsupportedWarning?: boolean |
| 114 | }): string => { |
| 115 | const chartType = mapChartType(args.element.chartType, 'barDir' in args.element ? args.element.barDir : undefined) |
| 116 | const canvasId = chartCanvasId(args.pageId, args.chartIndex) |
| 117 | const animationAttrs = buildAnimationAttrs(args.animation) |
| 118 | const animationAttrText = animationAttrs ? ` ${animationAttrs}` : '' |
| 119 | const frameStyle = buildChartFrameStyle({ |
| 120 | element: args.element, |
| 121 | scaleX: args.scaleX, |
| 122 | scaleY: args.scaleY, |
| 123 | zIndex: args.zIndex, |
| 124 | offsetX: args.offsetX, |
| 125 | offsetY: args.offsetY |
| 126 | }) |
| 127 | const data = 'data' in args.element ? args.element.data : null |
| 128 | const isCommonSeries = Array.isArray(data) && data.length > 0 && data.every((item) => { |
| 129 | const record = item as Partial<ChartSeries> | undefined |
| 130 | return Boolean(record && !Array.isArray(record) && Array.isArray(record.values)) |
| 131 | }) |
| 132 | const isPairedNumericSeries = |
| 133 | Array.isArray(data) && data.length >= 2 && isNumericArray(data[0]) && isNumericArray(data[1]) |
| 134 | if (!chartType || (!isCommonSeries && !isPairedNumericSeries)) { |
| 135 | if (!args.suppressUnsupportedWarning) { |
| 136 | args.warnings?.push({ |
| 137 | pageNumber: args.pageNumber, |
| 138 | message: unsupportedChartWarning(args.blockId, args.element.chartType) |
| 139 | }) |
| 140 | } |
| 141 | return buildChartPlaceholderHtml({ |
| 142 | element: args.element, |
| 143 | blockId: args.blockId, |
| 144 | frameStyle, |
| 145 | animationAttrText |
| 146 | }) |
| 147 | } |
| 148 | if (/3DChart/i.test(args.element.chartType)) { |
| 149 | args.warnings?.push({ |
| 150 | pageNumber: args.pageNumber, |
| 151 | message: `图表 ${args.blockId} 的 3D 效果已简化为二维图表` |
| 152 | }) |
| 153 | } |
| 154 | let labels: string[] = [] |
| 155 | let datasets: Array<Record<string, unknown>> = [] |
| 156 | let legendDisplay = false |
| 157 | if (isPairedNumericSeries) { |
| 158 | const xValues = (data[0] as number[]).map((value) => clampNumber(value)) |
| 159 | const yValues = (data[1] as number[]).map((value) => clampNumber(value)) |
| 160 | const radiusValues = isNumericArray(data[2]) ? data[2].map((value) => clampNumber(value, 6)) : [] |
| 161 | labels = xValues.map((value) => String(value)) |
| 162 | datasets = [ |
| 163 | { |
| 164 | label: 'Series 1', |
| 165 | data: xValues.map((x, index) => { |
| 166 | const y = yValues[index] ?? 0 |
| 167 | if (chartType.type !== 'bubble') return { x, y } |
| 168 | return { x, y, r: Math.max(3, Math.min(40, Math.abs(radiusValues[index] ?? 6))) } |
| 169 | }), |
| 170 | borderColor: args.element.colors?.[0] || undefined, |
| 171 | backgroundColor: args.element.colors?.[0] || undefined, |
| 172 | showLine: chartType.showLine || undefined, |
| 173 | tension: chartType.showLine ? 0.25 : undefined |
| 174 | } |
| 175 | ] |
| 176 | } else { |
| 177 | const series = data as ChartSeries[] |
| 178 | labels = series[0]?.values?.map((item) => item.x ?? '') || [] |
| 179 | const isSingleDatasetChart = chartType.type === 'pie' || chartType.type === 'doughnut' |
| 180 | legendDisplay = series.length > 1 || isSingleDatasetChart |
| 181 | datasets = isSingleDatasetChart |
| 182 | ? [ |
| 183 | { |
| 184 | label: series[0]?.key || 'Series 1', |
| 185 | data: (series[0]?.values || []).map((value) => value.y ?? 0), |
| 186 | backgroundColor: args.element.colors?.length ? args.element.colors : undefined, |
| 187 | borderColor: '#ffffff', |
| 188 | borderWidth: 1 |
| 189 | } |
| 190 | ] |
| 191 | : series.map((item, index) => ({ |
| 192 | label: item.key || `Series ${index + 1}`, |
| 193 | data: (item.values || []).map((value) => value.y ?? 0), |
| 194 | borderColor: args.element.colors?.[index] || undefined, |
| 195 | backgroundColor: args.element.colors?.[index] || undefined, |
| 196 | fill: chartType.fill || false, |
| 197 | tension: chartType.type === 'line' ? 0.25 : undefined |
| 198 | })) |
| 199 | } |
| 200 | const config = { |
| 201 | type: chartType.type, |
| 202 | data: { labels, datasets }, |
| 203 | options: { |
| 204 | responsive: true, |
| 205 | maintainAspectRatio: false, |
| 206 | indexAxis: chartType.indexAxis || 'x', |
| 207 | scales: isPairedNumericSeries ? { x: { type: 'linear' } } : undefined, |
| 208 | plugins: { legend: { display: legendDisplay } } |
| 209 | } |
| 210 | } |
| 211 | return buildChartHtmlFromConfig({ |
| 212 | element: args.element, |
| 213 | blockId: args.blockId, |
| 214 | canvasId, |
| 215 | frameStyle, |
| 216 | animationAttrText, |
| 217 | config |
| 218 | }) |
| 219 | } |
| 220 |