diff --git a/packages/s2-core/__tests__/unit/utils/export/__snapshots__/copy-spec.ts.snap b/packages/s2-core/__tests__/unit/utils/export/__snapshots__/copy-spec.ts.snap index c2a8be8954..b6d99c19fb 100644 --- a/packages/s2-core/__tests__/unit/utils/export/__snapshots__/copy-spec.ts.snap +++ b/packages/s2-core/__tests__/unit/utils/export/__snapshots__/copy-spec.ts.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`List Table Core Data Process should copy all data 1`] = ` -"1 浙江省 杭州市 家具 ### 问题摘要 -- **会话地址**: 7789 +"1 浙江省 杭州市 家具 \\"### 问题摘要 +- **会话地址**:\\" 7789 2 浙江省 绍兴市 家具 桌子 2367 3 浙江省 宁波市 家具 桌子 3877 4 浙江省 舟山市 家具 桌子 4342 @@ -103,8 +103,8 @@ exports[`List Table Core Data Process should copy correctly data with header in exports[`List Table Core Data Process should copy row data 1`] = `"1 浙江省 舟山市 家具 桌子 4342"`; exports[`List Table Core Data Process should copy series number data 1`] = ` -"1 浙江省 杭州市 家具 ### 问题摘要 -- **会话地址**: 7789 +"1 浙江省 杭州市 家具 \\"### 问题摘要 +- **会话地址**:\\" 7789 2 浙江省 绍兴市 家具 桌子 2367 3 浙江省 宁波市 家具 桌子 3877 4 浙江省 舟山市 家具 桌子 4342 diff --git a/packages/s2-core/__tests__/unit/utils/export/copy-spec.ts b/packages/s2-core/__tests__/unit/utils/export/copy-spec.ts index 28b8b0310a..0af969b6a6 100644 --- a/packages/s2-core/__tests__/unit/utils/export/copy-spec.ts +++ b/packages/s2-core/__tests__/unit/utils/export/copy-spec.ts @@ -9,7 +9,6 @@ import { Aggregation } from '@/common/interface'; import { CopyMIMEType } from '@/common/interface/export'; import { PivotSheet, SpreadSheet, TableSheet } from '@/sheet-type'; import { getSelectedData } from '@/utils/export/copy'; -import { convertString } from '@/utils/export/method'; import { getCellMeta } from '@/utils/interaction/select-event'; import { map } from 'lodash'; import { data as originalData, totalData } from 'tests/data/mock-dataset.json'; @@ -424,8 +423,7 @@ describe('List Table Core Data Process', () => { }); it('should copy correct data with "\n" data', async () => { - const newLineText = `1 - 2`; + const newLineText = `1\n2`; const sheet = new TableSheet( getContainer(), assembleDataCfg({ @@ -459,12 +457,11 @@ describe('List Table Core Data Process', () => { }); const data = getCopyPlainContent(sheet); - expect(data).toBe(convertString(newLineText)); + expect(data).toBe(`"1\r\n2"`); }); it('should not transform double quotes to single quotes when newline char is in data', async () => { - const newLineText = `"1 - 2"`; + const newLineText = `"1\n2"`; const sheet = new TableSheet( getContainer(), assembleDataCfg({ @@ -496,7 +493,7 @@ describe('List Table Core Data Process', () => { }); const data = getCopyPlainContent(sheet); - expect(data).toBe(convertString(newLineText)); + expect(data).toBe(`"""1\r\n2"""`); }); it('should copy row data when select data row cell', async () => { @@ -1097,7 +1094,7 @@ describe('Pivot Table Core Data Process', () => { }); const data = getCopyPlainContent(sheet); - expect(data).toBe(convertString(`7789\n元`)); + expect(data).toBe(`"7789\r\n元"`); }); it('should get correct data with - string in header', async () => { @@ -1136,7 +1133,7 @@ describe('Pivot Table Core Data Process', () => { }); const data = getCopyPlainContent(s2New); - expect(data).toBe(convertString(`7789\n元`)); + expect(data).toBe(`"7789\r\n元"`); }); it('should get correct data with - string in header name', async () => { @@ -1170,7 +1167,7 @@ describe('Pivot Table Core Data Process', () => { }); const data = getCopyPlainContent(s2New); - expect(data).toBe(convertString(`7789\n元`)); + expect(data).toBe(`"7789\r\n元"`); }); it('should get correct data with hideMeasureColumn is true', async () => { 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..276dd60e5e 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 @@ -683,7 +683,85 @@ describe('PivotSheet Export Test', () => { formatOptions, }); - expect(result.split(TAB_SEPARATOR)).toHaveLength(81); + expect(result).toContain(`"测试-province\t"`); + expect(result).toContain(`"测试-city\t"`); + expect(result).toContain(`"测试-type\t"`); + expect(result).toContain(`"测试-sub_type\t"`); }, ); + + // 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) => { + const valueString = String(value); + + return `${valueString.slice(0, valueString.length / 2)}\n${valueString.slice(valueString.length / 2)}`; + }, + }, + { + 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""\t",`); + expect(result).toContain(`"Venture ""Extended E\r\ndition, Very Large""",`); + }); }); 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..b012b5981f 100644 --- a/packages/s2-core/__tests__/unit/utils/export/method-spec.ts +++ b/packages/s2-core/__tests__/unit/utils/export/method-spec.ts @@ -1,8 +1,4 @@ -import { - convertString, - keyEqualTo, - trimTabSeparator, -} from '../../../../src/utils/export/method'; +import { escapeField, keyEqualTo } from '../../../../src/utils/export/method'; describe('method test', () => { test('#keyEqualTo', () => { @@ -11,21 +7,44 @@ describe('method test', () => { expect(keyEqualTo('a', '')).toBeFalsy(); expect(keyEqualTo('A', 'a')).toBeTruthy(); }); +}); + +describe('escapeField', () => { + it.each([42, null, undefined, 'hello', '123', 'test'])( + 'should return the same value for non-string and normal types %s', + (input) => { + expect(escapeField(input)).toBe(input); + }, + ); + + it('should escape double quotes by replacing with two double quotes', () => { + const input = 'hello "world"'; + const expected = '"hello ""world"""'; + + expect(escapeField(input)).toBe(expected); + }); - test('#convertString', () => { - expect(convertString('a')).toBe('a'); - expect(convertString('a\nb')).toBe('"a\nb"'); - expect(convertString('a\nb"c')).toBe('"a\nb\'c"'); - expect(convertString(null)).toBe(null); + it('should wrap strings containing commas in double quotes', () => { + const input = 'hello,world'; + const expected = '"hello,world"'; + + expect(escapeField(input)).toBe(expected); }); - test('#trimTabSeparator', () => { - expect(trimTabSeparator('a\tb')).toBe('ab'); - expect(trimTabSeparator('a\tb\t')).toBe('ab'); - expect(trimTabSeparator('')).toBe(''); - expect(trimTabSeparator('a')).toBe('a'); - expect(trimTabSeparator('\ta')).toBe('a'); - expect(trimTabSeparator(null as unknown as string)).toBe(null); - expect(trimTabSeparator(1 as unknown as string)).toBe(1); + it('should replace \n to \r in double quotes', () => { + const input = 'hello\nworld'; + const inputRN = 'hello\r\nworld'; + const expected = '"hello\r\nworld"'; + const expectedRN = '"hello\r\nworld"'; + + expect(escapeField(input)).toBe(expected); + expect(escapeField(inputRN)).toBe(expectedRN); + }); + + it('should wrap strings containing tabs in double quotes', () => { + const input = 'hello\tworld'; + const expected = '"hello\tworld"'; + + expect(escapeField(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..342b913b4e 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,3 +1,4 @@ +import { map } from 'lodash'; import { AsyncRenderThreshold, TAB_SEPARATOR, @@ -14,7 +15,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 { + escapeField, + getHeaderList, + getHeaderMeasureFieldNames, +} from '../method'; import { unifyConfig } from './common'; export abstract class BaseDataCellCopy { @@ -50,8 +55,12 @@ export abstract class BaseDataCellCopy { dataMatrix: SimpleData[][], separator: string, ): CopyablePlain { + const escapeDataMatrix: SimpleData[][] = map(dataMatrix, (row) => + map(row, escapeField), + ); + return this.config.transformers[CopyMIMEType.PLAIN]( - dataMatrix, + escapeDataMatrix, separator, ) as CopyablePlain; } diff --git a/packages/s2-core/src/utils/export/copy/common.ts b/packages/s2-core/src/utils/export/copy/common.ts index 9d9f55ae8e..6047b30af3 100644 --- a/packages/s2-core/src/utils/export/copy/common.ts +++ b/packages/s2-core/src/utils/export/copy/common.ts @@ -14,7 +14,6 @@ import { } from '../../../common/interface/export'; import type { Node } from '../../../facet/layout/node'; import type { SpreadSheet } from '../../../sheet-type/spread-sheet'; -import { trimTabSeparator } from '../method'; // 把 string[][] 矩阵转换成 CopyablePlain export const matrixPlainTextTransformer = ( @@ -218,7 +217,7 @@ export const getNodeFormatData = (leafNode: Node) => { const formatter = node.spreadsheet?.dataSet?.getFieldFormatter?.( node.field, ); - const value = trimTabSeparator(formatter?.(node.value) as string); + const value = formatter?.(node.value) as string; line.unshift(value); diff --git a/packages/s2-core/src/utils/export/copy/pivot-data-cell-copy.ts b/packages/s2-core/src/utils/export/copy/pivot-data-cell-copy.ts index 19c82313f5..192a8a1447 100644 --- a/packages/s2-core/src/utils/export/copy/pivot-data-cell-copy.ts +++ b/packages/s2-core/src/utils/export/copy/pivot-data-cell-copy.ts @@ -29,7 +29,6 @@ import type { Node } from '../../../facet/layout/node'; import type { SpreadSheet } from '../../../sheet-type'; import { getHeaderTotalStatus } from '../../dataset/pivot-data-set'; import { - convertString, getColNodeFieldFromNode, getSelectedCols, getSelectedRows, @@ -369,7 +368,7 @@ export class PivotDataCellCopy extends BaseDataCellCopy { }, }); - return convertString(dataItem); + return dataItem; }), ) as SimpleData[][]; diff --git a/packages/s2-core/src/utils/export/copy/table-copy.ts b/packages/s2-core/src/utils/export/copy/table-copy.ts index dc346f9f11..a924034e47 100644 --- a/packages/s2-core/src/utils/export/copy/table-copy.ts +++ b/packages/s2-core/src/utils/export/copy/table-copy.ts @@ -12,7 +12,6 @@ import type { import type { Node } from '../../../facet/layout/node'; import type { SpreadSheet } from '../../../sheet-type'; import { - convertString, getColNodeFieldFromNode, getSelectedCols, getSelectedRows, @@ -191,7 +190,7 @@ class TableDataCellCopy extends BaseDataCellCopy { // 因为通过复制数据单元格的方式和通过行列头复制的方式不同,所以不能复用 getDataMatrix 方法 const dataMatrix = map(cellMetaMatrix, (cellsMeta) => - map(cellsMeta, (meta) => convertString(this.getValueFromMeta(meta))), + map(cellsMeta, (meta) => this.getValueFromMeta(meta)), ) as string[][]; if (!copy?.withHeader) { diff --git a/packages/s2-core/src/utils/export/method.ts b/packages/s2-core/src/utils/export/method.ts index ad618095c4..522d706ece 100644 --- a/packages/s2-core/src/utils/export/method.ts +++ b/packages/s2-core/src/utils/export/method.ts @@ -1,15 +1,13 @@ /** * 导出和复制的公共方法,这里的方法都比较纯,参数中都不包含 spreadsheet 对象 */ -import { flow, forEach, includes, map, replace } from 'lodash'; +import { flow, forEach, map } from 'lodash'; import type { ColCell, RowCell } from '../../cell'; import { CellType, NODE_ID_SEPARATOR, SERIES_NUMBER_FIELD, - TAB_SEPARATOR, type CellMeta, - type DataItem, type SimpleData, } from '../../common'; import type { Node } from '../../facet/layout/node'; @@ -25,15 +23,6 @@ export function keyEqualTo(key: string, compareKey: string) { return String(key).toLowerCase() === String(compareKey).toLowerCase(); } -export const convertString = (value: DataItem) => { - if (/\n/.test(value as string)) { - // 单元格内换行 替换双引号 防止内容存在双引号 导致内容换行出错 - return `"${(value as string).replace(/\r\n?/g, '\n').replace(/"/g, "'")}"`; - } - - return value; -}; - /** * 获取 intersection cell 所有的层级 * @param {(RowCell | ColCell)[]} interactedCells @@ -55,15 +44,29 @@ export function getAllLevels(interactedCells: (RowCell | ColCell)[]) { } /** - * 复制/导出时会在文本的两侧中增加制表符,用于在 Excel 中展示 - * 兼容极端情况,防止维值中本身就存在制表符的情况,如:“成都市\t” => "成都市\t\t" 导致错列 + * https://en.wikipedia.org/wiki/Comma-separated_values#Example + * 根据 CSV、Excel 规范,按以下规则处理字段内容: + * 若字段包含 ,、"、\r、\n 或 \t → 用双引号包裹字段。 + * 字段中的双引号 → 转义为两个双引号 ""。 + * 为了兼容直接粘贴纯文本到Excel单元格保持换行的场景,把\n替换成\r\n。但是\r\n不做替换 + * @param field */ -export const trimTabSeparator = (text: string) => { - if (!includes(text, TAB_SEPARATOR)) { - return text; +export const escapeField = (field: SimpleData): SimpleData => { + if (typeof field !== 'string') { + return field; + } + + // 检查是否需要转义:包含逗号、双引号或换行符 + if (/[",\r\n\t]/.test(field)) { + // 转义双引号 -> 两个双引号 + // 为了兼容直接粘贴纯文本到Excel单元格保持换行的场景,把\n替换成\r\n。但是\r\n不做替换 + const newField = field.replace(/"/g, '""').replace(/(? renderCell(x, y)); each(remove, (x, y) => getCell(x, y).remove()); ``` -通过按需渲染,极大的提高了 `S2` 的渲染效率,这也是为什么我们支持百万级别数据渲染的原因。 +通过按需渲染,极大的提高了 `S2` 的渲染效率,这也是我们支持百万级别数据渲染的原因。 ### 缓存设计 diff --git a/s2-site/docs/manual/extended-reading/performance.en.md b/s2-site/docs/manual/extended-reading/performance.en.md index 652ca4d7d1..a50473cad8 100644 --- a/s2-site/docs/manual/extended-reading/performance.en.md +++ b/s2-site/docs/manual/extended-reading/performance.en.md @@ -67,7 +67,7 @@ const rowsMeta: PivotMeta = { level: 1, childField:"city", children: { - 浙江市:{ + 杭州市:{ level: 1, children: {}, }, diff --git a/s2-site/docs/manual/extended-reading/performance.zh.md b/s2-site/docs/manual/extended-reading/performance.zh.md index 19614c6a27..833cbd5786 100644 --- a/s2-site/docs/manual/extended-reading/performance.zh.md +++ b/s2-site/docs/manual/extended-reading/performance.zh.md @@ -75,7 +75,7 @@ const rowsMeta: PivotMeta = { level: 1, childField:"city", children: { - 浙江市:{ + 杭州市:{ level: 1, children: {}, }, @@ -185,7 +185,7 @@ each(add, (x, y) => renderCell(x, y)); each(remove, (x, y) => getCell(x, y).remove()); ``` -通过按需渲染,极大的提高了 `S2` 的渲染效率,这也是为什么我们支持百万级别数据渲染的原因。 +通过按需渲染,极大的提高了 `S2` 的渲染效率,这也是我们支持百万级别数据渲染的原因。 ### 缓存设计