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);