返回 oh-my-ppt
chart-data-import.ts
根目录 / src / main / element-editor / chart-data-import.ts
1 /** IPC handler for importing data into a selected chart element. */
2 import { dialog, ipcMain } from 'electron'
3 import fs from 'fs'
4 import path from 'path'
5 import { createRequire } from 'module'
6 import type { IpcContext } from '../ipc/context'
7 import type { ParsedChartDataResult } from '../../shared/chart-data'
8
9 const require = createRequire(import.meta.url)
10 const MAX_ROWS = 200
11 const MAX_SERIES = 8
12 const X_KEYS = ['x', 'label', 'category', 'name']
13 const TABLE_X_HEADER_KEYS = [
14 ...X_KEYS,
15 'date',
16 'time',
17 'month',
18 'quarter',
19 'year',
20 '日期',
21 '时间',
22 '月份',
23 '季度',
24 '年份',
25 '分类',
26 '类别',
27 '名称',
28 '地区',
29 '产品'
30 ]
31
32 type RawRow = Record<string, unknown> | unknown[]
33 type XlsxApi = {
34 readFile: (filename: string) => { SheetNames: string[]; Sheets: Record<string, unknown> }
35 utils: {
36 sheet_to_json: (sheet: unknown, options: { header: 1; defval: string }) => unknown[][]
37 }
38 }
39 type PapaApi = {
40 parse: (
41 input: string,
42 options: Record<string, unknown>
43 ) => {
44 data: unknown[][]
45 errors?: Array<{ message?: string }>
46 }
47 }
48
49 function loadXlsx(): XlsxApi {
50 try {
51 return require('xlsx') as XlsxApi
52 } catch {
53 throw new Error('Excel 解析依赖 xlsx 尚未安装,请先安装项目依赖')
54 }
55 }
56
57 function loadPapa(): PapaApi {
58 try {
59 return require('papaparse') as PapaApi
60 } catch {
61 throw new Error('CSV 解析依赖 papaparse 尚未安装,请先安装项目依赖')
62 }
63 }
64
65 function toFiniteNumber(value: unknown): number | null {
66 if (typeof value === 'number') return Number.isFinite(value) ? value : null
67 const text = String(value ?? '').trim().replace(/,/g, '')
68 if (!text) return null
69 const parsed = Number(text)
70 return Number.isFinite(parsed) ? parsed : null
71 }
72
73 function normalizeJsonInput(value: unknown): RawRow[] {
74 if (Array.isArray(value)) return value as RawRow[]
75 if (value && typeof value === 'object') {
76 const record = value as Record<string, unknown>
77 const nested = record.data ?? record.rows ?? record.items
78 if (Array.isArray(nested)) return nested as RawRow[]
79 }
80 return []
81 }
82
83 function rowsFromTable(table: unknown[][]): RawRow[] {
84 if (table.length === 0) return []
85 const firstRow = table[0] || []
86 const firstCell = String(firstRow[0] ?? '').trim().toLowerCase()
87 const hasHeader =
88 TABLE_X_HEADER_KEYS.includes(firstCell) ||
89 firstRow.some((cell, index) => index > 0 && toFiniteNumber(cell) === null)
90 if (!hasHeader) return table
91 const headers = firstRow.map((cell, index) =>
92 String(cell || (index === 0 ? 'x' : `Series ${index}`)).trim()
93 )
94 return table.slice(1).map((row) =>
95 headers.reduce<Record<string, unknown>>((record, header, index) => {
96 record[header || (index === 0 ? 'x' : `Series ${index}`)] = row[index]
97 return record
98 }, {})
99 )
100 }
101
102 function normalizeChartRows(rows: RawRow[]): {
103 rows: Array<Record<string, string | number>>
104 seriesCount: number
105 labelCount: number
106 numericCellCount: number
107 } {
108 const labels: string[] = []
109 const rowValues: Array<Record<string, unknown>> = []
110
111 rows.slice(0, MAX_ROWS).forEach((item) => {
112 if (Array.isArray(item)) {
113 const label = String(item[0] ?? '').trim()
114 if (!label) return
115 labels.push(label)
116 rowValues.push(
117 item.slice(1, MAX_SERIES + 1).reduce<Record<string, unknown>>((record, cell, index) => {
118 record[index === 0 ? 'Value' : `Series ${index + 1}`] = cell
119 return record
120 }, {})
121 )
122 return
123 }
124
125 if (!item || typeof item !== 'object') return
126 const record = item as Record<string, unknown>
127 const keys = Object.keys(record)
128 const xKey =
129 X_KEYS.find((key) => key in record) ??
130 keys.find((key) => toFiniteNumber(record[key]) === null) ??
131 keys[0]
132 const label = String(record[xKey] ?? '').trim()
133 if (!label) return
134 labels.push(label)
135 rowValues.push(
136 keys.reduce<Record<string, unknown>>((row, key) => {
137 if (key !== xKey) row[key] = record[key]
138 return row
139 }, {})
140 )
141 })
142
143 const seriesKeys = Array.from(new Set(rowValues.flatMap((row) => Object.keys(row))))
144 .filter((key) => key.trim() && rowValues.some((row) => toFiniteNumber(row[key]) !== null))
145 .slice(0, MAX_SERIES)
146 if (seriesKeys.length === 0) {
147 return {
148 rows: [],
149 seriesCount: 0,
150 labelCount: labels.length,
151 numericCellCount: 0
152 }
153 }
154 let numericCellCount = 0
155 const normalizedRows = labels.map((label, rowIndex) => {
156 const source = rowValues[rowIndex] || {}
157 return seriesKeys.reduce<Record<string, string | number>>(
158 (record, key) => {
159 const value = toFiniteNumber(source[key])
160 if (value !== null) numericCellCount += 1
161 record[key] = value ?? 0
162 return record
163 },
164 { x: label }
165 )
166 })
167
168 return {
169 rows: normalizedRows,
170 seriesCount: seriesKeys.length,
171 labelCount: labels.length,
172 numericCellCount
173 }
174 }
175
176 async function parseChartDataFile(filePath: string): Promise<ParsedChartDataResult> {
177 const ext = path.extname(filePath).toLowerCase()
178 let rawRows: RawRow[] = []
179
180 if (ext === '.json') {
181 try {
182 rawRows = normalizeJsonInput(JSON.parse(await fs.promises.readFile(filePath, 'utf-8')))
183 } catch {
184 throw new Error('JSON 文件解析失败,请检查文件格式')
185 }
186 } else if (ext === '.csv' || ext === '.tsv' || ext === '.txt') {
187 const parsed = loadPapa().parse(await fs.promises.readFile(filePath, 'utf-8'), {
188 skipEmptyLines: 'greedy'
189 })
190 if (parsed.errors?.length) {
191 throw new Error(parsed.errors[0].message || 'CSV 文件解析失败')
192 }
193 rawRows = rowsFromTable(parsed.data)
194 } else if (ext === '.xlsx' || ext === '.xls') {
195 const xlsx = loadXlsx()
196 const workbook = xlsx.readFile(filePath)
197 const firstSheetName = workbook.SheetNames[0]
198 if (!firstSheetName) throw new Error('Excel 文件没有可读取的工作表')
199 rawRows = rowsFromTable(
200 xlsx.utils.sheet_to_json(workbook.Sheets[firstSheetName], { header: 1, defval: '' })
201 )
202 } else {
203 throw new Error('不支持的图表数据文件格式')
204 }
205
206 if (rawRows.length === 0) throw new Error('图表数据文件为空或格式不符合要求')
207 const normalized = normalizeChartRows(rawRows)
208 if (normalized.labelCount === 0) throw new Error('图表数据需要至少一列 X 轴标签')
209 if (normalized.seriesCount === 0 || normalized.numericCellCount === 0) {
210 throw new Error('图表数据需要至少一列可识别的数值列')
211 }
212 if (normalized.rows.length === 0) throw new Error('没有解析到可用的图表数据')
213 return {
214 canceled: false,
215 filePath,
216 dataJson: JSON.stringify(normalized.rows, null, 2),
217 rowCount: normalized.rows.length,
218 seriesCount: normalized.seriesCount
219 }
220 }
221
222 export function registerChartDataImportHandlers(ctx: IpcContext): void {
223 ipcMain.handle('chart-data:choose-and-parse', async (): Promise<ParsedChartDataResult> => {
224 const result = await dialog.showOpenDialog(ctx.mainWindow, {
225 title: '选择图表数据',
226 properties: ['openFile'],
227 filters: [
228 { name: 'Chart Data', extensions: ['csv', 'tsv', 'txt', 'json', 'xlsx', 'xls'] },
229 { name: 'All Files', extensions: ['*'] }
230 ]
231 })
232 if (result.canceled || result.filePaths.length === 0) return { canceled: true }
233 return parseChartDataFile(result.filePaths[0])
234 })
235 }
236
236 lines TYPESCRIPT