Skip to content

Commit 89c982f

Browse files
feat: seed blank docx parts when loading JSON into editor (#2401)
* feat: seed blank docx parts when loading JSON into editor * test: simulate export when JSON data is provided * fix: preserve supplied media and fonts for JSON docx loads * fix: preserve embedded fonrts in json-backed docx exports * chore: fix types
1 parent 697e799 commit 89c982f

6 files changed

Lines changed: 176 additions & 10 deletions

File tree

packages/super-editor/src/core/DocxZipper.js

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', '
1313
const MIME_TYPE_FOR_EXT = { tif: 'tiff', jpg: 'jpeg' };
1414
const CUSTOM_XML_ITEM_PROPS_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.customXmlProperties+xml';
1515

16+
/** OOXML content types for embedded font file extensions. */
17+
const FONT_CONTENT_TYPES = {
18+
odttf: 'application/vnd.openxmlformats-officedocument.obfuscatedFont',
19+
ttf: 'application/x-font-ttf',
20+
otf: 'application/vnd.ms-opentype',
21+
};
22+
1623
/**
1724
* Class to handle unzipping and zipping of docx files
1825
*/
@@ -110,7 +117,7 @@ class DocxZipper {
110117
/**
111118
* Update [Content_Types].xml with extensions of new Image annotations
112119
*/
113-
async updateContentTypes(docx, media, fromJson, updatedDocs = {}) {
120+
async updateContentTypes(docx, media, fromJson, updatedDocs = {}, fonts = {}) {
114121
const additionalPartNames = Object.keys(updatedDocs || {});
115122
const newMediaTypes = Object.keys(media)
116123
.map((name) => this.getFileExtension(name))
@@ -147,6 +154,21 @@ class DocxZipper {
147154
seenTypes.add(type);
148155
}
149156

157+
// Register content types for embedded font extensions
158+
if (fonts) {
159+
const fontExts = new Set(
160+
Object.keys(fonts)
161+
.map((name) => this.getFileExtension(name))
162+
.filter((ext) => ext && FONT_CONTENT_TYPES[ext]),
163+
);
164+
for (const ext of fontExts) {
165+
if (defaultMediaTypes.includes(ext)) continue;
166+
if (seenTypes.has(ext)) continue;
167+
typesString += `<Default Extension="${ext}" ContentType="${FONT_CONTENT_TYPES[ext]}"/>`;
168+
seenTypes.add(ext);
169+
}
170+
}
171+
150172
// Update for comments and extensionless media overrides.
151173
const xmlJson = JSON.parse(xmljs.xml2json(contentTypesXml, null, 2));
152174
const types = xmlJson.elements?.find((el) => el.name === 'Types') || {};
@@ -365,7 +387,7 @@ class DocxZipper {
365387
let zip;
366388

367389
if (originalDocxFile) {
368-
zip = await this.exportFromOriginalFile(originalDocxFile, updatedDocs, media);
390+
zip = await this.exportFromOriginalFile(originalDocxFile, updatedDocs, media, fonts);
369391
} else {
370392
zip = await this.exportFromCollaborativeDocx(docx, updatedDocs, media, fonts);
371393
}
@@ -415,7 +437,7 @@ class DocxZipper {
415437
zip.file(fontName, fontUintArray);
416438
}
417439

418-
await this.updateContentTypes(zip, media, false, updatedDocs);
440+
await this.updateContentTypes(zip, media, false, updatedDocs, fonts);
419441

420442
// Reconcile package-level singleton metadata as a final safety pass.
421443
await this.#syncPackageMetadataInZip(zip);
@@ -430,7 +452,7 @@ class DocxZipper {
430452
* @param {Object} updatedDocs An object containing the updated docs (keys are relative file names)
431453
* @returns {Promise<JSZip>} The unzipped but updated docx file ready for zipping
432454
*/
433-
async exportFromOriginalFile(originalDocxFile, updatedDocs, media) {
455+
async exportFromOriginalFile(originalDocxFile, updatedDocs, media, fonts) {
434456
const unzippedOriginalDocx = await this.unzip(originalDocxFile);
435457
const filePromises = [];
436458
unzippedOriginalDocx.forEach((relativePath, zipEntry) => {
@@ -457,7 +479,14 @@ class DocxZipper {
457479
unzippedOriginalDocx.file(path, media[path]);
458480
});
459481

460-
await this.updateContentTypes(unzippedOriginalDocx, media, false, updatedDocs);
482+
// Export caller-supplied font files
483+
if (fonts) {
484+
for (const [fontName, fontUintArray] of Object.entries(fonts)) {
485+
unzippedOriginalDocx.file(fontName, fontUintArray);
486+
}
487+
}
488+
489+
await this.updateContentTypes(unzippedOriginalDocx, media, false, updatedDocs, fonts);
461490

462491
// Reconcile package-level singleton metadata as a final safety pass.
463492
await this.#syncPackageMetadataInZip(unzippedOriginalDocx);

packages/super-editor/src/core/DocxZipper.test.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,79 @@ describe('DocxZipper - .tmp image file detection', () => {
658658
});
659659
});
660660

661+
describe('DocxZipper - exportFromOriginalFile font preservation', () => {
662+
it('includes caller-supplied fonts in the output zip', async () => {
663+
const zipper = new DocxZipper();
664+
const originalZip = new JSZip();
665+
666+
const contentTypes = `<?xml version="1.0" encoding="UTF-8"?>
667+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
668+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
669+
<Default Extension="xml" ContentType="application/xml"/>
670+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
671+
</Types>`;
672+
originalZip.file('[Content_Types].xml', contentTypes);
673+
originalZip.file(
674+
'word/document.xml',
675+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
676+
);
677+
const originalDocxFile = await originalZip.generateAsync({ type: 'nodebuffer' });
678+
679+
const fontData = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef]);
680+
681+
const result = await zipper.updateZip({
682+
docx: [],
683+
updatedDocs: {
684+
'word/document.xml': '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
685+
},
686+
originalDocxFile,
687+
media: {},
688+
fonts: { 'word/fonts/font1.odttf': fontData },
689+
isHeadless: true,
690+
});
691+
692+
const readBack = await new JSZip().loadAsync(result);
693+
const fontBytes = await readBack.file('word/fonts/font1.odttf').async('uint8array');
694+
expect(fontBytes).toEqual(fontData);
695+
696+
// Verify [Content_Types].xml includes the odttf content type
697+
const outputContentTypes = await readBack.file('[Content_Types].xml').async('string');
698+
expect(outputContentTypes).toContain('Extension="odttf"');
699+
expect(outputContentTypes).toContain('application/vnd.openxmlformats-officedocument.obfuscatedFont');
700+
});
701+
702+
it('does not fail when fonts is undefined', async () => {
703+
const zipper = new DocxZipper();
704+
const originalZip = new JSZip();
705+
706+
const contentTypes = `<?xml version="1.0" encoding="UTF-8"?>
707+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
708+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
709+
<Default Extension="xml" ContentType="application/xml"/>
710+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
711+
</Types>`;
712+
originalZip.file('[Content_Types].xml', contentTypes);
713+
originalZip.file(
714+
'word/document.xml',
715+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
716+
);
717+
const originalDocxFile = await originalZip.generateAsync({ type: 'nodebuffer' });
718+
719+
const result = await zipper.updateZip({
720+
docx: [],
721+
updatedDocs: {
722+
'word/document.xml': '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
723+
},
724+
originalDocxFile,
725+
media: {},
726+
isHeadless: true,
727+
});
728+
729+
const readBack = await new JSZip().loadAsync(result);
730+
expect(readBack.file('word/document.xml')).toBeTruthy();
731+
});
732+
});
733+
661734
describe('DocxZipper - comment file deletion', () => {
662735
const contentTypesWithComments = `<?xml version="1.0" encoding="UTF-8"?>
663736
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">

packages/super-editor/src/core/Editor.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
802802
// Blank document (source is undefined or null)
803803
// For docx mode without pre-parsed content, load the blank.docx template
804804
const shouldLoadBlankDocx =
805-
resolvedMode === 'docx' && !options?.content && !options?.html && !options?.markdown && !options?.json;
805+
resolvedMode === 'docx' && !options?.content && !options?.html && !options?.markdown;
806806

807807
if (shouldLoadBlankDocx) {
808808
// Decode base64 blank.docx without fetch
@@ -821,8 +821,14 @@ export class Editor extends EventEmitter<EditorEventMap> {
821821
}
822822
const [docx, _media, mediaFiles, fonts] = (await Editor.loadXmlData(fileSource, canUseBuffer))!;
823823
resolvedOptions.content = docx;
824-
resolvedOptions.mediaFiles = mediaFiles;
825-
resolvedOptions.fonts = fonts;
824+
resolvedOptions.mediaFiles = {
825+
...mediaFiles,
826+
...(options?.mediaFiles ?? {}),
827+
};
828+
resolvedOptions.fonts = {
829+
...fonts,
830+
...(options?.fonts ?? {}),
831+
};
826832
resolvedOptions.fileSource = fileSource;
827833
resolvedOptions.isNewFile = explicitIsNewFile ?? true;
828834
this.#sourcePath = null;
@@ -2783,6 +2789,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
27832789
media,
27842790
true,
27852791
updatedDocs,
2792+
this.options.fonts,
27862793
);
27872794

27882795
// Reconcile package-level singleton metadata (content-type overrides

packages/super-editor/src/core/PositionTracker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export function createPositionTrackerPlugin(): Plugin<PositionTrackerState> {
249249

250250
props: {
251251
decorations() {
252-
return DecorationSet.empty;
252+
return null;
253253
},
254254
},
255255
});

packages/super-editor/src/extensions/image/imageHelpers/imagePositionPlugin.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ export const ImagePositionPlugin = ({ editor }) => {
100100

101101
props: {
102102
decorations(state) {
103-
return this.getState(state);
103+
// Duplicate prosemirror-view installs can make DecorationSet nominally incompatible here.
104+
return /** @type {any} */ (this.getState(state));
104105
},
105106
},
106107
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { Editor } from '@core/Editor.js';
3+
4+
const SAMPLE_JSON = {
5+
type: 'doc',
6+
attrs: {
7+
attrs: null,
8+
},
9+
content: [
10+
{
11+
type: 'paragraph',
12+
content: [
13+
{
14+
type: 'text',
15+
text: 'JSON-only export reproducible content',
16+
},
17+
],
18+
},
19+
],
20+
};
21+
22+
describe('Json override export', () => {
23+
it('exports a DOCX when editor is initialized from sample JSON', async () => {
24+
const editor = await Editor.open(undefined, { json: SAMPLE_JSON });
25+
26+
try {
27+
const exported = await editor.exportDocx();
28+
expect(Buffer.isBuffer(exported)).toBe(true);
29+
expect(exported.length).toBeGreaterThan(0);
30+
} finally {
31+
editor.destroy();
32+
}
33+
});
34+
35+
it('preserves caller-supplied media files and fonts when initialized from JSON', async () => {
36+
const mediaFiles = {
37+
'word/media/image1.png': 'data:image/png;base64,ZmFrZQ==',
38+
};
39+
const fonts = {
40+
'word/fonts/custom-font.odttf': 'data:font/otf;base64,ZmFrZQ==',
41+
};
42+
43+
const editor = await Editor.open(undefined, {
44+
json: SAMPLE_JSON,
45+
mediaFiles,
46+
fonts,
47+
});
48+
49+
try {
50+
expect(editor.options.mediaFiles).toMatchObject(mediaFiles);
51+
expect(editor.options.fonts).toMatchObject(fonts);
52+
} finally {
53+
editor.destroy();
54+
}
55+
});
56+
});

0 commit comments

Comments
 (0)