Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions packages/super-editor/src/core/DocxZipper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 += `<Default Extension="${ext}" ContentType="${FONT_CONTENT_TYPES[ext]}"/>`;
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') || {};
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand All @@ -430,7 +452,7 @@ class DocxZipper {
* @param {Object} updatedDocs An object containing the updated docs (keys are relative file names)
* @returns {Promise<JSZip>} 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) => {
Expand All @@ -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);
Expand Down
73 changes: 73 additions & 0 deletions packages/super-editor/src/core/DocxZipper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<?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"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`;
originalZip.file('[Content_Types].xml', contentTypes);
originalZip.file(
'word/document.xml',
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
);
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': '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
},
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 = `<?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"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`;
originalZip.file('[Content_Types].xml', contentTypes);
originalZip.file(
'word/document.xml',
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
);
const originalDocxFile = await originalZip.generateAsync({ type: 'nodebuffer' });

const result = await zipper.updateZip({
docx: [],
updatedDocs: {
'word/document.xml': '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>',
},
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 = `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
Expand Down
13 changes: 10 additions & 3 deletions packages/super-editor/src/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
// 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;
Comment thread
luccas-harbour marked this conversation as resolved.

if (shouldLoadBlankDocx) {
// Decode base64 blank.docx without fetch
Expand All @@ -821,8 +821,14 @@ export class Editor extends EventEmitter<EditorEventMap> {
}
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;
Expand Down Expand Up @@ -2783,6 +2789,7 @@ export class Editor extends EventEmitter<EditorEventMap> {
media,
true,
updatedDocs,
this.options.fonts,
);

// Reconcile package-level singleton metadata (content-type overrides
Expand Down
2 changes: 1 addition & 1 deletion packages/super-editor/src/core/PositionTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export function createPositionTrackerPlugin(): Plugin<PositionTrackerState> {

props: {
decorations() {
return DecorationSet.empty;
return null;
},
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
},
},
});
Expand Down
56 changes: 56 additions & 0 deletions packages/super-editor/src/tests/export/jsonDeclaration.test.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading