From 961aee25f626316862fa99c293e1c04abf98ff0b Mon Sep 17 00:00:00 2001 From: Alexzjt Date: Thu, 6 Feb 2025 16:19:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=BC=E5=87=BACSV=E6=97=B6=E7=89=B9?= =?UTF-8?q?=E6=AE=8A=E5=A4=84=E7=90=86=E9=80=97=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unit/utils/export/export-pivot-spec.ts | 70 +++++++++++++++++++ .../unit/utils/export/method-spec.ts | 48 +++++++++++++ .../utils/export/copy/base-data-cell-copy.ts | 16 ++++- packages/s2-core/src/utils/export/method.ts | 24 +++++++ 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/packages/s2-core/__tests__/unit/utils/export/export-pivot-spec.ts b/packages/s2-core/__tests__/unit/utils/export/export-pivot-spec.ts index 96a07a5280..75bb04a90f 100644 --- a/packages/s2-core/__tests__/unit/utils/export/export-pivot-spec.ts +++ b/packages/s2-core/__tests__/unit/utils/export/export-pivot-spec.ts @@ -686,4 +686,74 @@ describe('PivotSheet Export Test', () => { expect(result.split(TAB_SEPARATOR)).toHaveLength(81); }, ); + + // https://github.com/antvis/S2/issues/2880 + it('should escape CSV Field', async () => { + const data = clone(originData); + + data.unshift({ + number: 7789, + province: 'ac, abs, moon', + city: 'Venture "Extended Edition"', + type: 'Venture "Extended Edition, Very Large"', + sub_type: 'MUST SELL!\nair, moon roof, loaded', + }); + + const s2 = new PivotSheet( + getContainer(), + assembleDataCfg({ + data, + fields: { + valueInCols: true, + columns: ['province', 'city'], + rows: ['type', 'sub_type'], + values: ['number'], + }, + meta: [ + { + field: 'number', + name: '数,量', + formatter: (value) => { + return Number(value) + .toFixed(3) + .toString() + .replace(/(\d)(?=(\d{3})+.)/g, '$1,'); + }, + }, + { + field: 'province', + name: '省份', + formatter: (value) => `${value},`, + }, + { + field: 'city', + name: '城市', + formatter: (value) => `${value}\t`, + }, + { + field: 'type', + name: '类别', + formatter: (value) => `${value}\n`, + }, + { + field: 'sub_type', + name: '子类别', + formatter: (value) => `${value}"`, + }, + ], + }), + assembleOptions(), + ); + + await s2.render(); + const result = await asyncGetAllPlainData({ + sheetInstance: s2, + split: CSV_SEPARATOR, + formatOptions: { formatHeader: true, formatData: true }, + }); + + expect(result).toContain(`,"ac, abs, moon,",`); + expect(result).toContain(`,"7,789.000",`); + expect(result).toContain(`,"Venture ""Extended Edition""",`); + }); }); diff --git a/packages/s2-core/__tests__/unit/utils/export/method-spec.ts b/packages/s2-core/__tests__/unit/utils/export/method-spec.ts index edb3e757bd..e7db85b677 100644 --- a/packages/s2-core/__tests__/unit/utils/export/method-spec.ts +++ b/packages/s2-core/__tests__/unit/utils/export/method-spec.ts @@ -1,5 +1,6 @@ import { convertString, + escapeCSVField, keyEqualTo, trimTabSeparator, } from '../../../../src/utils/export/method'; @@ -29,3 +30,50 @@ describe('method test', () => { expect(trimTabSeparator(1 as unknown as string)).toBe(1); }); }); + +type SimpleData = string | number | null | undefined; +describe('escapeCSVField', () => { + it('should return the same value for non-string types', () => { + const testData: SimpleData[] = [42, null, undefined]; + + testData.forEach((input) => { + expect(escapeCSVField(input)).toBe(input); + }); + }); + + it('should return the same string if no special characters are present', () => { + const testStrings = ['hello', '123', 'test']; + + testStrings.forEach((str) => { + expect(escapeCSVField(str)).toBe(str); + }); + }); + + it('should escape double quotes by replacing with two double quotes', () => { + const input = 'hello "world"'; + const expected = '"hello ""world"""'; + + expect(escapeCSVField(input)).toBe(expected); + }); + + it('should wrap strings containing commas in double quotes', () => { + const input = 'hello,world'; + const expected = '"hello,world"'; + + expect(escapeCSVField(input)).toBe(expected); + }); + + it('should wrap strings containing newlines in double quotes', () => { + const input = 'hello\nworld'; + const expected = '"hello\nworld"'; + + expect(escapeCSVField(input)).toBe(expected); + }); + + it('should wrap strings containing tabs in double quotes', () => { + const input = 'hello\tworld'; + const expected = '"hello\tworld"'; + + expect(escapeCSVField(input)).toBe(expected); + }); +}); diff --git a/packages/s2-core/src/utils/export/copy/base-data-cell-copy.ts b/packages/s2-core/src/utils/export/copy/base-data-cell-copy.ts index 3f6d1de3d2..e401fb73af 100644 --- a/packages/s2-core/src/utils/export/copy/base-data-cell-copy.ts +++ b/packages/s2-core/src/utils/export/copy/base-data-cell-copy.ts @@ -1,5 +1,7 @@ +import { map } from 'lodash'; import { AsyncRenderThreshold, + CSV_SEPARATOR, TAB_SEPARATOR, type DataItem, type Formatter, @@ -14,7 +16,11 @@ import type { import { CopyMIMEType } from '../../../common/interface/export'; import { Node } from '../../../facet/layout/node'; import type { SpreadSheet } from '../../../sheet-type'; -import { getHeaderList, getHeaderMeasureFieldNames } from '../method'; +import { + escapeCSVField, + getHeaderList, + getHeaderMeasureFieldNames, +} from '../method'; import { unifyConfig } from './common'; export abstract class BaseDataCellCopy { @@ -50,8 +56,14 @@ export abstract class BaseDataCellCopy { dataMatrix: SimpleData[][], separator: string, ): CopyablePlain { + let escapeDataMatrix: SimpleData[][] = dataMatrix; + + if (separator === CSV_SEPARATOR) { + escapeDataMatrix = map(dataMatrix, (row) => map(row, escapeCSVField)); + } + return this.config.transformers[CopyMIMEType.PLAIN]( - dataMatrix, + escapeDataMatrix, separator, ) as CopyablePlain; } diff --git a/packages/s2-core/src/utils/export/method.ts b/packages/s2-core/src/utils/export/method.ts index ad618095c4..ea520f758c 100644 --- a/packages/s2-core/src/utils/export/method.ts +++ b/packages/s2-core/src/utils/export/method.ts @@ -66,6 +66,30 @@ export const trimTabSeparator = (text: string) => { return replace(text, new RegExp(TAB_SEPARATOR, 'g'), ''); }; +/** + * https://en.wikipedia.org/wiki/Comma-separated_values#Example + * 根据 CSV 规范,按以下规则处理字段内容: + * 若字段包含 ,、"、\n 或 \t → 用双引号包裹字段。 + * 若字段中的双引号 → 转义为两个双引号 ""。 + * @param field + */ +export const escapeCSVField = (field: SimpleData): SimpleData => { + if (typeof field !== 'string') { + return field; + } + + // 检查是否需要转义:包含逗号、双引号或换行符 + if (/[",\n\t]/.test(field)) { + // 转义双引号 -> 两个双引号 + field = field.replace(/"/g, '""'); + + // 用双引号包裹字段 + return `"${field}"`; + } + + return field; +}; + export const getHeaderMeasureFieldNames = ( fields: string[], spreadsheet: SpreadSheet,