From 4aa3bf7e851ead5850dcab2ce4606988d0c7ce5f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 10:35:39 -0300 Subject: [PATCH 1/5] feat: seed blank docx parts when loading JSON into editor --- packages/super-editor/src/core/Editor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 406e55c70f..e73ebcb81d 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -802,7 +802,7 @@ export class Editor extends EventEmitter { // Blank document (source is undefined or null) // For docx mode without pre-parsed content, load the blank.docx template const shouldLoadBlankDocx = - resolvedMode === 'docx' && !options?.content && !options?.html && !options?.markdown && !options?.json; + resolvedMode === 'docx' && !options?.content && !options?.html && !options?.markdown; if (shouldLoadBlankDocx) { // Decode base64 blank.docx without fetch From 7d6a1a88817db52d796fe689d1d32384256a744b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 10:47:35 -0300 Subject: [PATCH 2/5] test: simulate export when JSON data is provided --- .../src/tests/export/jsonDeclaration.test.js | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/super-editor/src/tests/export/jsonDeclaration.test.js diff --git a/packages/super-editor/src/tests/export/jsonDeclaration.test.js b/packages/super-editor/src/tests/export/jsonDeclaration.test.js new file mode 100644 index 0000000000..042f1e2352 --- /dev/null +++ b/packages/super-editor/src/tests/export/jsonDeclaration.test.js @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { Editor } from '@core/Editor.js'; + +const SAMPLE_JSON = { + type: 'doc', + attrs: { + attrs: null, + }, + content: [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'JSON-only export reproducible content', + }, + ], + }, + ], +}; + +describe('Json override export', () => { + it('exports a DOCX when editor is initialized from sample JSON', async () => { + const editor = await Editor.open(undefined, { json: SAMPLE_JSON }); + + try { + const exported = await editor.exportDocx(); + expect(Buffer.isBuffer(exported)).toBe(true); + expect(exported.length).toBeGreaterThan(0); + } finally { + editor.destroy(); + } + }); +}); From 99524822caf3be06db76ad45b8d159da2eec9fe2 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 16 Mar 2026 12:07:42 -0300 Subject: [PATCH 3/5] fix: preserve supplied media and fonts for JSON docx loads --- packages/super-editor/src/core/Editor.ts | 10 +++++++-- .../src/tests/export/jsonDeclaration.test.js | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index e73ebcb81d..0b56f35395 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -821,8 +821,14 @@ export class Editor extends EventEmitter { } const [docx, _media, mediaFiles, fonts] = (await Editor.loadXmlData(fileSource, canUseBuffer))!; resolvedOptions.content = docx; - resolvedOptions.mediaFiles = mediaFiles; - resolvedOptions.fonts = fonts; + resolvedOptions.mediaFiles = { + ...mediaFiles, + ...(options?.mediaFiles ?? {}), + }; + resolvedOptions.fonts = { + ...fonts, + ...(options?.fonts ?? {}), + }; resolvedOptions.fileSource = fileSource; resolvedOptions.isNewFile = explicitIsNewFile ?? true; this.#sourcePath = null; diff --git a/packages/super-editor/src/tests/export/jsonDeclaration.test.js b/packages/super-editor/src/tests/export/jsonDeclaration.test.js index 042f1e2352..166666236b 100644 --- a/packages/super-editor/src/tests/export/jsonDeclaration.test.js +++ b/packages/super-editor/src/tests/export/jsonDeclaration.test.js @@ -31,4 +31,26 @@ describe('Json override export', () => { editor.destroy(); } }); + + it('preserves caller-supplied media files and fonts when initialized from JSON', async () => { + const mediaFiles = { + 'word/media/image1.png': 'data:image/png;base64,ZmFrZQ==', + }; + const fonts = { + 'word/fonts/custom-font.odttf': 'data:font/otf;base64,ZmFrZQ==', + }; + + const editor = await Editor.open(undefined, { + json: SAMPLE_JSON, + mediaFiles, + fonts, + }); + + try { + expect(editor.options.mediaFiles).toMatchObject(mediaFiles); + expect(editor.options.fonts).toMatchObject(fonts); + } finally { + editor.destroy(); + } + }); }); From db29b885a923d64c977dd94bf41a821629f5baea Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Mar 2026 14:13:13 -0700 Subject: [PATCH 4/5] fix: preserve embedded fonrts in json-backed docx exports --- packages/super-editor/src/core/DocxZipper.js | 39 ++++++++-- .../super-editor/src/core/DocxZipper.test.js | 73 +++++++++++++++++++ packages/super-editor/src/core/Editor.ts | 1 + 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 7c2370544e..3c1d6b6d54 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -13,6 +13,13 @@ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', ' const MIME_TYPE_FOR_EXT = { tif: 'tiff', jpg: 'jpeg' }; const CUSTOM_XML_ITEM_PROPS_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.customXmlProperties+xml'; +/** OOXML content types for embedded font file extensions. */ +const FONT_CONTENT_TYPES = { + odttf: 'application/vnd.openxmlformats-officedocument.obfuscatedFont', + ttf: 'application/x-font-ttf', + otf: 'application/vnd.ms-opentype', +}; + /** * Class to handle unzipping and zipping of docx files */ @@ -110,7 +117,7 @@ class DocxZipper { /** * Update [Content_Types].xml with extensions of new Image annotations */ - async updateContentTypes(docx, media, fromJson, updatedDocs = {}) { + async updateContentTypes(docx, media, fromJson, updatedDocs = {}, fonts = {}) { const additionalPartNames = Object.keys(updatedDocs || {}); const newMediaTypes = Object.keys(media) .map((name) => this.getFileExtension(name)) @@ -147,6 +154,21 @@ class DocxZipper { seenTypes.add(type); } + // Register content types for embedded font extensions + if (fonts) { + const fontExts = new Set( + Object.keys(fonts) + .map((name) => this.getFileExtension(name)) + .filter((ext) => ext && FONT_CONTENT_TYPES[ext]), + ); + for (const ext of fontExts) { + if (defaultMediaTypes.includes(ext)) continue; + if (seenTypes.has(ext)) continue; + typesString += ``; + seenTypes.add(ext); + } + } + // Update for comments and extensionless media overrides. const xmlJson = JSON.parse(xmljs.xml2json(contentTypesXml, null, 2)); const types = xmlJson.elements?.find((el) => el.name === 'Types') || {}; @@ -365,7 +387,7 @@ class DocxZipper { let zip; if (originalDocxFile) { - zip = await this.exportFromOriginalFile(originalDocxFile, updatedDocs, media); + zip = await this.exportFromOriginalFile(originalDocxFile, updatedDocs, media, fonts); } else { zip = await this.exportFromCollaborativeDocx(docx, updatedDocs, media, fonts); } @@ -415,7 +437,7 @@ class DocxZipper { zip.file(fontName, fontUintArray); } - await this.updateContentTypes(zip, media, false, updatedDocs); + await this.updateContentTypes(zip, media, false, updatedDocs, fonts); // Reconcile package-level singleton metadata as a final safety pass. await this.#syncPackageMetadataInZip(zip); @@ -430,7 +452,7 @@ class DocxZipper { * @param {Object} updatedDocs An object containing the updated docs (keys are relative file names) * @returns {Promise} The unzipped but updated docx file ready for zipping */ - async exportFromOriginalFile(originalDocxFile, updatedDocs, media) { + async exportFromOriginalFile(originalDocxFile, updatedDocs, media, fonts) { const unzippedOriginalDocx = await this.unzip(originalDocxFile); const filePromises = []; unzippedOriginalDocx.forEach((relativePath, zipEntry) => { @@ -457,7 +479,14 @@ class DocxZipper { unzippedOriginalDocx.file(path, media[path]); }); - await this.updateContentTypes(unzippedOriginalDocx, media, false, updatedDocs); + // Export caller-supplied font files + if (fonts) { + for (const [fontName, fontUintArray] of Object.entries(fonts)) { + unzippedOriginalDocx.file(fontName, fontUintArray); + } + } + + await this.updateContentTypes(unzippedOriginalDocx, media, false, updatedDocs, fonts); // Reconcile package-level singleton metadata as a final safety pass. await this.#syncPackageMetadataInZip(unzippedOriginalDocx); diff --git a/packages/super-editor/src/core/DocxZipper.test.js b/packages/super-editor/src/core/DocxZipper.test.js index 178c1e88b0..5d262a03f1 100644 --- a/packages/super-editor/src/core/DocxZipper.test.js +++ b/packages/super-editor/src/core/DocxZipper.test.js @@ -658,6 +658,79 @@ describe('DocxZipper - .tmp image file detection', () => { }); }); +describe('DocxZipper - exportFromOriginalFile font preservation', () => { + it('includes caller-supplied fonts in the output zip', async () => { + const zipper = new DocxZipper(); + const originalZip = new JSZip(); + + const contentTypes = ` + + + + + `; + originalZip.file('[Content_Types].xml', contentTypes); + originalZip.file( + 'word/document.xml', + '', + ); + const originalDocxFile = await originalZip.generateAsync({ type: 'nodebuffer' }); + + const fontData = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef]); + + const result = await zipper.updateZip({ + docx: [], + updatedDocs: { + 'word/document.xml': '', + }, + originalDocxFile, + media: {}, + fonts: { 'word/fonts/font1.odttf': fontData }, + isHeadless: true, + }); + + const readBack = await new JSZip().loadAsync(result); + const fontBytes = await readBack.file('word/fonts/font1.odttf').async('uint8array'); + expect(fontBytes).toEqual(fontData); + + // Verify [Content_Types].xml includes the odttf content type + const outputContentTypes = await readBack.file('[Content_Types].xml').async('string'); + expect(outputContentTypes).toContain('Extension="odttf"'); + expect(outputContentTypes).toContain('application/vnd.openxmlformats-officedocument.obfuscatedFont'); + }); + + it('does not fail when fonts is undefined', async () => { + const zipper = new DocxZipper(); + const originalZip = new JSZip(); + + const contentTypes = ` + + + + + `; + originalZip.file('[Content_Types].xml', contentTypes); + originalZip.file( + 'word/document.xml', + '', + ); + const originalDocxFile = await originalZip.generateAsync({ type: 'nodebuffer' }); + + const result = await zipper.updateZip({ + docx: [], + updatedDocs: { + 'word/document.xml': '', + }, + originalDocxFile, + media: {}, + isHeadless: true, + }); + + const readBack = await new JSZip().loadAsync(result); + expect(readBack.file('word/document.xml')).toBeTruthy(); + }); +}); + describe('DocxZipper - comment file deletion', () => { const contentTypesWithComments = ` diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 0b56f35395..d1e5769bf0 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -2789,6 +2789,7 @@ export class Editor extends EventEmitter { media, true, updatedDocs, + this.options.fonts, ); // Reconcile package-level singleton metadata (content-type overrides From 29d427cf51a770bb5c15ae884b29eccbd0fc436a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Mon, 16 Mar 2026 14:18:38 -0700 Subject: [PATCH 5/5] chore: fix types --- packages/super-editor/src/core/PositionTracker.ts | 2 +- .../src/extensions/image/imageHelpers/imagePositionPlugin.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/PositionTracker.ts b/packages/super-editor/src/core/PositionTracker.ts index 54d683bd16..0bbf1aef89 100644 --- a/packages/super-editor/src/core/PositionTracker.ts +++ b/packages/super-editor/src/core/PositionTracker.ts @@ -249,7 +249,7 @@ export function createPositionTrackerPlugin(): Plugin { props: { decorations() { - return DecorationSet.empty; + return null; }, }, }); diff --git a/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js b/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js index 7ef5ba7ae2..9a59da27c3 100644 --- a/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js +++ b/packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js @@ -100,7 +100,8 @@ export const ImagePositionPlugin = ({ editor }) => { props: { decorations(state) { - return this.getState(state); + // Duplicate prosemirror-view installs can make DecorationSet nominally incompatible here. + return /** @type {any} */ (this.getState(state)); }, }, });