diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js
index 42bd3b4f2..3023ede6a 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,8 @@ class DocxZipper {
});
Object.keys(media).forEach((path) => {
- const binaryData = Buffer.from(media[path], 'base64');
+ const value = media[path];
+ const binaryData = typeof value === 'string' ? base64ToUint8Array(value) : value;
zip.file(path, binaryData);
});
diff --git a/packages/super-editor/src/core/DocxZipper.test.js b/packages/super-editor/src/core/DocxZipper.test.js
index 02cd3fef0..b8295d141 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.js b/packages/super-editor/src/core/super-converter/SuperConverter.js
index 57b4f6b4d..969616a1a 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/SuperConverter.test.js b/packages/super-editor/src/core/super-converter/SuperConverter.test.js
index fb550a8da..c07c0fb76 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();
diff --git a/packages/super-editor/src/core/super-converter/helpers.js b/packages/super-editor/src/core/super-converter/helpers.js
index 8443580b5..46a29e6f1 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,
};
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 ecd67f890..c1b6de908 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,8 @@ import {
polygonUnitsToPixels,
pixelsToPolygonUnits,
getArrayBufferFromUrl,
+ computeCrc32Hex,
+ base64ToUint8Array,
} from './helpers.js';
describe('polygonToObj', () => {
@@ -339,3 +341,45 @@ 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');
+ });
+});
+
+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]);
+ });
+});
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 561ec044f..4b25b7a72 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 b906fa98e..0f97ee630 100644
--- a/packages/superdoc/vite.config.umd.js
+++ b/packages/superdoc/vite.config.umd.js
@@ -1,17 +1,16 @@
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 {
define: {
__APP_VERSION__: JSON.stringify(version),
- process: JSON.stringify({ env: { NODE_ENV: 'production' } }),
+ 'process.env.NODE_ENV': JSON.stringify('production'),
},
plugins,
resolve: {