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

voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new efb465fe82 [ZEPPELIN-6634] Download service prepends a UTF-8 BOM to 
JSON files, breaking standard parsers
efb465fe82 is described below

commit efb465fe82ee31a6a9b0682ca2c423a423330622
Author: Lee SuJung <[email protected]>
AuthorDate: Fri Sep 4 21:50:34 2026 +0900

    [ZEPPELIN-6634] Download service prepends a UTF-8 BOM to JSON files, 
breaking standard parsers
    
    ### What is this PR for?
    Downloaded `.zpln` and `.ipynb` files start with a UTF-8 BOM, which the 
JSON specification does not allow, so strict parsers refuse them:
    
    ```
    Python json.load(utf-8)   -> FAIL: Unexpected UTF-8 BOM
    nbformat (Jupyter)        -> FAIL: Notebook does not appear to be JSON
    Zeppelin's own import     -> OK (Gson skips the BOM)
    ```
    
    Zeppelin reads its own files back because Gson tolerates the BOM, which is 
why this went unnoticed until a downloaded `.ipynb` was opened in Jupyter.
    
    The BOM was added in ZEPPELIN-672 so Excel reads a CSV export as UTF-8, but 
it lives in the shared download service, so the `zpln` and `ipynb` exports that 
later reused that service inherited it.
    
    This makes the caller decide: `saveAs()` takes a `bom` flag defaulting to 
`false`, and only the CSV/TSV call site passes `true`. Of the six call sites, 
that is the only one that needs it — the new UI has none at all, since its 
CSV/TSV export goes through the `xlsx` library.
    
    | Call site | Format | BOM |
    |---|---|---|
    | `result.controller.js` | CSV / TSV | yes |
    | `notebook.controller.js` (x2) | zpln | no |
    | `websocket-event.factory.js` | ipynb | no |
    | `action-bar.component.ts` (x2) | zpln (new UI) | no |
    
    Both UIs are fixed together, and the classic service drops the BOM in both 
its IE and standard branches.
    
    ### What type of PR is it?
    Bug Fix
    
    ### Todos
    * [x] Add a `bom` flag to both `saveAs()` services, defaulting to off
    * [x] Opt the CSV/TSV call site in, leaving the JSON exports without a BOM
    * [x] Add unit tests asserting the bytes on both paths
    
    ### What is the Jira issue?
    * [ZEPPELIN-6634](https://issues.apache.org/jira/browse/ZEPPELIN-6634)
    
    ### How should this be tested?
    New spec `save-as.service.spec.ts` checks the raw bytes handed to 
`createObjectURL`, since `Blob.text()` decodes and strips a leading BOM: a 
`zpln` export has no BOM and parses as JSON, and a CSV export with `bom: true` 
keeps `EF BB BF`.
    
    ```
    cd zeppelin-web-angular && npm run test:shell
    ```
    
    Result: `Tests 31 passed (31)`.
    
    Manual, on a built server: export a note as `.zpln` and download a table 
result as CSV.
    
    ```
    $ xxd note.zpln | head -1
    00000000: 7b22 7061 7261 6772 6170 6873 223a 5b7b  {"paragraphs":[{
    $ python3 -c "import json; json.load(open('note.zpln')); print('ok')"
    ok
    
    $ xxd export.csv | head -1
    00000000: efbb bf6e 616d 652c 6369 7479 0aea b980  ...name,city....
    ```
    
    The zpln no longer starts with a BOM and Python parses it, while the CSV 
still carries one. Korean, Chinese and emoji survived in both.
    
    ### Screenshots (if appropriate)
    N/A
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No — the flag defaults to 
off, and CSV/TSV keeps the BOM it had
    * Does this needs documentation? No
    
    
    Closes #5449 from xhaktm00/ZEPPELIN-6634.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 .../src/app/services/save-as.service.spec.ts       | 76 ++++++++++++++++++++++
 .../src/app/services/save-as.service.ts            | 11 +++-
 .../notebook/paragraph/result/result.controller.js |  3 +-
 .../src/app/notebook/save-as/save-as.service.js    | 15 ++++-
 4 files changed, 99 insertions(+), 6 deletions(-)

diff --git a/zeppelin-web-angular/src/app/services/save-as.service.spec.ts 
b/zeppelin-web-angular/src/app/services/save-as.service.spec.ts
new file mode 100644
index 0000000000..0f2c666ade
--- /dev/null
+++ b/zeppelin-web-angular/src/app/services/save-as.service.spec.ts
@@ -0,0 +1,76 @@
+/*
+ * 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SaveAsService } from './save-as.service';
+
+/**
+ * Returns the bytes the service handed to createObjectURL. Blob.text() 
decodes and strips a
+ * leading BOM, so the raw bytes are the only way to tell whether one was 
written.
+ */
+async function downloadedBytes(saveAs: () => void): Promise<Uint8Array> {
+  let saved: Blob | undefined;
+  // the service goes through window.URL, which is not the same object 
vi.stubGlobal replaces
+  vi.spyOn(window.URL, 'createObjectURL').mockImplementation((blob: Blob | 
MediaSource) => {
+    saved = blob as Blob;
+    return 'blob:url';
+  });
+  vi.spyOn(window.URL, 'revokeObjectURL').mockImplementation(() => undefined);
+
+  saveAs();
+
+  expect(saved).toBeDefined();
+  return new Uint8Array(await (saved as Blob).arrayBuffer());
+}
+
+const UTF8_BOM = [0xef, 0xbb, 0xbf];
+
+function hasBom(bytes: Uint8Array): boolean {
+  return UTF8_BOM.every((byte, index) => bytes[index] === byte);
+}
+
+describe('SaveAsService', () => {
+  let service: SaveAsService;
+
+  beforeEach(() => {
+    service = new SaveAsService();
+    // jsdom has no navigation, so the anchor click must not actually do 
anything
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => 
undefined);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('leaves a JSON export without a BOM', async () => {
+    const content = '{"paragraphs":[{"text":"한글 テスト 中文 🎉"}]}';
+
+    const bytes = await downloadedBytes(() => service.saveAs(content, 'note', 
'zpln'));
+
+    // a BOM here makes strict JSON parsers such as Python's json or nbformat 
reject the file
+    expect(hasBom(bytes)).toBe(false);
+    const text = new TextDecoder().decode(bytes);
+    expect(text).toBe(content);
+    expect(JSON.parse(text)).toEqual(JSON.parse(content));
+  });
+
+  it('prepends a BOM when the caller asks for one', async () => {
+    const content = 'name,value\n한글,1\n';
+
+    const bytes = await downloadedBytes(() => service.saveAs(content, 
'result', 'csv', true));
+
+    // Excel needs the BOM to read the CSV as UTF-8 (ZEPPELIN-672)
+    expect(hasBom(bytes)).toBe(true);
+    expect(new 
TextDecoder().decode(bytes.subarray(UTF8_BOM.length))).toBe(content);
+  });
+});
diff --git a/zeppelin-web-angular/src/app/services/save-as.service.ts 
b/zeppelin-web-angular/src/app/services/save-as.service.ts
index 53dc05c9bd..32f7c531eb 100644
--- a/zeppelin-web-angular/src/app/services/save-as.service.ts
+++ b/zeppelin-web-angular/src/app/services/save-as.service.ts
@@ -16,11 +16,18 @@ import { Injectable } from '@angular/core';
   providedIn: 'root'
 })
 export class SaveAsService {
-  saveAs(content: string, filename: string, extension: string) {
+  /**
+   * @param bom prepends a UTF-8 BOM so Excel reads a CSV/TSV export as UTF-8 
(ZEPPELIN-672).
+   *            JSON formats must leave it off: the JSON spec disallows a BOM 
and strict parsers
+   *            such as Python's json or nbformat refuse the file.
+   */
+  saveAs(content: string, filename: string, extension: string, bom = false) {
     const BOM = '\uFEFF';
     const fileName = `${filename}.${extension}`;
     const binaryData = [];
-    binaryData.push(BOM);
+    if (bom) {
+      binaryData.push(BOM);
+    }
     binaryData.push(content);
     const blob = new Blob(binaryData, { type: 'octet/stream' });
     const url = window.URL.createObjectURL(blob);
diff --git 
a/zeppelin-web/src/app/notebook/paragraph/result/result.controller.js 
b/zeppelin-web/src/app/notebook/paragraph/result/result.controller.js
index bd850d0ba3..5028c7a97d 100644
--- a/zeppelin-web/src/app/notebook/paragraph/result/result.controller.js
+++ b/zeppelin-web/src/app/notebook/paragraph/result/result.controller.js
@@ -911,7 +911,8 @@ function ResultCtrl($scope, $rootScope, $route, $window, 
$routeParams, $location
     } else if (delimiter === ',') {
       extension = 'csv';
     }
-    saveAsService.saveAs(dsv, exportedFileName, extension);
+    // CSV and TSV keep the BOM so Excel reads them as UTF-8 (ZEPPELIN-672)
+    saveAsService.saveAs(dsv, exportedFileName, extension, true);
   };
 
   $scope.copyToClipboard = function(delimiter) {
diff --git a/zeppelin-web/src/app/notebook/save-as/save-as.service.js 
b/zeppelin-web/src/app/notebook/save-as/save-as.service.js
index 9330d711d7..8076736da8 100644
--- a/zeppelin-web/src/app/notebook/save-as/save-as.service.js
+++ b/zeppelin-web/src/app/notebook/save-as/save-as.service.js
@@ -17,12 +17,19 @@ angular.module('zeppelinWebApp').service('saveAsService', 
SaveAsService);
 function SaveAsService(browserDetectService) {
   'ngInject';
 
-  this.saveAs = function(content, filename, extension) {
+  /**
+   * @param {boolean} [bom] prepends a UTF-8 BOM so Excel reads a CSV/TSV 
export as UTF-8
+   *   (ZEPPELIN-672). JSON formats must leave it off: the JSON spec disallows 
a BOM and strict
+   *   parsers such as Python's json or nbformat refuse the file.
+   */
+  this.saveAs = function(content, filename, extension, bom) {
     let BOM = '\uFEFF';
     if (browserDetectService.detectIE()) {
       angular.element('body').append('<iframe id="SaveAsId" style="display: 
none"></iframe>');
       let frameSaveAs = angular.element('body > 
iframe#SaveAsId')[0].contentWindow;
-      content = BOM + content;
+      if (bom) {
+        content = BOM + content;
+      }
       frameSaveAs.document.open('text/json', 'replace');
       frameSaveAs.document.write(content);
       frameSaveAs.document.close();
@@ -40,7 +47,9 @@ function SaveAsService(browserDetectService) {
     } else {
       const fileName = filename + '.' + extension;
       let binaryData = [];
-      binaryData.push(BOM);
+      if (bom) {
+        binaryData.push(BOM);
+      }
       binaryData.push(content);
       let blob = new Blob(binaryData, {type: 'octet/stream'});
       const url = window.URL.createObjectURL(blob);

Reply via email to