Skip to content
5 changes: 3 additions & 2 deletions packages/super-editor/src/core/DocxZipper.js
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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);
});

Expand Down
42 changes: 42 additions & 0 deletions packages/super-editor/src/core/DocxZipper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`;

const docx = [
{ name: '[Content_Types].xml', content: contentTypes },
{ name: 'word/document.xml', content: '<w:document/>' },
];

// 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]);
});
});
23 changes: 11 additions & 12 deletions packages/super-editor/src/core/super-converter/SuperConverter.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()}`;
}

/**
Expand All @@ -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()}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down
50 changes: 35 additions & 15 deletions packages/super-editor/src/core/super-converter/helpers.js
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -620,4 +638,6 @@ export {
convertSizeToCSS,
resolveShadingFillColor,
resolveOpcTargetPath,
computeCrc32Hex,
base64ToUint8Array,
};
44 changes: 44 additions & 0 deletions packages/super-editor/src/core/super-converter/helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
polygonUnitsToPixels,
pixelsToPolygonUnits,
getArrayBufferFromUrl,
computeCrc32Hex,
base64ToUint8Array,
} from './helpers.js';

describe('polygonToObj', () => {
Expand Down Expand Up @@ -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]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

/**
Expand Down
5 changes: 2 additions & 3 deletions packages/superdoc/vite.config.umd.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
Loading