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: {