This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 4c697f07a fix(web): prefix CSV downloads with a UTF-8 BOM (#4743)
4c697f07a is described below

commit 4c697f07acde460e2344375cb1f82669f5b270fd
Author: Apulupie <[email protected]>
AuthorDate: Mon Sep 21 21:12:49 2026 +0800

    fix(web): prefix CSV downloads with a UTF-8 BOM (#4743)
    
    `downloadCsv` in `web/src/utils/download.ts` built its Blob from the raw 
string and declared the encoding only in the MIME type. Spreadsheets sniff 
bytes rather than trust that parameter, so an export with Chinese topic remarks 
or group names was decoded as ANSI/GBK and came out as mojibake in Excel. Every 
CSV download in the frontend goes through this one function, including the 
pages that hand it a CSV built server-side by `MetadataService`.
    
    The Blob is now prefixed with `\uFEFF`. No caller passes a string that 
already carries a BOM, and the two exporters that prefix one themselves, 
`AuditService` and `CloudCredentialService`, go through `downloadBlob` 
untouched, so nothing is doubled. The formula guard runs per cell inside 
`buildCsv`, so the prefix always lands outside the first quoted field, and 
`resourceCsvImport` already strips a leading BOM, keeping the export-import 
round trip byte-identical.
    
    BOM placement is now split: audit and credential exports are prefixed 
server-side, consumer-group and topic exports client-side, and those REST 
bodies stay BOM-less for non-browser callers. That inconsistency predates this 
change and is left alone, as is the `download.ts` comment that overstates which 
server exporters prefix one.
    
    Fixes #4744
---
 web/src/utils/download.test.ts | 36 ++++++++++++++++++++++++++++++++++--
 web/src/utils/download.ts      |  6 +++++-
 2 files changed, 39 insertions(+), 3 deletions(-)

diff --git a/web/src/utils/download.test.ts b/web/src/utils/download.test.ts
index 35d0ed6ca..1b3be406a 100644
--- a/web/src/utils/download.test.ts
+++ b/web/src/utils/download.test.ts
@@ -16,7 +16,7 @@
  */
 
 import { afterEach, describe, expect, it, vi } from 'vitest';
-import { buildCsv, downloadBlob } from './download';
+import { buildCsv, downloadBlob, downloadCsv } from './download';
 describe('buildCsv', () => {
   it('escapes quotes, empty values, and spreadsheet formulas', () => {
     const csv = buildCsv(
@@ -36,12 +36,44 @@ describe('buildCsv', () => {
         '"Name","Remark"',
         '"\'=SUM(A1:A2)","hello, ""mq"""',
         '"\'\nline-feed",""',
-        '"\'\'=literal","\'\'\'+two-apostrophes"',
+        "\"''=literal\",\"'''+two-apostrophes\"",
       ].join('\n'),
     );
   });
 });
 
+describe('downloadCsv', () => {
+  afterEach(() => {
+    document.body.innerHTML = '';
+    vi.restoreAllMocks();
+  });
+
+  it('prefixes a UTF-8 BOM so spreadsheet apps detect the encoding', async () 
=> {
+    const createObjectURL = vi.fn((_blob: Blob) => 'blob:csv');
+    Object.defineProperty(URL, 'createObjectURL', {
+      writable: true,
+      value: createObjectURL,
+    });
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      writable: true,
+      value: vi.fn(),
+    });
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => 
{});
+
+    const csv = '"Name","Remark"\n"topic-a","中文备注"';
+
+    downloadCsv('export.csv', csv);
+
+    const blob = createObjectURL.mock.calls[0][0];
+    const bytes = new Uint8Array(await blob.arrayBuffer());
+    // blob.text() strips a leading BOM (TextDecoder's default), so assert the 
raw bytes:
+    // EF BB BF is the UTF-8 encoding of U+FEFF. Without it Excel decodes the 
file as
+    // ANSI/GBK and garbles every non-ASCII cell.
+    expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
+    expect(await blob.text()).toBe(csv);
+  });
+});
+
 describe('downloadBlob', () => {
   afterEach(() => {
     vi.useRealTimers();
diff --git a/web/src/utils/download.ts b/web/src/utils/download.ts
index 51f502f96..83af33d54 100644
--- a/web/src/utils/download.ts
+++ b/web/src/utils/download.ts
@@ -51,5 +51,9 @@ export const buildCsv = <T>(columns: CsvColumn<T>[], rows: 
T[]) =>
   ].join('\n');
 
 export const downloadCsv = (filename: string, csv: string) => {
-  downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), filename);
+  // Spreadsheet apps sniff the encoding instead of honouring the charset 
parameter: without
+  // a BOM, Excel decodes the file as ANSI/GBK and garbles every non-ASCII 
cell. The
+  // server-side CSV exporters already prefix \uFEFF, and resourceCsvImport 
strips it again,
+  // so the in-app export → import round trip stays byte-identical.
+  downloadBlob(new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' }), 
filename);
 };

Reply via email to