Skip to content

Commit 863254a

Browse files
authored
fix(super-editor): reconcile OPC package metadata during DOCX export (#2357)
1 parent c76e9e8 commit 863254a

7 files changed

Lines changed: 1208 additions & 0 deletions

File tree

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getContentTypesFromXml, base64ToUint8Array, detectImageType } from './s
44
import { ensureXmlString, isXmlLike } from './encoding-helpers.js';
55
import { DOCX } from '@superdoc/common';
66
import { COMMENT_FILE_BASENAMES } from './super-converter/constants.js';
7+
import { syncPackageMetadata } from './opc/sync-package-metadata.js';
78

89
/** Image file extensions recognized during import and export. */
910
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']);
@@ -313,6 +314,47 @@ class DocxZipper {
313314
docx.file(contentTypesPath, updatedContentTypesXml);
314315
}
315316

317+
/**
318+
* Run the OPC package metadata synchronizer against a JSZip instance.
319+
*
320+
* Reads [Content_Types].xml and _rels/.rels from the zip, reconciles
321+
* managed package-level parts, and writes the corrected files back.
322+
*
323+
* The assembled zip is treated as the single source of truth — no stale
324+
* updatedDocs are passed, so the synchronizer sees exactly what
325+
* updateContentTypes() already wrote.
326+
*
327+
* @param {JSZip} zip - The fully assembled zip to reconcile.
328+
*/
329+
async #syncPackageMetadataInZip(zip) {
330+
// Build a base-files map from the zip's current listing.
331+
// At this point the zip already contains all base + updated + media entries.
332+
const baseForSync = {};
333+
zip.forEach((path) => {
334+
baseForSync[path] = ''; // non-null signals "exists"
335+
});
336+
337+
// Read the two metadata files the synchronizer needs to parse.
338+
// Use JSZip's async API to correctly handle all internal storage formats.
339+
const ctEntry = zip.file('[Content_Types].xml');
340+
if (ctEntry) {
341+
baseForSync['[Content_Types].xml'] = await ctEntry.async('string');
342+
}
343+
const rlEntry = zip.file('_rels/.rels');
344+
if (rlEntry) {
345+
baseForSync['_rels/.rels'] = await rlEntry.async('string');
346+
}
347+
348+
// Pass an empty updatedDocs — the zip is already the assembled truth.
349+
const { contentTypesXml, relsXml } = syncPackageMetadata({
350+
baseFiles: baseForSync,
351+
updatedDocs: {},
352+
});
353+
354+
zip.file('[Content_Types].xml', contentTypesXml);
355+
zip.file('_rels/.rels', relsXml);
356+
}
357+
316358
async unzip(file) {
317359
const zip = await this.zip.loadAsync(file);
318360
return zip;
@@ -374,6 +416,10 @@ class DocxZipper {
374416
}
375417

376418
await this.updateContentTypes(zip, media, false, updatedDocs);
419+
420+
// Reconcile package-level singleton metadata as a final safety pass.
421+
await this.#syncPackageMetadataInZip(zip);
422+
377423
return zip;
378424
}
379425

@@ -413,6 +459,9 @@ class DocxZipper {
413459

414460
await this.updateContentTypes(unzippedOriginalDocx, media, false, updatedDocs);
415461

462+
// Reconcile package-level singleton metadata as a final safety pass.
463+
await this.#syncPackageMetadataInZip(unzippedOriginalDocx);
464+
416465
return unzippedOriginalDocx;
417466
}
418467

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import type { DocumentApi } from '@superdoc/document-api';
6363
import { createDocumentApi } from '@superdoc/document-api';
6464
import { getDocumentApiAdapters } from '../document-api-adapters/index.js';
6565
import { initPartsRuntime } from './parts/init-parts-runtime.js';
66+
import { syncPackageMetadata } from './opc/sync-package-metadata.js';
6667

6768
declare const __APP_VERSION__: string;
6869
declare const version: string | undefined;
@@ -2759,6 +2760,20 @@ export class Editor extends EventEmitter<EditorEventMap> {
27592760
true,
27602761
updatedDocs,
27612762
);
2763+
2764+
// Reconcile package-level singleton metadata (content-type overrides
2765+
// and root relationships) against the final set of output entries.
2766+
// this.options.content is DocxFileEntry[] | Record<string, unknown> | string | null.
2767+
// The synchronizer accepts an array of {name, content} or a key→content map.
2768+
const content = this.options.content;
2769+
const baseFiles = Array.isArray(content) || (content && typeof content === 'object') ? content : null;
2770+
const { contentTypesXml, relsXml } = syncPackageMetadata({
2771+
baseFiles: baseFiles as Parameters<typeof syncPackageMetadata>[0]['baseFiles'],
2772+
updatedDocs,
2773+
});
2774+
updatedDocs['[Content_Types].xml'] = contentTypesXml;
2775+
updatedDocs['_rels/.rels'] = relsXml;
2776+
27622777
return updatedDocs;
27632778
}
27642779

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Registry of OPC package-level singleton parts and their required metadata.
3+
*
4+
* Each entry defines a part that must have exactly one content-type Override in
5+
* [Content_Types].xml and exactly one root Relationship in _rels/.rels when the
6+
* part exists in the final package. When the part is absent, both registrations
7+
* must be removed.
8+
*
9+
* Values sourced from ECMA-376 / ISO 29500 and verified against real DOCX
10+
* fixtures produced by Microsoft Word.
11+
*/
12+
13+
/** @typedef {import('./sync-package-metadata.js').ManagedPartEntry} ManagedPartEntry */
14+
15+
/** @type {ManagedPartEntry[]} */
16+
export const MANAGED_PACKAGE_PARTS = [
17+
{
18+
zipPath: 'word/document.xml',
19+
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml',
20+
relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument',
21+
},
22+
{
23+
zipPath: 'docProps/core.xml',
24+
contentType: 'application/vnd.openxmlformats-package.core-properties+xml',
25+
relationshipType: 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties',
26+
},
27+
{
28+
zipPath: 'docProps/app.xml',
29+
contentType: 'application/vnd.openxmlformats-officedocument.extended-properties+xml',
30+
relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties',
31+
},
32+
{
33+
zipPath: 'docProps/custom.xml',
34+
contentType: 'application/vnd.openxmlformats-officedocument.custom-properties+xml',
35+
relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties',
36+
},
37+
];

0 commit comments

Comments
 (0)