返回 oh-my-ppt
document-csv-to-markdown.ts
根目录 / src / main / io / document-csv-to-markdown.ts
1 export interface CsvMarkdownConversionOptions {
2 title: string
3 }
4
5 const normalizeNewlines = (value: string): string =>
6 value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
7
8 const cleanCell = (value: string): string =>
9 value.replace(/\s+/g, ' ').trim()
10
11 const escapeMarkdownCell = (value: string): string =>
12 cleanCell(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|') || ' '
13
14 const escapeHeadingText = (value: string): string =>
15 cleanCell(value).replace(/#+/g, '').trim() || 'Untitled'
16
17 const hasCjkText = (value: string): boolean => /[\u3400-\u9fff]/.test(value)
18
19 const usesChineseLabels = (title: string, headers: string[]): boolean =>
20 hasCjkText(`${title}\n${headers.join('\n')}`)
21
22 const fieldListLabel = (useChinese: boolean): string =>
23 useChinese ? '字段' : 'Fields'
24
25 const rowCountLabel = (useChinese: boolean): string =>
26 useChinese ? '记录数' : 'Rows'
27
28 const ungroupedValue = (useChinese: boolean): string =>
29 useChinese ? '未分组' : 'Ungrouped'
30
31 const dataSectionTitle = (headers: string[], useChinese: boolean): string => {
32 const shortHeaders = headers.filter((header) => header.length > 0 && header.length <= 18)
33 if (shortHeaders.length >= 2 && shortHeaders.length <= 4 && shortHeaders.length === headers.length) {
34 return shortHeaders.join(useChinese ? '、' : ' / ')
35 }
36 return useChinese ? '数据明细' : 'Data Details'
37 }
38
39 const groupedSectionTitle = (groupHeader: string, useChinese: boolean): string =>
40 useChinese ? `按${escapeHeadingText(groupHeader)}拆分` : `By ${escapeHeadingText(groupHeader)}`
41
42 const parseCsvRows = (source: string): string[][] => {
43 const rows: string[][] = []
44 let row: string[] = []
45 let cell = ''
46 let inQuotes = false
47 const text = normalizeNewlines(source)
48
49 for (let index = 0; index < text.length; index += 1) {
50 const char = text[index]
51 const nextChar = text[index + 1]
52 if (char === '"') {
53 if (inQuotes && nextChar === '"') {
54 cell += '"'
55 index += 1
56 } else {
57 inQuotes = !inQuotes
58 }
59 } else if (char === ',' && !inQuotes) {
60 row.push(cell)
61 cell = ''
62 } else if (char === '\n' && !inQuotes) {
63 row.push(cell)
64 rows.push(row)
65 row = []
66 cell = ''
67 } else {
68 cell += char
69 }
70 }
71
72 row.push(cell)
73 rows.push(row)
74
75 return rows
76 .map((cells) => cells.map(cleanCell))
77 .filter((cells) => cells.some((value) => value.length > 0))
78 }
79
80 const normalizeRows = (rows: string[][]): { headers: string[]; dataRows: string[][] } => {
81 const [rawHeaders = [], ...rawDataRows] = rows
82 const columnCount = Math.max(rawHeaders.length, ...rawDataRows.map((row) => row.length), 1)
83 const headers = Array.from({ length: columnCount }, (_, index) => {
84 const header = cleanCell(rawHeaders[index] || '')
85 return header || `Column ${index + 1}`
86 })
87 const dataRows = rawDataRows.map((row) =>
88 Array.from({ length: columnCount }, (_, index) => cleanCell(row[index] || ''))
89 )
90 return { headers, dataRows }
91 }
92
93 const NUMERIC_VALUE_PATTERN = /^[-+]?[$¥€]?\s*\d+(?:,\d{3})*(?:\.\d+)?%?$/
94 const DATE_LIKE_VALUE_PATTERN = /^\d{4}[-/年]\d{1,2}(?:[-/月]\d{1,2}日?)?$|^\d{1,2}[-/]\d{1,2}[-/]\d{2,4}$/
95
96 const mostlyMatches = (values: string[], pattern: RegExp): boolean => {
97 if (values.length === 0) return false
98 return values.filter((value) => pattern.test(value)).length / values.length >= 0.75
99 }
100
101 const selectGroupColumnIndex = (headers: string[], dataRows: string[][]): number | null => {
102 if (dataRows.length < 4) return null
103 let bestCandidate: { index: number; score: number } | null = null
104
105 for (const [index] of headers.entries()) {
106 const values = dataRows.map((row) => row[index]).filter(Boolean)
107 const uniqueValues = new Set(values)
108 if (uniqueValues.size < 2) continue
109 if (uniqueValues.size > 50) continue
110 if (uniqueValues.size >= values.length * 0.75) continue
111 if (mostlyMatches(values, NUMERIC_VALUE_PATTERN)) continue
112 if (mostlyMatches(values, DATE_LIKE_VALUE_PATTERN)) continue
113
114 const repeatRatio = 1 - uniqueValues.size / values.length
115 const coverageRatio = values.length / dataRows.length
116 const earlyColumnBonus = Math.max(0, (headers.length - index) / headers.length) * 0.05
117 const score = repeatRatio + coverageRatio * 0.25 + earlyColumnBonus
118 if (!bestCandidate || score > bestCandidate.score) {
119 bestCandidate = { index, score }
120 }
121 }
122
123 return bestCandidate?.index ?? null
124 }
125
126 const formatMarkdownTable = (headers: string[], rows: string[][]): string => {
127 const headerLine = `| ${headers.map(escapeMarkdownCell).join(' | ')} |`
128 const dividerLine = `| ${headers.map(() => '---').join(' | ')} |`
129 const rowLines = rows.map((row) => `| ${row.map(escapeMarkdownCell).join(' | ')} |`)
130 return [headerLine, dividerLine, ...rowLines].join('\n')
131 }
132
133 export const convertCsvTextToMarkdown = (
134 source: string,
135 options: CsvMarkdownConversionOptions
136 ): string => {
137 const rows = parseCsvRows(source)
138 const { headers, dataRows } = normalizeRows(rows)
139 const useChinese = usesChineseLabels(options.title, headers)
140 if (dataRows.length === 0) {
141 return [
142 `# ${escapeHeadingText(options.title)}`,
143 '',
144 `## ${dataSectionTitle(headers, useChinese)}`,
145 '',
146 formatMarkdownTable(headers, [])
147 ].join('\n')
148 }
149
150 const groupColumnIndex = selectGroupColumnIndex(headers, dataRows)
151 const lines = [
152 `# ${escapeHeadingText(options.title)}`,
153 '',
154 `- ${fieldListLabel(useChinese)}:${headers.join(useChinese ? '、' : ', ')}`,
155 `- ${rowCountLabel(useChinese)}:${dataRows.length}`
156 ]
157
158 if (groupColumnIndex === null) {
159 lines.push('', `## ${dataSectionTitle(headers, useChinese)}`, '', formatMarkdownTable(headers, dataRows))
160 return lines.join('\n')
161 }
162
163 const groupHeader = headers[groupColumnIndex]
164 const groupedRows = new Map<string, string[][]>()
165 dataRows.forEach((row) => {
166 const groupValue = row[groupColumnIndex] || ungroupedValue(useChinese)
167 const currentRows = groupedRows.get(groupValue) || []
168 currentRows.push(row)
169 groupedRows.set(groupValue, currentRows)
170 })
171
172 lines.push('', `## ${groupedSectionTitle(groupHeader, useChinese)}`)
173 groupedRows.forEach((groupRows, groupValue) => {
174 lines.push('', `### ${escapeHeadingText(groupValue)}`, '')
175 lines.push(formatMarkdownTable(headers, groupRows))
176 })
177
178 return lines.join('\n')
179 }
180
180 lines TYPESCRIPT