Skip to content

Commit 961aee2

Browse files
committed
fix: 导出CSV时特殊处理逗号
1 parent 330bbf2 commit 961aee2

File tree

4 files changed

+156
-2
lines changed

4 files changed

+156
-2
lines changed

packages/s2-core/__tests__/unit/utils/export/export-pivot-spec.ts

+70
Original file line numberDiff line numberDiff line change
@@ -686,4 +686,74 @@ describe('PivotSheet Export Test', () => {
686686
expect(result.split(TAB_SEPARATOR)).toHaveLength(81);
687687
},
688688
);
689+
690+
// https://github.com/antvis/S2/issues/2880
691+
it('should escape CSV Field', async () => {
692+
const data = clone<DataItem[]>(originData);
693+
694+
data.unshift({
695+
number: 7789,
696+
province: 'ac, abs, moon',
697+
city: 'Venture "Extended Edition"',
698+
type: 'Venture "Extended Edition, Very Large"',
699+
sub_type: 'MUST SELL!\nair, moon roof, loaded',
700+
});
701+
702+
const s2 = new PivotSheet(
703+
getContainer(),
704+
assembleDataCfg({
705+
data,
706+
fields: {
707+
valueInCols: true,
708+
columns: ['province', 'city'],
709+
rows: ['type', 'sub_type'],
710+
values: ['number'],
711+
},
712+
meta: [
713+
{
714+
field: 'number',
715+
name: '数,量',
716+
formatter: (value) => {
717+
return Number(value)
718+
.toFixed(3)
719+
.toString()
720+
.replace(/(\d)(?=(\d{3})+.)/g, '$1,');
721+
},
722+
},
723+
{
724+
field: 'province',
725+
name: '省份',
726+
formatter: (value) => `${value},`,
727+
},
728+
{
729+
field: 'city',
730+
name: '城市',
731+
formatter: (value) => `${value}\t`,
732+
},
733+
{
734+
field: 'type',
735+
name: '类别',
736+
formatter: (value) => `${value}\n`,
737+
},
738+
{
739+
field: 'sub_type',
740+
name: '子类别',
741+
formatter: (value) => `${value}"`,
742+
},
743+
],
744+
}),
745+
assembleOptions(),
746+
);
747+
748+
await s2.render();
749+
const result = await asyncGetAllPlainData({
750+
sheetInstance: s2,
751+
split: CSV_SEPARATOR,
752+
formatOptions: { formatHeader: true, formatData: true },
753+
});
754+
755+
expect(result).toContain(`,"ac, abs, moon,",`);
756+
expect(result).toContain(`,"7,789.000",`);
757+
expect(result).toContain(`,"Venture ""Extended Edition""",`);
758+
});
689759
});

packages/s2-core/__tests__/unit/utils/export/method-spec.ts

+48
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
convertString,
3+
escapeCSVField,
34
keyEqualTo,
45
trimTabSeparator,
56
} from '../../../../src/utils/export/method';
@@ -29,3 +30,50 @@ describe('method test', () => {
2930
expect(trimTabSeparator(1 as unknown as string)).toBe(1);
3031
});
3132
});
33+
34+
type SimpleData = string | number | null | undefined;
35+
describe('escapeCSVField', () => {
36+
it('should return the same value for non-string types', () => {
37+
const testData: SimpleData[] = [42, null, undefined];
38+
39+
testData.forEach((input) => {
40+
expect(escapeCSVField(input)).toBe(input);
41+
});
42+
});
43+
44+
it('should return the same string if no special characters are present', () => {
45+
const testStrings = ['hello', '123', 'test'];
46+
47+
testStrings.forEach((str) => {
48+
expect(escapeCSVField(str)).toBe(str);
49+
});
50+
});
51+
52+
it('should escape double quotes by replacing with two double quotes', () => {
53+
const input = 'hello "world"';
54+
const expected = '"hello ""world"""';
55+
56+
expect(escapeCSVField(input)).toBe(expected);
57+
});
58+
59+
it('should wrap strings containing commas in double quotes', () => {
60+
const input = 'hello,world';
61+
const expected = '"hello,world"';
62+
63+
expect(escapeCSVField(input)).toBe(expected);
64+
});
65+
66+
it('should wrap strings containing newlines in double quotes', () => {
67+
const input = 'hello\nworld';
68+
const expected = '"hello\nworld"';
69+
70+
expect(escapeCSVField(input)).toBe(expected);
71+
});
72+
73+
it('should wrap strings containing tabs in double quotes', () => {
74+
const input = 'hello\tworld';
75+
const expected = '"hello\tworld"';
76+
77+
expect(escapeCSVField(input)).toBe(expected);
78+
});
79+
});

packages/s2-core/src/utils/export/copy/base-data-cell-copy.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { map } from 'lodash';
12
import {
23
AsyncRenderThreshold,
4+
CSV_SEPARATOR,
35
TAB_SEPARATOR,
46
type DataItem,
57
type Formatter,
@@ -14,7 +16,11 @@ import type {
1416
import { CopyMIMEType } from '../../../common/interface/export';
1517
import { Node } from '../../../facet/layout/node';
1618
import type { SpreadSheet } from '../../../sheet-type';
17-
import { getHeaderList, getHeaderMeasureFieldNames } from '../method';
19+
import {
20+
escapeCSVField,
21+
getHeaderList,
22+
getHeaderMeasureFieldNames,
23+
} from '../method';
1824
import { unifyConfig } from './common';
1925

2026
export abstract class BaseDataCellCopy {
@@ -50,8 +56,14 @@ export abstract class BaseDataCellCopy {
5056
dataMatrix: SimpleData[][],
5157
separator: string,
5258
): CopyablePlain {
59+
let escapeDataMatrix: SimpleData[][] = dataMatrix;
60+
61+
if (separator === CSV_SEPARATOR) {
62+
escapeDataMatrix = map(dataMatrix, (row) => map(row, escapeCSVField));
63+
}
64+
5365
return this.config.transformers[CopyMIMEType.PLAIN](
54-
dataMatrix,
66+
escapeDataMatrix,
5567
separator,
5668
) as CopyablePlain;
5769
}

packages/s2-core/src/utils/export/method.ts

+24
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ export const trimTabSeparator = (text: string) => {
6666
return replace(text, new RegExp(TAB_SEPARATOR, 'g'), '');
6767
};
6868

69+
/**
70+
* https://en.wikipedia.org/wiki/Comma-separated_values#Example
71+
* 根据 CSV 规范,按以下规则处理字段内容:
72+
* 若字段包含 ,、"、\n 或 \t → 用双引号包裹字段。
73+
* 若字段中的双引号 → 转义为两个双引号 ""。
74+
* @param field
75+
*/
76+
export const escapeCSVField = (field: SimpleData): SimpleData => {
77+
if (typeof field !== 'string') {
78+
return field;
79+
}
80+
81+
// 检查是否需要转义:包含逗号、双引号或换行符
82+
if (/[",\n\t]/.test(field)) {
83+
// 转义双引号 -> 两个双引号
84+
field = field.replace(/"/g, '""');
85+
86+
// 用双引号包裹字段
87+
return `"${field}"`;
88+
}
89+
90+
return field;
91+
};
92+
6993
export const getHeaderMeasureFieldNames = (
7094
fields: string[],
7195
spreadsheet: SpreadSheet,

0 commit comments

Comments
 (0)