Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: 导出复制时处理特殊字符 #3093

Merged
merged 10 commits into from
Feb 8, 2025
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 7 additions & 10 deletions packages/s2-core/__tests__/unit/utils/export/copy-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -459,12 +457,11 @@ describe('List Table Core Data Process', () => {
});
const data = getCopyPlainContent(sheet);

expect(data).toBe(convertString(newLineText));
expect(data).toBe(`"1\r2"`);
});

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({
Expand Down Expand Up @@ -496,7 +493,7 @@ describe('List Table Core Data Process', () => {
});
const data = getCopyPlainContent(sheet);

expect(data).toBe(convertString(newLineText));
expect(data).toBe(`"""1\r2"""`);
});

it('should copy row data when select data row cell', async () => {
Expand Down Expand Up @@ -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元"`);
});

it('should get correct data with - string in header', async () => {
Expand Down Expand Up @@ -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元"`);
});

it('should get correct data with - string in header name', async () => {
Expand Down Expand Up @@ -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元"`);
});

it('should get correct data with hideMeasureColumn is true', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DataItem[]>(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\rdition, Very Large""",`);
});
});
65 changes: 47 additions & 18 deletions packages/s2-core/__tests__/unit/utils/export/method-spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import {
convertString,
keyEqualTo,
trimTabSeparator,
} from '../../../../src/utils/export/method';
import { SimpleData } from '../../../../src';
import { escapeField, keyEqualTo } from '../../../../src/utils/export/method';

describe('method test', () => {
test('#keyEqualTo', () => {
Expand All @@ -11,21 +8,53 @@ describe('method test', () => {
expect(keyEqualTo('a', '')).toBeFalsy();
expect(keyEqualTo('A', 'a')).toBeTruthy();
});
});

describe('escapeField', () => {
it('should return the same value for non-string types', () => {
const testData: SimpleData[] = [42, null, undefined];

testData.forEach((input) => {
expect(escapeField(input)).toBe(input);
});
});

it('should return the same string if no special characters are present', () => {
const testStrings = ['hello', '123', 'test'];

testStrings.forEach((str) => {
expect(escapeField(str)).toBe(str);
});
});

it('should escape double quotes by replacing with two double quotes', () => {
const input = 'hello "world"';
const expected = '"hello ""world"""';

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);
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 wrap strings containing commas in double quotes', () => {
const input = 'hello,world';
const expected = '"hello,world"';

expect(escapeField(input)).toBe(expected);
});

it('should replace \n to \r in double quotes', () => {
const input = 'hello\nworld';
const inputRN = 'hello\r\nworld';
const expected = '"hello\rworld"';
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);
});
});
13 changes: 11 additions & 2 deletions packages/s2-core/src/utils/export/copy/base-data-cell-copy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { map } from 'lodash';
import {
AsyncRenderThreshold,
TAB_SEPARATOR,
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 1 addition & 2 deletions packages/s2-core/src/utils/export/copy/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -369,7 +368,7 @@ export class PivotDataCellCopy extends BaseDataCellCopy {
},
});

return convertString(dataItem);
return dataItem;
}),
) as SimpleData[][];

Expand Down
3 changes: 1 addition & 2 deletions packages/s2-core/src/utils/export/copy/table-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {
import type { Node } from '../../../facet/layout/node';
import type { SpreadSheet } from '../../../sheet-type';
import {
convertString,
getColNodeFieldFromNode,
getSelectedCols,
getSelectedRows,
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading