返回 oh-my-ppt
chart-renderer.ts
根目录 / src / main / io / pptx-import / chart-renderer.ts
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
220 lines TYPESCRIPT