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 406e55c70f..d1e5769bf0 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
@@ -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;
@@ -2783,6 +2789,7 @@ export class Editor extends EventEmitter {
media,
true,
updatedDocs,
+ this.options.fonts,
);
// Reconcile package-level singleton metadata (content-type overrides
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));
},
},
});
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..166666236b
--- /dev/null
+++ b/packages/super-editor/src/tests/export/jsonDeclaration.test.js
@@ -0,0 +1,56 @@
+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();
+ }
+ });
+
+ 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();
+ }
+ });
+});