From a7477519d367a377676b4c04b4bc6b9b1211e4bc Mon Sep 17 00:00:00 2001 From: cam Date: Tue, 3 Feb 2026 19:39:38 -0800 Subject: [PATCH] fix: use DEFLATE compression for docx export instead of STORE JSZip defaults to STORE (no compression) when compression isn't specified, causing exported .docx files to be ~10x larger than the original. This adds DEFLATE as the default compression method in DocxZipper.updateZip, matching what Word and other editors produce. The compression method is configurable via the `compression` option in exportDocument/save/saveTo, allowing callers to opt into STORE for faster exports on large documents if needed. Co-Authored-By: Claude Opus 4.5 --- packages/super-editor/src/core/DocxZipper.js | 8 +- .../super-editor/src/core/DocxZipper.test.js | 91 +++++++++++++++++++ packages/super-editor/src/core/Editor.ts | 7 ++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 018ceb96c7..42bd3b4f22 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -262,7 +262,7 @@ class DocxZipper { return zip; } - async updateZip({ docx, updatedDocs, originalDocxFile, media, fonts, isHeadless }) { + async updateZip({ docx, updatedDocs, originalDocxFile, media, fonts, isHeadless, compression = 'DEFLATE' }) { // We use a different re-zip process if we have the original docx vs the docx xml metadata let zip; @@ -274,7 +274,11 @@ class DocxZipper { // If we are headless we don't have 'blob' support, so export as 'nodebuffer' const exportType = isHeadless ? 'nodebuffer' : 'blob'; - return await zip.generateAsync({ type: exportType }); + return await zip.generateAsync({ + type: exportType, + compression, + compressionOptions: compression === 'DEFLATE' ? { level: 6 } : undefined, + }); } /** diff --git a/packages/super-editor/src/core/DocxZipper.test.js b/packages/super-editor/src/core/DocxZipper.test.js index b004bfcff4..02cd3fef08 100644 --- a/packages/super-editor/src/core/DocxZipper.test.js +++ b/packages/super-editor/src/core/DocxZipper.test.js @@ -109,6 +109,97 @@ describe('DocxZipper - UTF-16 XML handling', () => { }); }); +describe('DocxZipper - updateZip compression', () => { + it('uses DEFLATE compression by default', async () => { + const zipper = new DocxZipper(); + + const contentTypes = ` + + + + + `; + + const documentXml = ` + + ${'Hello world. '.repeat(100)} + `; + + const docx = [ + { name: '[Content_Types].xml', content: contentTypes }, + { name: 'word/document.xml', content: documentXml }, + ]; + + const result = await zipper.updateZip({ + docx, + updatedDocs: {}, + media: {}, + fonts: {}, + isHeadless: true, + }); + + // Verify the output is compressed by checking DEFLATE produces smaller output than STORE + const storeResult = await new DocxZipper().updateZip({ + docx, + updatedDocs: {}, + media: {}, + fonts: {}, + isHeadless: true, + compression: 'STORE', + }); + + expect(result.length).toBeLessThan(storeResult.length); + + // Verify the compressed output is a valid zip that can be read back + const readBack = await new JSZip().loadAsync(result); + const docXml = await readBack.file('word/document.xml').async('string'); + expect(docXml).toContain('Hello world.'); + }); + + it('respects STORE compression when explicitly requested', async () => { + const zipper = new DocxZipper(); + + const contentTypes = ` + + + + + `; + + const documentXml = ` + + ${'Hello world. '.repeat(100)} + `; + + const docx = [ + { name: '[Content_Types].xml', content: contentTypes }, + { name: 'word/document.xml', content: documentXml }, + ]; + + const result = await zipper.updateZip({ + docx, + updatedDocs: {}, + media: {}, + fonts: {}, + isHeadless: true, + compression: 'STORE', + }); + + // STORE should produce output roughly the size of the uncompressed content + // (plus ZIP overhead), so it should be larger than DEFLATE + const deflateResult = await new DocxZipper().updateZip({ + docx, + updatedDocs: {}, + media: {}, + fonts: {}, + isHeadless: true, + compression: 'DEFLATE', + }); + + expect(result.length).toBeGreaterThan(deflateResult.length); + }); +}); + describe('DocxZipper - updateContentTypes', () => { it('adds header/footer overrides for newly added parts', async () => { const zipper = new DocxZipper(); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index bca42f0eb3..133865332a 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -138,6 +138,9 @@ export interface SaveOptions { /** Highlight color for fields */ fieldsHighlightColor?: string | null; + + /** ZIP compression method for docx export. Defaults to 'DEFLATE'. Use 'STORE' for faster exports without compression. */ + compression?: 'DEFLATE' | 'STORE'; } /** @@ -2484,6 +2487,7 @@ export class Editor extends EventEmitter { comments, getUpdatedDocs = false, fieldsHighlightColor = null, + compression, }: { isFinalDoc?: boolean; commentsType?: string; @@ -2492,6 +2496,7 @@ export class Editor extends EventEmitter { comments?: Comment[]; getUpdatedDocs?: boolean; fieldsHighlightColor?: string | null; + compression?: 'DEFLATE' | 'STORE'; } = {}): Promise | ProseMirrorJSON | string | undefined> { try { // Use provided comments, or fall back to imported comments from converter @@ -2623,6 +2628,7 @@ export class Editor extends EventEmitter { media, fonts: this.options.fonts, isHeadless: this.options.isHeadless, + compression, }); return result; @@ -2893,6 +2899,7 @@ export class Editor extends EventEmitter { commentsType: options?.commentsType, comments: options?.comments, fieldsHighlightColor: options?.fieldsHighlightColor, + compression: options?.compression, }); return result as Blob | Buffer;