Skip to content

Commit 1629d25

Browse files
authored
fix(plugin-grid): sniff CSV encoding in import wizard (GB18030 fallback) (#2557)
The import wizard read delimited files with file.text(), which always decodes UTF-8. CSVs saved by zh-CN Excel ("CSV (comma delimited)") are GBK-encoded, so every non-ASCII header turned into mojibake, required columns could never be mapped, and the import could never succeed. Decode bytes instead: honor an explicit BOM (UTF-8 / UTF-16 LE / BE), then try strict UTF-8, then fall back to GB18030 via the native TextDecoder (no new dependency). Reported in steedos-labs/os-project-titanwind-ehr#185.
1 parent 471c5d3 commit 1629d25

2 files changed

Lines changed: 64 additions & 3 deletions

File tree

packages/plugin-grid/src/importParsers.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,38 @@ describe('parseSpreadsheetFile', () => {
109109
const grid = await parseSpreadsheetFile(makeFile('data.csv', 'a,b\n1,2'));
110110
expect(grid).toEqual([['a', 'b'], ['1', '2']]);
111111
});
112+
113+
// Encoding sniffing (#185): zh-CN Excel's "save as CSV" writes GBK, which a
114+
// plain UTF-8 read turns into unmappable mojibake headers.
115+
it('decodes a GBK/GB18030 .csv (zh-CN Excel default) via fallback', async () => {
116+
// '名称 *,编号 *\n测试岛2,TEST-001' encoded as GBK
117+
const gbk = new Uint8Array([
118+
0xc3, 0xfb, 0xb3, 0xc6, 0x20, 0x2a, 0x2c, 0xb1, 0xe0, 0xba, 0xc5, 0x20, 0x2a, 0x0a,
119+
0xb2, 0xe2, 0xca, 0xd4, 0xb5, 0xba, 0x32, 0x2c, 0x54, 0x45, 0x53, 0x54, 0x2d, 0x30, 0x30, 0x31,
120+
]);
121+
const grid = await parseSpreadsheetFile(makeFile('data.csv', gbk));
122+
expect(grid).toEqual([['名称 *', '编号 *'], ['测试岛2', 'TEST-001']]);
123+
});
124+
125+
it('keeps plain UTF-8 Chinese without a BOM intact', async () => {
126+
const grid = await parseSpreadsheetFile(makeFile('data.csv', '名称,城市\n张三,北京'));
127+
expect(grid).toEqual([['名称', '城市'], ['张三', '北京']]);
128+
});
129+
130+
it('strips a UTF-8 BOM (our own downloaded template round-trips)', async () => {
131+
const grid = await parseSpreadsheetFile(makeFile('data.csv', 'a,b\n1,2'));
132+
expect(grid).toEqual([['a', 'b'], ['1', '2']]);
133+
});
134+
135+
it('decodes UTF-16 LE with a BOM', async () => {
136+
// 'name,城市\n1,北京' encoded as UTF-16 LE with BOM
137+
const utf16 = new Uint8Array([
138+
0xff, 0xfe, 0x6e, 0x00, 0x61, 0x00, 0x6d, 0x00, 0x65, 0x00, 0x2c, 0x00,
139+
0xce, 0x57, 0x02, 0x5e, 0x0a, 0x00, 0x31, 0x00, 0x2c, 0x00, 0x17, 0x53, 0xac, 0x4e,
140+
]);
141+
const grid = await parseSpreadsheetFile(makeFile('data.csv', utf16));
142+
expect(grid).toEqual([['name', '城市'], ['1', '北京']]);
143+
});
112144
});
113145

114146
describe('suggestColumnMappings', () => {

packages/plugin-grid/src/importParsers.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,43 @@ export async function parseExcelArrayBuffer(buffer: ArrayBuffer): Promise<string
104104
}
105105

106106
/**
107-
* Parse any supported import file into `string[][]`. Delimited text is read as
108-
* UTF-8; .xlsx is parsed via {@link parseExcelArrayBuffer}. Throws an
107+
* Decode a delimited-text import file's bytes into a string. Real-world
108+
* spreadsheet CSVs are not reliably UTF-8 — zh-CN Excel's "另存为 CSV" writes
109+
* GBK, which `file.text()` (always UTF-8) turns into mojibake headers that can
110+
* never be mapped — so the encoding is sniffed instead of assumed:
111+
* 1. An explicit BOM (UTF-8 / UTF-16 LE / UTF-16 BE) wins.
112+
* 2. Otherwise try strict UTF-8; its validation rejects GBK multi-byte runs.
113+
* 3. Fall back to GB18030 (a superset of GBK/GB2312), the dominant
114+
* non-UTF-8 CSV encoding in practice.
115+
*/
116+
export function decodeSpreadsheetText(buffer: ArrayBuffer): string {
117+
const bytes = new Uint8Array(buffer);
118+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return new TextDecoder('utf-8').decode(bytes);
119+
if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes);
120+
if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(bytes);
121+
try {
122+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
123+
} catch {
124+
try {
125+
return new TextDecoder('gb18030').decode(bytes);
126+
} catch {
127+
// Runtimes without the gb18030 decoder (e.g. small-ICU Node) — degrade
128+
// to lossy UTF-8 rather than failing the whole import.
129+
return new TextDecoder('utf-8').decode(bytes);
130+
}
131+
}
132+
}
133+
134+
/**
135+
* Parse any supported import file into `string[][]`. Delimited text is
136+
* decoded via {@link decodeSpreadsheetText} (UTF-8 with GB18030 fallback);
137+
* .xlsx is parsed via {@link parseExcelArrayBuffer}. Throws an
109138
* {@link ImportParseError} code for unsupported / legacy formats.
110139
*/
111140
export async function parseSpreadsheetFile(file: File): Promise<string[][]> {
112141
const name = file.name.toLowerCase();
113142
if (name.endsWith('.csv') || name.endsWith('.tsv') || name.endsWith('.txt')) {
114-
const text = await file.text();
143+
const text = decodeSpreadsheetText(await file.arrayBuffer());
115144
return parseDelimited(text, name.endsWith('.tsv') ? '\t' : ',');
116145
}
117146
if (name.endsWith('.xlsx') || name.endsWith('.xlsm')) {

0 commit comments

Comments
 (0)