From 089037e7b3c071dea7b764565c3023d88218f92b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 14 Feb 2026 09:43:48 -0300 Subject: [PATCH 1/8] revert: remove nodePolyfills from UMD build Reverts the vite.config.umd.js changes from 13fa579c5. Buffer usage will be replaced with browser-native APIs instead of polyfilling. --- packages/superdoc/vite.config.umd.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/superdoc/vite.config.umd.js b/packages/superdoc/vite.config.umd.js index b906fa98ee..1c1e8570bb 100644 --- a/packages/superdoc/vite.config.umd.js +++ b/packages/superdoc/vite.config.umd.js @@ -1,11 +1,10 @@ import vue from '@vitejs/plugin-vue'; -import { nodePolyfills } from 'vite-plugin-node-polyfills'; import { defineConfig } from 'vite'; import { version } from './package.json'; import { getAliases } from './vite.config.js'; export default defineConfig(({ command }) => { - const plugins = [vue(), nodePolyfills()]; + const plugins = [vue()]; const isDev = command === 'serve'; return { @@ -36,6 +35,7 @@ export default defineConfig(({ command }) => { external: [ 'yjs', '@hocuspocus/provider', + 'vite-plugin-node-polyfills', 'pdfjs-dist', 'pdfjs-dist/build/pdf.mjs', 'pdfjs-dist/legacy/build/pdf.mjs', @@ -46,6 +46,7 @@ export default defineConfig(({ command }) => { yjs: 'Yjs', '@hocuspocus/provider': 'HocuspocusProvider', 'pdfjs-dist': 'PDFJS', + 'vite-plugin-node-polyfills': 'NodePolyfills', }, }, }, From fb320d65e966d978d1563b7fb0bedb2e80c0475a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 14 Feb 2026 09:51:21 -0300 Subject: [PATCH 2/8] 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. --- packages/super-editor/src/core/DocxZipper.js | 4 +- .../core/super-converter/SuperConverter.js | 23 ++++----- .../src/core/super-converter/helpers.js | 50 +++++++++++++------ 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 42bd3b4f22..6c9099152a 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -1,6 +1,6 @@ import * as xmljs from 'xml-js'; import JSZip from 'jszip'; -import { getContentTypesFromXml } from './super-converter/helpers.js'; +import { getContentTypesFromXml, base64ToUint8Array } from './super-converter/helpers.js'; import { ensureXmlString, isXmlLike } from './encoding-helpers.js'; /** @@ -303,7 +303,7 @@ class DocxZipper { }); Object.keys(media).forEach((path) => { - const binaryData = Buffer.from(media[path], 'base64'); + const binaryData = base64ToUint8Array(media[path]); zip.file(path, binaryData); }); diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 57b4f6b4db..969616a1ac 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -1,9 +1,9 @@ +/* global TextEncoder */ import * as xmljs from 'xml-js'; import { v4 as uuidv4 } from 'uuid'; -import crc32 from 'buffer-crc32'; import { DocxExporter, exportSchemaToJson } from './exporter'; import { createDocumentJson, addDefaultStylesIfMissing } from './v2/importer/docxImporter.js'; -import { deobfuscateFont, getArrayBufferFromUrl } from './helpers.js'; +import { deobfuscateFont, getArrayBufferFromUrl, computeCrc32Hex } from './helpers.js'; import { baseNumbering } from './v2/exporter/helpers/base-list.definitions.js'; import { DEFAULT_CUSTOM_XML, DEFAULT_DOCX_DEFS } from './exporter-docx-defs.js'; import { @@ -758,9 +758,8 @@ class SuperConverter { */ #generateIdentifierHash() { const combined = `${this.documentGuid}|${this.getDocumentCreatedTimestamp()}`; - const buffer = Buffer.from(combined, 'utf8'); - const hash = crc32(buffer); - return `HASH-${hash.toString('hex').toUpperCase()}`; + const data = new TextEncoder().encode(combined); + return `HASH-${computeCrc32Hex(data).toUpperCase()}`; } /** @@ -775,21 +774,21 @@ class SuperConverter { } try { - let buffer; + let data; - if (Buffer.isBuffer(this.fileSource)) { - buffer = this.fileSource; + if (ArrayBuffer.isView(this.fileSource)) { + const view = this.fileSource; + data = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); } else if (this.fileSource instanceof ArrayBuffer) { - buffer = Buffer.from(this.fileSource); + data = new Uint8Array(this.fileSource); } else if (this.fileSource instanceof Blob || this.fileSource instanceof File) { const arrayBuffer = await this.fileSource.arrayBuffer(); - buffer = Buffer.from(arrayBuffer); + data = new Uint8Array(arrayBuffer); } else { return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`; } - const hash = crc32(buffer); - return `HASH-${hash.toString('hex').toUpperCase()}`; + return `HASH-${computeCrc32Hex(data).toUpperCase()}`; } catch (e) { console.warn('[super-converter] Could not generate content hash:', e); return `HASH-${uuidv4().replace(/-/g, '').substring(0, 8).toUpperCase()}`; diff --git a/packages/super-editor/src/core/super-converter/helpers.js b/packages/super-editor/src/core/super-converter/helpers.js index 8443580b5e..46a29e6f12 100644 --- a/packages/super-editor/src/core/super-converter/helpers.js +++ b/packages/super-editor/src/core/super-converter/helpers.js @@ -1,6 +1,38 @@ import { parseSizeUnit } from '../utilities/index.js'; import { xml2js } from 'xml-js'; +// --- Browser-compatible CRC32 (replaces buffer-crc32 to avoid Node.js Buffer dependency) --- +const CRC32_TABLE = new Uint32Array(256); +for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + CRC32_TABLE[i] = c; +} + +/** + * Compute CRC32 of a Uint8Array and return as 8-char lowercase hex string. + * Drop-in replacement for `buffer-crc32(buf).toString('hex')`. + */ +function computeCrc32Hex(data) { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) { + crc = CRC32_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); + } + return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0'); +} + +/** Decode a base64 string to Uint8Array (works in both Node 16+ and browsers). */ +function base64ToUint8Array(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + // CSS pixels per inch; used to convert between Word's inch-based measurements and DOM pixels. const PIXELS_PER_INCH = 96; @@ -276,21 +308,7 @@ const getArrayBufferFromUrl = async (input) => { // If this is a data URI we need only the payload portion const base64Payload = isDataUri ? trimmed.split(',', 2)[1] : trimmed.replace(/\s/g, ''); - try { - if (typeof globalThis.atob === 'function') { - const binary = globalThis.atob(base64Payload); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; - } - } catch (err) { - console.warn('atob failed, falling back to Buffer:', err); - } - - const buf = Buffer.from(base64Payload, 'base64'); - return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + return base64ToUint8Array(base64Payload).buffer; }; const getContentTypesFromXml = (contentTypesXml) => { @@ -620,4 +638,6 @@ export { convertSizeToCSS, resolveShadingFillColor, resolveOpcTargetPath, + computeCrc32Hex, + base64ToUint8Array, }; From 2d51497022f1477abd7fc21d39393ecdbbe5405b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 14 Feb 2026 10:06:07 -0300 Subject: [PATCH 3/8] 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). --- packages/superdoc/vite.config.umd.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdoc/vite.config.umd.js b/packages/superdoc/vite.config.umd.js index 1c1e8570bb..256e8edf88 100644 --- a/packages/superdoc/vite.config.umd.js +++ b/packages/superdoc/vite.config.umd.js @@ -10,7 +10,7 @@ export default defineConfig(({ command }) => { return { define: { __APP_VERSION__: JSON.stringify(version), - process: JSON.stringify({ env: { NODE_ENV: 'production' } }), + 'process.env.NODE_ENV': JSON.stringify('production'), }, plugins, resolve: { From 6f1059838b654d5098d2d45283925b8a166c47d9 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 14 Feb 2026 10:08:04 -0300 Subject: [PATCH 4/8] 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. --- packages/super-editor/src/core/DocxZipper.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 6c9099152a..3023ede6ac 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -303,7 +303,8 @@ class DocxZipper { }); Object.keys(media).forEach((path) => { - const binaryData = base64ToUint8Array(media[path]); + const value = media[path]; + const binaryData = typeof value === 'string' ? base64ToUint8Array(value) : value; zip.file(path, binaryData); }); From e9a75aaa760334dffa7dcc8c77b62a269610ae58 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 14 Feb 2026 10:45:19 -0300 Subject: [PATCH 5/8] 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 --- .../v3/handlers/wp/helpers/metafile-converter.js | 12 ++---------- packages/superdoc/vite.config.umd.js | 2 -- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js index 561ec044fa..4b25b7a727 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/wp/helpers/metafile-converter.js @@ -15,6 +15,7 @@ /* global btoa, XMLSerializer */ import { EMFJS, WMFJS } from './rtfjs'; +import { base64ToUint8Array } from '../../../../helpers.js'; // Disable verbose logging from the renderers EMFJS.loggingEnabled(false); @@ -104,16 +105,7 @@ function base64ToArrayBuffer(data) { base64 = data.substring(commaIndex + 1); } - // Decode base64 to binary string - const binaryString = atob(base64); - - // Convert binary string to ArrayBuffer - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - return bytes.buffer; + return base64ToUint8Array(base64).buffer; } /** diff --git a/packages/superdoc/vite.config.umd.js b/packages/superdoc/vite.config.umd.js index 256e8edf88..0f97ee630f 100644 --- a/packages/superdoc/vite.config.umd.js +++ b/packages/superdoc/vite.config.umd.js @@ -35,7 +35,6 @@ export default defineConfig(({ command }) => { external: [ 'yjs', '@hocuspocus/provider', - 'vite-plugin-node-polyfills', 'pdfjs-dist', 'pdfjs-dist/build/pdf.mjs', 'pdfjs-dist/legacy/build/pdf.mjs', @@ -46,7 +45,6 @@ export default defineConfig(({ command }) => { yjs: 'Yjs', '@hocuspocus/provider': 'HocuspocusProvider', 'pdfjs-dist': 'PDFJS', - 'vite-plugin-node-polyfills': 'NodePolyfills', }, }, }, From d373e19e12a34d4a71b538c68eeb7098e02f61ee Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 14 Feb 2026 10:47:21 -0300 Subject: [PATCH 6/8] test: add CRC32 parity tests for computeCrc32Hex Verify output matches buffer-crc32 with known reference values to prevent silent breakage of document fingerprinting. --- .../src/core/super-converter/helpers.test.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/super-editor/src/core/super-converter/helpers.test.js b/packages/super-editor/src/core/super-converter/helpers.test.js index ecd67f890c..c9e8e7e320 100644 --- a/packages/super-editor/src/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/core/super-converter/helpers.test.js @@ -5,6 +5,7 @@ import { polygonUnitsToPixels, pixelsToPolygonUnits, getArrayBufferFromUrl, + computeCrc32Hex, } from './helpers.js'; describe('polygonToObj', () => { @@ -339,3 +340,25 @@ describe('getArrayBufferFromUrl', () => { expect(Array.from(new Uint8Array(result))).toEqual(Array.from(bytes)); }); }); + +describe('computeCrc32Hex', () => { + it('matches buffer-crc32 output for known inputs', () => { + // Reference values verified against buffer-crc32 npm package + const cases = [ + { input: 'hello world', expected: '0d4a1185' }, + { input: '', expected: '00000000' }, + { input: 'The quick brown fox jumps over the lazy dog', expected: '414fa339' }, + ]; + + for (const { input, expected } of cases) { + const data = new TextEncoder().encode(input); + expect(computeCrc32Hex(data)).toBe(expected); + } + }); + + it('produces consistent output for binary data', () => { + const data = new Uint8Array([0, 1, 2, 3, 255, 254, 253, 128, 127, 64, 32, 16]); + // Reference: buffer-crc32(Buffer.from([0,1,2,3,255,254,253,128,127,64,32,16])).toString('hex') + expect(computeCrc32Hex(data)).toBe('463601ac'); + }); +}); From bdb290e04c21f860226a89511e272f70c9d3df6f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 14 Feb 2026 10:50:12 -0300 Subject: [PATCH 7/8] test: add base64ToUint8Array unit tests --- .../src/core/super-converter/helpers.test.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/super-editor/src/core/super-converter/helpers.test.js b/packages/super-editor/src/core/super-converter/helpers.test.js index c9e8e7e320..c1b6de9085 100644 --- a/packages/super-editor/src/core/super-converter/helpers.test.js +++ b/packages/super-editor/src/core/super-converter/helpers.test.js @@ -6,6 +6,7 @@ import { pixelsToPolygonUnits, getArrayBufferFromUrl, computeCrc32Hex, + base64ToUint8Array, } from './helpers.js'; describe('polygonToObj', () => { @@ -362,3 +363,23 @@ describe('computeCrc32Hex', () => { expect(computeCrc32Hex(data)).toBe('463601ac'); }); }); + +describe('base64ToUint8Array', () => { + it('decodes a base64 string to Uint8Array', () => { + // "hello" in base64 + const result = base64ToUint8Array('aGVsbG8='); + expect(Array.from(result)).toEqual([104, 101, 108, 108, 111]); + }); + + it('handles empty string', () => { + const result = base64ToUint8Array(''); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(0); + }); + + it('decodes binary data correctly', () => { + // Bytes [0, 1, 255] → base64 "AAH/" + const result = base64ToUint8Array('AAH/'); + expect(Array.from(result)).toEqual([0, 1, 255]); + }); +}); From 74716ac9329b5aa3046d6c0a42a8db7f7f69983c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 14 Feb 2026 11:03:12 -0300 Subject: [PATCH 8/8] 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. --- .../super-editor/src/core/DocxZipper.test.js | 42 +++++++++++++++++++ .../super-converter/SuperConverter.test.js | 4 +- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/DocxZipper.test.js b/packages/super-editor/src/core/DocxZipper.test.js index 02cd3fef08..b8295d1413 100644 --- a/packages/super-editor/src/core/DocxZipper.test.js +++ b/packages/super-editor/src/core/DocxZipper.test.js @@ -257,3 +257,45 @@ describe('DocxZipper - updateContentTypes', () => { expect(updatedContentTypes).toContain('/word/footer1.xml'); }); }); + +describe('DocxZipper - exportFromCollaborativeDocx media handling', () => { + it('handles both base64 string and ArrayBuffer media values', async () => { + const zipper = new DocxZipper(); + + const contentTypes = ` + + + + + + `; + + const docx = [ + { name: '[Content_Types].xml', content: contentTypes }, + { name: 'word/document.xml', content: '' }, + ]; + + // base64 for bytes [72, 101, 108, 108, 111] ("Hello") + const base64Media = 'SGVsbG8='; + // ArrayBuffer for bytes [87, 111, 114, 108, 100] ("World") + const binaryMedia = new Uint8Array([87, 111, 114, 108, 100]).buffer; + + const result = await zipper.updateZip({ + docx, + updatedDocs: {}, + media: { + 'word/media/image1.png': base64Media, + 'word/media/image2.png': binaryMedia, + }, + fonts: {}, + isHeadless: true, + }); + + const readBack = await new JSZip().loadAsync(result); + const img1 = await readBack.file('word/media/image1.png').async('uint8array'); + const img2 = await readBack.file('word/media/image2.png').async('uint8array'); + + expect(Array.from(img1)).toEqual([72, 101, 108, 108, 111]); + expect(Array.from(img2)).toEqual([87, 111, 114, 108, 100]); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.test.js b/packages/super-editor/src/core/super-converter/SuperConverter.test.js index fb550a8da3..c07c0fb760 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.test.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.test.js @@ -82,7 +82,7 @@ describe('SuperConverter Document GUID', () => { // getDocumentIdentifier assigns GUID and returns content hash (since no timestamp) const identifier = await converter.getDocumentIdentifier(); - expect(identifier).toMatch(/^HASH-/); + expect(identifier).toBe('HASH-61D1432F'); // GUID is now assigned (for persistence on export) expect(converter.getDocumentGuid()).toBe('test-uuid-1234'); @@ -164,7 +164,7 @@ describe('SuperConverter Document GUID', () => { }); const identifier = await converter.getDocumentIdentifier(); - expect(identifier).toMatch(/^HASH-[A-F0-9]+$/); + expect(identifier).toBe('HASH-A5FD6589'); expect(converter.getDocumentGuid()).toBe('EXISTING-GUID-123'); expect(converter.getDocumentCreatedTimestamp()).toBe('2024-01-15T10:30:00Z'); expect(converter.documentModified).toBeFalsy();