From cb03cf54075bf69a002d575e4adecc0ed449179e Mon Sep 17 00:00:00 2001 From: xhaktm00 <153787023+xhaktm00@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:42:05 +0900 Subject: [PATCH] [ZEPPELIN-6699] Write a UTF-8 BOM in the React remote CSV export The React remote builds its CSV by hand and hands the string straight to a Blob, with no BOM, so Excel reads it in the system code page and garbles non-ASCII data. The classic path prepends one through saveAsService, and the Angular path gets one from XLSX.writeFile(), so only this export was missing it. ZEPPELIN-672 added the BOM for exactly this reason. The xlsx branch is left alone since that format carries its own encoding. --- .../src/utils/exportFile.spec.ts | 48 +++++++++++++++++++ .../zeppelin-react/src/utils/exportFile.ts | 5 +- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/utils/exportFile.spec.ts diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/utils/exportFile.spec.ts b/zeppelin-web-angular/projects/zeppelin-react/src/utils/exportFile.spec.ts new file mode 100644 index 00000000000..adc654cbfff --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/utils/exportFile.spec.ts @@ -0,0 +1,48 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { exportFile } from './exportFile'; + +const saveAs = vi.fn(); +vi.mock('file-saver', () => ({ saveAs: (...args: unknown[]) => saveAs(...args) })); + +const UTF8_BOM = [0xef, 0xbb, 0xbf]; + +/** The saved bytes, since Blob.text() decodes and drops a leading BOM. */ +async function savedBytes(): Promise { + expect(saveAs).toHaveBeenCalledOnce(); + const blob = saveAs.mock.calls[0][0] as Blob; + return new Uint8Array(await blob.arrayBuffer()); +} + +describe('exportFile', () => { + beforeEach(() => { + saveAs.mockClear(); + }); + + it('prepends a UTF-8 BOM to the CSV so Excel reads it as UTF-8', async () => { + await exportFile({ columnNames: ['name', 'city'], rows: [['alice', '서울']] }, 'csv'); + + const bytes = await savedBytes(); + expect([...bytes.subarray(0, 3)]).toEqual(UTF8_BOM); + expect(new TextDecoder().decode(bytes.subarray(3))).toBe('name,city\nalice,서울'); + expect(saveAs.mock.calls[0][1]).toBe('export.csv'); + }); + + it('does not export an empty table', async () => { + await exportFile({ columnNames: ['name'], rows: [] }, 'csv'); + + expect(saveAs).not.toHaveBeenCalled(); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/utils/exportFile.ts b/zeppelin-web-angular/projects/zeppelin-react/src/utils/exportFile.ts index c59525309f2..8b82c610758 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/utils/exportFile.ts +++ b/zeppelin-web-angular/projects/zeppelin-react/src/utils/exportFile.ts @@ -42,7 +42,10 @@ export const exportFile = async (tableData: TableData, type: 'csv' | 'xlsx') => const rows = tableData.rows.map(row => row.join(separator)); const content = [header, ...rows].join('\n'); - const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + // Excel reads a CSV without a BOM in the system code page, garbling non-ASCII data. The + // Angular and classic exports write one for the same reason (ZEPPELIN-672). + const BOM = '\uFEFF'; + const blob = new Blob([BOM, content], { type: 'text/plain;charset=utf-8' }); saveAs(blob, `export.${type}`); } };