Skip to content

Commit d6141ec

Browse files
authored
fix: replace Node.js Buffer APIs with browser-native alternatives (#2028)
* revert: remove nodePolyfills from UMD build Reverts the vite.config.umd.js changes from 13fa579. Buffer usage will be replaced with browser-native APIs instead of polyfilling. * fix: replace Node.js Buffer APIs with browser-native alternatives Buffer.from, Buffer.isBuffer, and buffer-crc32 crash in browser/CDN environments. Use Uint8Array, ArrayBuffer.isView, TextEncoder, and a custom CRC32 table instead. * fix(build): replace process define with process.env.NODE_ENV in UMD config The generic `process` define wasn't replacing `process.env.NODE_ENV` member accesses in the bundle, causing "process is not defined" at runtime. Targeting the specific path fixes it and enables dead-code elimination (~110KB smaller bundle). * fix: handle binary media values in collaborative export Media values from #exportProcessMediaFiles are ArrayBuffers (via getArrayBufferFromUrl), not base64 strings. Only call base64ToUint8Array for string values to avoid crashing atob with binary input. * refactor: remove dead externals config and consolidate base64 decode - Remove vite-plugin-node-polyfills from UMD external/globals (build-time plugin, never imported at runtime) - Reuse base64ToUint8Array from helpers.js in metafile-converter instead of duplicating the atob+charCodeAt loop * test: add CRC32 parity tests for computeCrc32Hex Verify output matches buffer-crc32 with known reference values to prevent silent breakage of document fingerprinting. * test: add base64ToUint8Array unit tests * test: pin hash values and add mixed media export test Pin generateContentHash and generateIdentifierHash to exact expected values instead of pattern matching. Add exportFromCollaborativeDocx test covering both base64 string and ArrayBuffer media values.
1 parent 13fa579 commit d6141ec

8 files changed

Lines changed: 141 additions & 44 deletions

File tree

packages/super-editor/src/core/DocxZipper.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as xmljs from 'xml-js';
22
import JSZip from 'jszip';
3-
import { getContentTypesFromXml } from './super-converter/helpers.js';
3+
import { getContentTypesFromXml, base64ToUint8Array } from './super-converter/helpers.js';
44
import { ensureXmlString, isXmlLike } from './encoding-helpers.js';
55

66
/**
@@ -303,7 +303,8 @@ class DocxZipper {
303303
});
304304

305305
Object.keys(media).forEach((path) => {
306-
const binaryData = Buffer.from(media[path], 'base64');
306+
const value = media[path];
307+
const binaryData = typeof value === 'string' ? base64ToUint8Array(value) : value;
307308
zip.file(path, binaryData);
308309
});
309310

packages/super-editor/src/core/DocxZipper.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,45 @@ describe('DocxZipper - updateContentTypes', () => {
257257
expect(updatedContentTypes).toContain('/word/footer1.xml');
258258
});
259259
});
260+
261+
describe('DocxZipper - exportFromCollaborativeDocx media handling', () => {
262+
it('handles both base64 string and ArrayBuffer media values', async () => {
263+
const zipper = new DocxZipper();
264+
265+
const contentTypes = `<?xml version="1.0" encoding="UTF-8"?>
266+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
267+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
268+
<Default Extension="xml" ContentType="application/xml"/>
269+
<Default Extension="png" ContentType="image/png"/>
270+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
271+
</Types>`;
272+
273+
const docx = [
274+
{ name: '[Content_Types].xml', content: contentTypes },
275+
{ name: 'word/document.xml', content: '<w:document/>' },
276+
];
277+
278+
// base64 for bytes [72, 101, 108, 108, 111] ("Hello")
279+
const base64Media = 'SGVsbG8=';
280+
// ArrayBuffer for bytes [87, 111, 114, 108, 100] ("World")
281+
const binaryMedia = new Uint8Array([87, 111, 114, 108, 100]).buffer;
282+
283+
const result = await zipper.updateZip({
284+
docx,
285+
updatedDocs: {},
286+
media: {
287+
'word/media/image1.png': base64Media,
288+
'word/media/image2.png': binaryMedia,
289+
},
290+
fonts: {},
291+
isHeadless: true,
292+
});
293+
294+
const readBack = await new JSZip().loadAsync(result);
295+
const img1 = await readBack.file('word/media/image1.png').async('uint8array');
296+
const img2 = await readBack.file('word/media/image2.png').async('uint8array');
297+
298+
expect(Array.from(img1)).toEqual([72, 101, 108, 108, 111]);
299+
expect(Array.from(img2)).toEqual([87, 111, 114, 108, 100]);
300+
});
301+
});

packages/super-editor/src/core/super-converter/SuperConverter.js

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
/* global TextEncoder */
12
import * as xmljs from 'xml-js';
23
import { v4 as uuidv4 } from 'uuid';
3-
import crc32 from 'buffer-crc32';
44
import { DocxExporter, exportSchemaToJson } from './exporter';
55
import { createDocumentJson, addDefaultStylesIfMissing } from './v2/importer/docxImporter.js';
6-
import { deobfuscateFont, getArrayBufferFromUrl } from './helpers.js';
6+
import { deobfuscateFont, getArrayBufferFromUrl, computeCrc32Hex } from './helpers.js';
77
import { baseNumbering } from './v2/exporter/helpers/base-list.definitions.js';
88
import { DEFAULT_CUSTOM_XML, DEFAULT_DOCX_DEFS } from './exporter-docx-defs.js';
99
import {
@@ -758,9 +758,8 @@ class SuperConverter {
758758
*/
759759
#generateIdentifierHash() {
760760
const combined = `${this.documentGuid}|${this.getDocumentCreatedTimestamp()}`;
761-
const buffer = Buffer.from(combined, 'utf8');
762-
const hash = crc32(buffer);
763-
return `HASH-${hash.toString('hex').toUpperCase()}`;
761+
const data = new TextEncoder().encode(combined);
762+
return `HASH-${computeCrc32Hex(data).toUpperCase()}`;
764763
}
765764

766765
/**
@@ -775,21 +774,21 @@ class SuperConverter {
775774
}
776775

777776
try {
778-
let buffer;
777+
let data;
779778

780-
if (Buffer.isBuffer(this.fileSource)) {
781-
buffer = this.fileSource;
779+
if (ArrayBuffer.isView(this.fileSource)) {
780+
const view = this.fileSource;
781+
data = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
782782
} else if (this.fileSource instanceof ArrayBuffer) {
783-
buffer = Buffer.from(this.fileSource);
783+
data = new Uint8Array(this.fileSource);
784784
} else if (this.fileSource instanceof Blob || this.fileSource instanceof File) {
785785
const arrayBuffer = await this.fileSource.arrayBuffer();
786-
buffer = Buffer.from(arrayBuffer);
786+
data = new Uint8Array(arrayBuffer);
787787
} else {
788788
return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`;
789789
}
790790

791-
const hash = crc32(buffer);
792-
return `HASH-${hash.toString('hex').toUpperCase()}`;
791+
return `HASH-${computeCrc32Hex(data).toUpperCase()}`;
793792
} catch (e) {
794793
console.warn('[super-converter] Could not generate content hash:', e);
795794
return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`;

packages/super-editor/src/core/super-converter/SuperConverter.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ describe('SuperConverter Document GUID', () => {
8282

8383
// getDocumentIdentifier assigns GUID and returns content hash (since no timestamp)
8484
const identifier = await converter.getDocumentIdentifier();
85-
expect(identifier).toMatch(/^HASH-/);
85+
expect(identifier).toBe('HASH-61D1432F');
8686

8787
// GUID is now assigned (for persistence on export)
8888
expect(converter.getDocumentGuid()).toBe('test-uuid-1234');
@@ -164,7 +164,7 @@ describe('SuperConverter Document GUID', () => {
164164
});
165165

166166
const identifier = await converter.getDocumentIdentifier();
167-
expect(identifier).toMatch(/^HASH-[A-F0-9]+$/);
167+
expect(identifier).toBe('HASH-A5FD6589');
168168
expect(converter.getDocumentGuid()).toBe('EXISTING-GUID-123');
169169
expect(converter.getDocumentCreatedTimestamp()).toBe('2024-01-15T10:30:00Z');
170170
expect(converter.documentModified).toBeFalsy();

packages/super-editor/src/core/super-converter/helpers.js

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,38 @@
11
import { parseSizeUnit } from '../utilities/index.js';
22
import { xml2js } from 'xml-js';
33

4+
// --- Browser-compatible CRC32 (replaces buffer-crc32 to avoid Node.js Buffer dependency) ---
5+
const CRC32_TABLE = new Uint32Array(256);
6+
for (let i = 0; i < 256; i++) {
7+
let c = i;
8+
for (let j = 0; j < 8; j++) {
9+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
10+
}
11+
CRC32_TABLE[i] = c;
12+
}
13+
14+
/**
15+
* Compute CRC32 of a Uint8Array and return as 8-char lowercase hex string.
16+
* Drop-in replacement for `buffer-crc32(buf).toString('hex')`.
17+
*/
18+
function computeCrc32Hex(data) {
19+
let crc = 0xffffffff;
20+
for (let i = 0; i < data.length; i++) {
21+
crc = CRC32_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
22+
}
23+
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
24+
}
25+
26+
/** Decode a base64 string to Uint8Array (works in both Node 16+ and browsers). */
27+
function base64ToUint8Array(base64) {
28+
const binary = atob(base64);
29+
const bytes = new Uint8Array(binary.length);
30+
for (let i = 0; i < binary.length; i++) {
31+
bytes[i] = binary.charCodeAt(i);
32+
}
33+
return bytes;
34+
}
35+
436
// CSS pixels per inch; used to convert between Word's inch-based measurements and DOM pixels.
537
const PIXELS_PER_INCH = 96;
638

@@ -276,21 +308,7 @@ const getArrayBufferFromUrl = async (input) => {
276308
// If this is a data URI we need only the payload portion
277309
const base64Payload = isDataUri ? trimmed.split(',', 2)[1] : trimmed.replace(/\s/g, '');
278310

279-
try {
280-
if (typeof globalThis.atob === 'function') {
281-
const binary = globalThis.atob(base64Payload);
282-
const bytes = new Uint8Array(binary.length);
283-
for (let i = 0; i < binary.length; i++) {
284-
bytes[i] = binary.charCodeAt(i);
285-
}
286-
return bytes.buffer;
287-
}
288-
} catch (err) {
289-
console.warn('atob failed, falling back to Buffer:', err);
290-
}
291-
292-
const buf = Buffer.from(base64Payload, 'base64');
293-
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
311+
return base64ToUint8Array(base64Payload).buffer;
294312
};
295313

296314
const getContentTypesFromXml = (contentTypesXml) => {
@@ -620,4 +638,6 @@ export {
620638
convertSizeToCSS,
621639
resolveShadingFillColor,
622640
resolveOpcTargetPath,
641+
computeCrc32Hex,
642+
base64ToUint8Array,
623643
};

packages/super-editor/src/core/super-converter/helpers.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
polygonUnitsToPixels,
66
pixelsToPolygonUnits,
77
getArrayBufferFromUrl,
8+
computeCrc32Hex,
9+
base64ToUint8Array,
810
} from './helpers.js';
911

1012
describe('polygonToObj', () => {
@@ -339,3 +341,45 @@ describe('getArrayBufferFromUrl', () => {
339341
expect(Array.from(new Uint8Array(result))).toEqual(Array.from(bytes));
340342
});
341343
});
344+
345+
describe('computeCrc32Hex', () => {
346+
it('matches buffer-crc32 output for known inputs', () => {
347+
// Reference values verified against buffer-crc32 npm package
348+
const cases = [
349+
{ input: 'hello world', expected: '0d4a1185' },
350+
{ input: '', expected: '00000000' },
351+
{ input: 'The quick brown fox jumps over the lazy dog', expected: '414fa339' },
352+
];
353+
354+
for (const { input, expected } of cases) {
355+
const data = new TextEncoder().encode(input);
356+
expect(computeCrc32Hex(data)).toBe(expected);
357+
}
358+
});
359+
360+
it('produces consistent output for binary data', () => {
361+
const data = new Uint8Array([0, 1, 2, 3, 255, 254, 253, 128, 127, 64, 32, 16]);
362+
// Reference: buffer-crc32(Buffer.from([0,1,2,3,255,254,253,128,127,64,32,16])).toString('hex')
363+
expect(computeCrc32Hex(data)).toBe('463601ac');
364+
});
365+
});
366+
367+
describe('base64ToUint8Array', () => {
368+
it('decodes a base64 string to Uint8Array', () => {
369+
// "hello" in base64
370+
const result = base64ToUint8Array('aGVsbG8=');
371+
expect(Array.from(result)).toEqual([104, 101, 108, 108, 111]);
372+
});
373+
374+
it('handles empty string', () => {
375+
const result = base64ToUint8Array('');
376+
expect(result).toBeInstanceOf(Uint8Array);
377+
expect(result.length).toBe(0);
378+
});
379+
380+
it('decodes binary data correctly', () => {
381+
// Bytes [0, 1, 255] → base64 "AAH/"
382+
const result = base64ToUint8Array('AAH/');
383+
expect(Array.from(result)).toEqual([0, 1, 255]);
384+
});
385+
});

packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
/* global btoa, XMLSerializer */
1616

1717
import { EMFJS, WMFJS } from './rtfjs';
18+
import { base64ToUint8Array } from '../../../../helpers.js';
1819

1920
// Disable verbose logging from the renderers
2021
EMFJS.loggingEnabled(false);
@@ -104,16 +105,7 @@ function base64ToArrayBuffer(data) {
104105
base64 = data.substring(commaIndex + 1);
105106
}
106107

107-
// Decode base64 to binary string
108-
const binaryString = atob(base64);
109-
110-
// Convert binary string to ArrayBuffer
111-
const bytes = new Uint8Array(binaryString.length);
112-
for (let i = 0; i < binaryString.length; i++) {
113-
bytes[i] = binaryString.charCodeAt(i);
114-
}
115-
116-
return bytes.buffer;
108+
return base64ToUint8Array(base64).buffer;
117109
}
118110

119111
/**

packages/superdoc/vite.config.umd.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import vue from '@vitejs/plugin-vue';
2-
import { nodePolyfills } from 'vite-plugin-node-polyfills';
32
import { defineConfig } from 'vite';
43
import { version } from './package.json';
54
import { getAliases } from './vite.config.js';
65

76
export default defineConfig(({ command }) => {
8-
const plugins = [vue(), nodePolyfills()];
7+
const plugins = [vue()];
98
const isDev = command === 'serve';
109

1110
return {
1211
define: {
1312
__APP_VERSION__: JSON.stringify(version),
14-
process: JSON.stringify({ env: { NODE_ENV: 'production' } }),
13+
'process.env.NODE_ENV': JSON.stringify('production'),
1514
},
1615
plugins,
1716
resolve: {

0 commit comments

Comments
 (0)