diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 92c003ebbc..7c2370544e 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -4,6 +4,7 @@ import { getContentTypesFromXml, base64ToUint8Array, detectImageType } from './s import { ensureXmlString, isXmlLike } from './encoding-helpers.js'; import { DOCX } from '@superdoc/common'; import { COMMENT_FILE_BASENAMES } from './super-converter/constants.js'; +import { syncPackageMetadata } from './opc/sync-package-metadata.js'; /** Image file extensions recognized during import and export. */ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'emf', 'wmf', 'svg', 'webp']); @@ -313,6 +314,47 @@ class DocxZipper { docx.file(contentTypesPath, updatedContentTypesXml); } + /** + * Run the OPC package metadata synchronizer against a JSZip instance. + * + * Reads [Content_Types].xml and _rels/.rels from the zip, reconciles + * managed package-level parts, and writes the corrected files back. + * + * The assembled zip is treated as the single source of truth — no stale + * updatedDocs are passed, so the synchronizer sees exactly what + * updateContentTypes() already wrote. + * + * @param {JSZip} zip - The fully assembled zip to reconcile. + */ + async #syncPackageMetadataInZip(zip) { + // Build a base-files map from the zip's current listing. + // At this point the zip already contains all base + updated + media entries. + const baseForSync = {}; + zip.forEach((path) => { + baseForSync[path] = ''; // non-null signals "exists" + }); + + // Read the two metadata files the synchronizer needs to parse. + // Use JSZip's async API to correctly handle all internal storage formats. + const ctEntry = zip.file('[Content_Types].xml'); + if (ctEntry) { + baseForSync['[Content_Types].xml'] = await ctEntry.async('string'); + } + const rlEntry = zip.file('_rels/.rels'); + if (rlEntry) { + baseForSync['_rels/.rels'] = await rlEntry.async('string'); + } + + // Pass an empty updatedDocs — the zip is already the assembled truth. + const { contentTypesXml, relsXml } = syncPackageMetadata({ + baseFiles: baseForSync, + updatedDocs: {}, + }); + + zip.file('[Content_Types].xml', contentTypesXml); + zip.file('_rels/.rels', relsXml); + } + async unzip(file) { const zip = await this.zip.loadAsync(file); return zip; @@ -374,6 +416,10 @@ class DocxZipper { } await this.updateContentTypes(zip, media, false, updatedDocs); + + // Reconcile package-level singleton metadata as a final safety pass. + await this.#syncPackageMetadataInZip(zip); + return zip; } @@ -413,6 +459,9 @@ class DocxZipper { await this.updateContentTypes(unzippedOriginalDocx, media, false, updatedDocs); + // Reconcile package-level singleton metadata as a final safety pass. + await this.#syncPackageMetadataInZip(unzippedOriginalDocx); + return unzippedOriginalDocx; } diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 2c62e846f0..f1743300be 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -62,6 +62,7 @@ import type { DocumentApi } from '@superdoc/document-api'; import { createDocumentApi } from '@superdoc/document-api'; import { getDocumentApiAdapters } from '../document-api-adapters/index.js'; import { initPartsRuntime } from './parts/init-parts-runtime.js'; +import { syncPackageMetadata } from './opc/sync-package-metadata.js'; declare const __APP_VERSION__: string; declare const version: string | undefined; @@ -2742,6 +2743,20 @@ export class Editor extends EventEmitter { true, updatedDocs, ); + + // Reconcile package-level singleton metadata (content-type overrides + // and root relationships) against the final set of output entries. + // this.options.content is DocxFileEntry[] | Record | string | null. + // The synchronizer accepts an array of {name, content} or a key→content map. + const content = this.options.content; + const baseFiles = Array.isArray(content) || (content && typeof content === 'object') ? content : null; + const { contentTypesXml, relsXml } = syncPackageMetadata({ + baseFiles: baseFiles as Parameters[0]['baseFiles'], + updatedDocs, + }); + updatedDocs['[Content_Types].xml'] = contentTypesXml; + updatedDocs['_rels/.rels'] = relsXml; + return updatedDocs; } diff --git a/packages/super-editor/src/core/opc/managed-parts-registry.js b/packages/super-editor/src/core/opc/managed-parts-registry.js new file mode 100644 index 0000000000..e786d2fbfc --- /dev/null +++ b/packages/super-editor/src/core/opc/managed-parts-registry.js @@ -0,0 +1,37 @@ +/** + * Registry of OPC package-level singleton parts and their required metadata. + * + * Each entry defines a part that must have exactly one content-type Override in + * [Content_Types].xml and exactly one root Relationship in _rels/.rels when the + * part exists in the final package. When the part is absent, both registrations + * must be removed. + * + * Values sourced from ECMA-376 / ISO 29500 and verified against real DOCX + * fixtures produced by Microsoft Word. + */ + +/** @typedef {import('./sync-package-metadata.js').ManagedPartEntry} ManagedPartEntry */ + +/** @type {ManagedPartEntry[]} */ +export const MANAGED_PACKAGE_PARTS = [ + { + zipPath: 'word/document.xml', + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml', + relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', + }, + { + zipPath: 'docProps/core.xml', + contentType: 'application/vnd.openxmlformats-package.core-properties+xml', + relationshipType: 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties', + }, + { + zipPath: 'docProps/app.xml', + contentType: 'application/vnd.openxmlformats-officedocument.extended-properties+xml', + relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties', + }, + { + zipPath: 'docProps/custom.xml', + contentType: 'application/vnd.openxmlformats-officedocument.custom-properties+xml', + relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties', + }, +]; diff --git a/packages/super-editor/src/core/opc/sync-package-metadata.js b/packages/super-editor/src/core/opc/sync-package-metadata.js new file mode 100644 index 0000000000..ee2032f228 --- /dev/null +++ b/packages/super-editor/src/core/opc/sync-package-metadata.js @@ -0,0 +1,346 @@ +/** + * OPC package metadata synchronizer. + * + * Reconciles [Content_Types].xml and _rels/.rels with the final set of entries + * in a DOCX package. This is the single authority for package-level singleton + * registrations (content-type overrides and root relationships). + * + * Designed to run as the last metadata pass before zip serialization, after the + * legacy `updateContentTypes()` has already handled media, comments, footnotes, + * headers, and footers. + * + * @module opc/sync-package-metadata + */ + +import * as xmljs from 'xml-js'; +import { MANAGED_PACKAGE_PARTS } from './managed-parts-registry.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} ManagedPartEntry + * @property {string} zipPath - Path inside the zip (e.g. "docProps/custom.xml") + * @property {string} contentType - Required Override ContentType value + * @property {string} relationshipType - Required root Relationship Type URI + */ + +/** + * @typedef {Object} PackageEntrySource + * @property {Array<{name: string, content: string}>|Record|null} baseFiles + * Original package entries — either an array of {name, content} or a key→content map. + * @property {Record} updatedDocs + * Export-time overrides. A null value means the entry is deleted. + */ + +/** + * @typedef {Object} SyncResult + * @property {string} contentTypesXml - Reconciled [Content_Types].xml + * @property {string} relsXml - Reconciled _rels/.rels + */ + +// --------------------------------------------------------------------------- +// XML Namespaces +// --------------------------------------------------------------------------- + +const RELATIONSHIPS_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Read an entry from the layered package view. + * updatedDocs takes precedence — a null value means the entry was deleted. + */ +function readEntry(path, baseFiles, updatedDocs) { + if (updatedDocs && Object.prototype.hasOwnProperty.call(updatedDocs, path)) { + return updatedDocs[path]; // string or null (deleted) + } + if (!baseFiles) return undefined; + if (Array.isArray(baseFiles)) { + return baseFiles.find((f) => f.name === path)?.content; + } + return baseFiles[path]; +} + +/** + * Check whether a part will exist in the final package. + * A non-null, non-undefined value (including empty string) means "present". + */ +function partExistsInPackage(zipPath, baseFiles, updatedDocs) { + const content = readEntry(zipPath, baseFiles, updatedDocs); + return content != null; +} + +/** + * Parse an XML string into xml-js non-compact JS object. + * Returns null if parsing fails. + */ +function parseXml(xmlString) { + try { + return xmljs.xml2js(xmlString, { compact: false }); + } catch { + return null; + } +} + +/** + * Serialize an xml-js non-compact JS object back to an XML string. + */ +function serializeXml(jsObject) { + return xmljs.js2xml(jsObject, { spaces: 0 }); +} + +/** + * Find the first child element matching a given tag name, ignoring namespace prefixes. + */ +function findRootElement(parsed, tagName) { + return parsed?.elements?.find((el) => { + if (!el.name) return false; + const localName = el.name.includes(':') ? el.name.split(':').pop() : el.name; + return localName === tagName; + }); +} + +/** + * Build an empty _rels/.rels structure. + */ +function createEmptyRels() { + return { + declaration: { attributes: { version: '1.0', encoding: 'UTF-8', standalone: 'yes' } }, + elements: [ + { + type: 'element', + name: 'Relationships', + attributes: { xmlns: RELATIONSHIPS_NS }, + elements: [], + }, + ], + }; +} + +/** + * Find the highest rId number currently used in a Relationships element. + */ +function findMaxRId(relsRoot) { + let max = 0; + for (const el of relsRoot.elements || []) { + const id = el.attributes?.Id; + if (!id) continue; + const match = id.match(/^rId(\d+)$/); + if (match) max = Math.max(max, parseInt(match[1], 10)); + } + return max; +} + +// --------------------------------------------------------------------------- +// Content Types reconciliation +// --------------------------------------------------------------------------- + +/** + * Ensure correct Override entries exist for all managed parts that are present + * in the final package. Remove stale overrides for absent managed parts. + * Dedupe and correct wrong content types for managed entries. + */ +function reconcileContentTypes(typesRoot, presentParts) { + if (!typesRoot.elements) typesRoot.elements = []; + + const managedByPartName = new Map(); + for (const entry of MANAGED_PACKAGE_PARTS) { + managedByPartName.set(`/${entry.zipPath}`, entry); + } + + // Index existing Override elements for managed parts + const existingOverrides = new Map(); // partName → [element indices] + for (let i = 0; i < typesRoot.elements.length; i++) { + const el = typesRoot.elements[i]; + if (el.name !== 'Override') continue; + const partName = el.attributes?.PartName; + if (!partName || !managedByPartName.has(partName)) continue; + if (!existingOverrides.has(partName)) existingOverrides.set(partName, []); + existingOverrides.get(partName).push(i); + } + + // Collect indices to remove (stale or duplicate) + const indicesToRemove = new Set(); + + for (const [partName, entry] of managedByPartName) { + const isPresent = presentParts.has(entry.zipPath); + const indices = existingOverrides.get(partName) || []; + + if (!isPresent) { + // Part absent → remove all managed overrides for it + for (const idx of indices) indicesToRemove.add(idx); + continue; + } + + if (indices.length === 0) { + // Part present but no override → add one + typesRoot.elements.push({ + type: 'element', + name: 'Override', + attributes: { PartName: partName, ContentType: entry.contentType }, + }); + } else { + // Keep the first override and correct its content type; remove duplicates + typesRoot.elements[indices[0]].attributes.ContentType = entry.contentType; + for (let i = 1; i < indices.length; i++) indicesToRemove.add(indices[i]); + } + } + + // Remove marked elements in reverse index order to preserve positions + if (indicesToRemove.size > 0) { + const sorted = [...indicesToRemove].sort((a, b) => b - a); + for (const idx of sorted) typesRoot.elements.splice(idx, 1); + } +} + +// --------------------------------------------------------------------------- +// Root relationships reconciliation +// --------------------------------------------------------------------------- + +/** + * Ensure correct Relationship entries exist for all managed parts that are + * present in the final package. Remove stale relationships for absent managed + * parts. Dedupe and correct wrong targets/types for managed entries. + * Reuses existing rIds and allocates new ones only when needed. + */ +function reconcileRootRels(relsRoot, presentParts) { + if (!relsRoot.elements) relsRoot.elements = []; + + const managedByType = new Map(); + for (const entry of MANAGED_PACKAGE_PARTS) { + managedByType.set(entry.relationshipType, entry); + } + + // Index existing Relationship elements for managed types + const existingRels = new Map(); // relationshipType → [{ index, element }] + for (let i = 0; i < relsRoot.elements.length; i++) { + const el = relsRoot.elements[i]; + if (el.name !== 'Relationship') continue; + const type = el.attributes?.Type; + if (!type || !managedByType.has(type)) continue; + if (!existingRels.has(type)) existingRels.set(type, []); + existingRels.get(type).push({ index: i, element: el }); + } + + const indicesToRemove = new Set(); + let maxRId = findMaxRId(relsRoot); + + for (const [relType, entry] of managedByType) { + const isPresent = presentParts.has(entry.zipPath); + const existing = existingRels.get(relType) || []; + + if (!isPresent) { + // Part absent → remove all managed relationships for it + for (const { index } of existing) indicesToRemove.add(index); + continue; + } + + if (existing.length === 0) { + // Part present but no relationship → add one with next available rId + maxRId++; + relsRoot.elements.push({ + type: 'element', + name: 'Relationship', + attributes: { + Id: `rId${maxRId}`, + Type: relType, + Target: entry.zipPath, + }, + }); + } else { + // Keep the first relationship, correct its target; remove duplicates + existing[0].element.attributes.Target = entry.zipPath; + for (let i = 1; i < existing.length; i++) indicesToRemove.add(existing[i].index); + } + } + + // Remove marked elements in reverse index order + if (indicesToRemove.size > 0) { + const sorted = [...indicesToRemove].sort((a, b) => b - a); + for (const idx of sorted) relsRoot.elements.splice(idx, 1); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Synchronize OPC package metadata with the final set of output entries. + * + * Reads the current [Content_Types].xml and _rels/.rels, reconciles them + * against the managed parts registry, and returns corrected XML strings + * for both files. + * + * @param {PackageEntrySource} source - Layered package entry view + * @returns {SyncResult} + */ +export function syncPackageMetadata({ baseFiles, updatedDocs }) { + // Determine which managed parts will exist in the final package + const presentParts = new Set(); + for (const entry of MANAGED_PACKAGE_PARTS) { + if (partExistsInPackage(entry.zipPath, baseFiles, updatedDocs)) { + presentParts.add(entry.zipPath); + } + } + + // --- [Content_Types].xml --- + const rawContentTypes = readEntry('[Content_Types].xml', baseFiles, updatedDocs); + if (rawContentTypes == null) { + throw new Error( + 'syncPackageMetadata: [Content_Types].xml is missing from the package. ' + + 'Cannot safely reconcile package metadata without an existing content types file.', + ); + } + + const contentTypesParsed = parseXml(rawContentTypes); + if (!contentTypesParsed) { + throw new Error( + 'syncPackageMetadata: [Content_Types].xml could not be parsed as valid XML. ' + + 'Cannot safely reconcile package metadata from a malformed content types file.', + ); + } + + const typesRoot = findRootElement(contentTypesParsed, 'Types'); + if (!typesRoot) { + throw new Error('syncPackageMetadata: [Content_Types].xml does not contain a root element.'); + } + + reconcileContentTypes(typesRoot, presentParts); + + // --- _rels/.rels --- + const rawRels = readEntry('_rels/.rels', baseFiles, updatedDocs); + let relsParsed; + + if (rawRels == null) { + // Absent root rels: safe to synthesize since this file is purely metadata + relsParsed = createEmptyRels(); + } else { + relsParsed = parseXml(rawRels); + if (!relsParsed) { + throw new Error( + 'syncPackageMetadata: _rels/.rels could not be parsed as valid XML. ' + + 'Cannot safely reconcile root relationships from a malformed rels file.', + ); + } + } + + let relsRoot = findRootElement(relsParsed, 'Relationships'); + if (!relsRoot) { + // Parsed but missing root — replace the entire document + // with a clean structure rather than appending to malformed content. + relsParsed = createEmptyRels(); + relsRoot = relsParsed.elements[0]; + } + + reconcileRootRels(relsRoot, presentParts); + + return { + contentTypesXml: serializeXml(contentTypesParsed), + relsXml: serializeXml(relsParsed), + }; +} diff --git a/packages/super-editor/src/core/opc/sync-package-metadata.test.js b/packages/super-editor/src/core/opc/sync-package-metadata.test.js new file mode 100644 index 0000000000..56f33ca72d --- /dev/null +++ b/packages/super-editor/src/core/opc/sync-package-metadata.test.js @@ -0,0 +1,603 @@ +import { describe, it, expect } from 'vitest'; +import { syncPackageMetadata } from './sync-package-metadata.js'; +import { getOverrides, getRelationships } from './test-helpers.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a minimal [Content_Types].xml string with the given Override entries. */ +function buildContentTypesXml(overrides = []) { + const overrideElements = overrides + .map(({ partName, contentType }) => ``) + .join(''); + + return ( + '' + + '' + + '' + + '' + + overrideElements + + '' + ); +} + +/** Build a minimal _rels/.rels string with the given Relationship entries. */ +function buildRelsXml(relationships = []) { + const relElements = relationships + .map(({ id, type, target }) => ``) + .join(''); + + return ( + '' + + '' + + relElements + + '' + ); +} + +// --------------------------------------------------------------------------- +// Relationship type constants (must match managed-parts-registry.js) +// --------------------------------------------------------------------------- + +const REL_OFFICE_DOC = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument'; +const REL_CORE = 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties'; +const REL_EXTENDED = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties'; +const REL_CUSTOM = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties'; + +const CT_DOCUMENT = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml'; +const CT_CORE = 'application/vnd.openxmlformats-package.core-properties+xml'; +const CT_EXTENDED = 'application/vnd.openxmlformats-officedocument.extended-properties+xml'; +const CT_CUSTOM = 'application/vnd.openxmlformats-officedocument.custom-properties+xml'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** A typical _rels/.rels without custom-properties (like the real bug scenario). */ +const RELS_WITHOUT_CUSTOM = buildRelsXml([ + { id: 'rId1', type: REL_OFFICE_DOC, target: 'word/document.xml' }, + { id: 'rId2', type: REL_CORE, target: 'docProps/core.xml' }, + { id: 'rId3', type: REL_EXTENDED, target: 'docProps/app.xml' }, +]); + +/** Content types without custom-properties override (the real bug scenario). */ +const CT_WITHOUT_CUSTOM = buildContentTypesXml([ + { partName: '/word/document.xml', contentType: CT_DOCUMENT }, + { partName: '/docProps/core.xml', contentType: CT_CORE }, + { partName: '/docProps/app.xml', contentType: CT_EXTENDED }, +]); + +/** Full content types and rels with all four managed parts. */ +const RELS_COMPLETE = buildRelsXml([ + { id: 'rId1', type: REL_OFFICE_DOC, target: 'word/document.xml' }, + { id: 'rId2', type: REL_CORE, target: 'docProps/core.xml' }, + { id: 'rId3', type: REL_EXTENDED, target: 'docProps/app.xml' }, + { id: 'rId4', type: REL_CUSTOM, target: 'docProps/custom.xml' }, +]); + +const CT_COMPLETE = buildContentTypesXml([ + { partName: '/word/document.xml', contentType: CT_DOCUMENT }, + { partName: '/docProps/core.xml', contentType: CT_CORE }, + { partName: '/docProps/app.xml', contentType: CT_EXTENDED }, + { partName: '/docProps/custom.xml', contentType: CT_CUSTOM }, +]); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('syncPackageMetadata', () => { + describe('adding missing registrations', () => { + it('adds custom-properties override and root relationship when docProps/custom.xml exists', () => { + const { contentTypesXml, relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_WITHOUT_CUSTOM, + '_rels/.rels': RELS_WITHOUT_CUSTOM, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + }, + updatedDocs: { + 'docProps/custom.xml': + '1.0', + }, + }); + + const overrides = getOverrides(contentTypesXml); + const customOverride = overrides.find((o) => o.partName === '/docProps/custom.xml'); + expect(customOverride).toBeTruthy(); + expect(customOverride.contentType).toBe(CT_CUSTOM); + + const rels = getRelationships(relsXml); + const customRel = rels.find((r) => r.type === REL_CUSTOM); + expect(customRel).toBeTruthy(); + expect(customRel.target).toBe('docProps/custom.xml'); + }); + + it('adds registrations for docProps/app.xml when missing', () => { + const ctWithoutApp = buildContentTypesXml([ + { partName: '/word/document.xml', contentType: CT_DOCUMENT }, + { partName: '/docProps/core.xml', contentType: CT_CORE }, + ]); + const relsWithoutApp = buildRelsXml([ + { id: 'rId1', type: REL_OFFICE_DOC, target: 'word/document.xml' }, + { id: 'rId2', type: REL_CORE, target: 'docProps/core.xml' }, + ]); + + const { contentTypesXml, relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': ctWithoutApp, + '_rels/.rels': relsWithoutApp, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + }, + updatedDocs: {}, + }); + + const overrides = getOverrides(contentTypesXml); + expect(overrides.find((o) => o.partName === '/docProps/app.xml')).toBeTruthy(); + + const rels = getRelationships(relsXml); + expect(rels.find((r) => r.type === REL_EXTENDED)).toBeTruthy(); + }); + + it('handles all four managed parts through the same registry', () => { + const emptyCt = buildContentTypesXml([]); + const emptyRels = buildRelsXml([]); + + const { contentTypesXml, relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': emptyCt, + '_rels/.rels': emptyRels, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + 'docProps/custom.xml': '', + }, + updatedDocs: {}, + }); + + const overrides = getOverrides(contentTypesXml); + expect(overrides).toHaveLength(4); + expect(overrides.find((o) => o.partName === '/word/document.xml')).toBeTruthy(); + expect(overrides.find((o) => o.partName === '/docProps/core.xml')).toBeTruthy(); + expect(overrides.find((o) => o.partName === '/docProps/app.xml')).toBeTruthy(); + expect(overrides.find((o) => o.partName === '/docProps/custom.xml')).toBeTruthy(); + + const rels = getRelationships(relsXml); + expect(rels).toHaveLength(4); + expect(rels.find((r) => r.type === REL_OFFICE_DOC)).toBeTruthy(); + expect(rels.find((r) => r.type === REL_CORE)).toBeTruthy(); + expect(rels.find((r) => r.type === REL_EXTENDED)).toBeTruthy(); + expect(rels.find((r) => r.type === REL_CUSTOM)).toBeTruthy(); + }); + }); + + describe('removing stale registrations', () => { + it('removes custom-properties registrations when docProps/custom.xml is absent', () => { + const { contentTypesXml, relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_COMPLETE, + '_rels/.rels': RELS_COMPLETE, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + // no docProps/custom.xml + }, + updatedDocs: {}, + }); + + const overrides = getOverrides(contentTypesXml); + expect(overrides.find((o) => o.partName === '/docProps/custom.xml')).toBeUndefined(); + // Other overrides should remain + expect(overrides.find((o) => o.partName === '/word/document.xml')).toBeTruthy(); + + const rels = getRelationships(relsXml); + expect(rels.find((r) => r.type === REL_CUSTOM)).toBeUndefined(); + expect(rels.find((r) => r.type === REL_OFFICE_DOC)).toBeTruthy(); + }); + + it('removes registrations when updatedDocs explicitly deletes a part with null', () => { + const { contentTypesXml, relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_COMPLETE, + '_rels/.rels': RELS_COMPLETE, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + 'docProps/custom.xml': '', + }, + updatedDocs: { + 'docProps/custom.xml': null, // explicitly deleted + }, + }); + + const overrides = getOverrides(contentTypesXml); + expect(overrides.find((o) => o.partName === '/docProps/custom.xml')).toBeUndefined(); + + const rels = getRelationships(relsXml); + expect(rels.find((r) => r.type === REL_CUSTOM)).toBeUndefined(); + }); + }); + + describe('preserving unrelated entries', () => { + it('preserves unknown Override entries', () => { + const ctWithExtra = buildContentTypesXml([ + { partName: '/word/document.xml', contentType: CT_DOCUMENT }, + { + partName: '/word/numbering.xml', + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml', + }, + { + partName: '/word/styles.xml', + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml', + }, + ]); + + const { contentTypesXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': ctWithExtra, + '_rels/.rels': RELS_WITHOUT_CUSTOM, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + }, + updatedDocs: {}, + }); + + const overrides = getOverrides(contentTypesXml); + expect(overrides.find((o) => o.partName === '/word/numbering.xml')).toBeTruthy(); + expect(overrides.find((o) => o.partName === '/word/styles.xml')).toBeTruthy(); + }); + + it('preserves unknown Relationship entries', () => { + const relsWithUnknown = buildRelsXml([ + { id: 'rId1', type: REL_OFFICE_DOC, target: 'word/document.xml' }, + { id: 'rId2', type: REL_CORE, target: 'docProps/core.xml' }, + { id: 'rId3', type: REL_EXTENDED, target: 'docProps/app.xml' }, + { + id: 'rId5', + type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/something-custom', + target: 'custom/thing.xml', + }, + ]); + + const { relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_WITHOUT_CUSTOM, + '_rels/.rels': relsWithUnknown, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + }, + updatedDocs: {}, + }); + + const rels = getRelationships(relsXml); + const unknownRel = rels.find((r) => r.id === 'rId5'); + expect(unknownRel).toBeTruthy(); + expect(unknownRel.target).toBe('custom/thing.xml'); + }); + }); + + describe('deduplication and correction', () => { + it('deduplicates multiple Override entries for the same managed part', () => { + const ctWithDupes = buildContentTypesXml([ + { partName: '/word/document.xml', contentType: CT_DOCUMENT }, + { partName: '/docProps/custom.xml', contentType: CT_CUSTOM }, + { partName: '/docProps/custom.xml', contentType: CT_CUSTOM }, + { partName: '/docProps/custom.xml', contentType: 'wrong/type' }, + ]); + + const { contentTypesXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': ctWithDupes, + '_rels/.rels': RELS_COMPLETE, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + 'docProps/custom.xml': '', + }, + updatedDocs: {}, + }); + + const overrides = getOverrides(contentTypesXml); + const customOverrides = overrides.filter((o) => o.partName === '/docProps/custom.xml'); + expect(customOverrides).toHaveLength(1); + expect(customOverrides[0].contentType).toBe(CT_CUSTOM); + }); + + it('deduplicates multiple Relationship entries for the same managed type', () => { + const relsWithDupes = buildRelsXml([ + { id: 'rId1', type: REL_OFFICE_DOC, target: 'word/document.xml' }, + { id: 'rId2', type: REL_CUSTOM, target: 'docProps/custom.xml' }, + { id: 'rId3', type: REL_CUSTOM, target: 'docProps/custom.xml' }, + ]); + + const { relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_COMPLETE, + '_rels/.rels': relsWithDupes, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + 'docProps/custom.xml': '', + }, + updatedDocs: {}, + }); + + const rels = getRelationships(relsXml); + const customRels = rels.filter((r) => r.type === REL_CUSTOM); + expect(customRels).toHaveLength(1); + expect(customRels[0].id).toBe('rId2'); // reuses first existing rId + }); + + it('corrects wrong content type for a managed part', () => { + const ctWithWrongType = buildContentTypesXml([ + { partName: '/word/document.xml', contentType: CT_DOCUMENT }, + { partName: '/docProps/custom.xml', contentType: 'application/wrong-type' }, + ]); + + const { contentTypesXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': ctWithWrongType, + '_rels/.rels': RELS_COMPLETE, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + 'docProps/custom.xml': '', + }, + updatedDocs: {}, + }); + + const overrides = getOverrides(contentTypesXml); + const custom = overrides.find((o) => o.partName === '/docProps/custom.xml'); + expect(custom.contentType).toBe(CT_CUSTOM); + }); + + it('corrects wrong target for a managed relationship', () => { + const relsWithWrongTarget = buildRelsXml([ + { id: 'rId1', type: REL_OFFICE_DOC, target: 'word/document.xml' }, + { id: 'rId4', type: REL_CUSTOM, target: 'wrong/path.xml' }, + ]); + + const { relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_COMPLETE, + '_rels/.rels': relsWithWrongTarget, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + 'docProps/custom.xml': '', + }, + updatedDocs: {}, + }); + + const rels = getRelationships(relsXml); + const custom = rels.find((r) => r.type === REL_CUSTOM); + expect(custom.target).toBe('docProps/custom.xml'); + expect(custom.id).toBe('rId4'); // reuses existing rId + }); + }); + + describe('rId allocation', () => { + it('reuses existing rIds when possible', () => { + const { relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_COMPLETE, + '_rels/.rels': RELS_COMPLETE, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + 'docProps/custom.xml': '', + }, + updatedDocs: {}, + }); + + const rels = getRelationships(relsXml); + expect(rels.find((r) => r.type === REL_OFFICE_DOC).id).toBe('rId1'); + expect(rels.find((r) => r.type === REL_CORE).id).toBe('rId2'); + expect(rels.find((r) => r.type === REL_EXTENDED).id).toBe('rId3'); + expect(rels.find((r) => r.type === REL_CUSTOM).id).toBe('rId4'); + }); + + it('allocates next rId when adding a new relationship', () => { + const { relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_WITHOUT_CUSTOM, + '_rels/.rels': RELS_WITHOUT_CUSTOM, // rId1..rId3 exist + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + }, + updatedDocs: { + 'docProps/custom.xml': '', + }, + }); + + const rels = getRelationships(relsXml); + const custom = rels.find((r) => r.type === REL_CUSTOM); + expect(custom.id).toBe('rId4'); + }); + + it('handles gaps in rId numbering correctly', () => { + const relsWithGap = buildRelsXml([ + { id: 'rId1', type: REL_OFFICE_DOC, target: 'word/document.xml' }, + { id: 'rId5', type: REL_CORE, target: 'docProps/core.xml' }, + { id: 'rId10', type: REL_EXTENDED, target: 'docProps/app.xml' }, + ]); + + const { relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_WITHOUT_CUSTOM, + '_rels/.rels': relsWithGap, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + }, + updatedDocs: { + 'docProps/custom.xml': '', + }, + }); + + const rels = getRelationships(relsXml); + const custom = rels.find((r) => r.type === REL_CUSTOM); + expect(custom.id).toBe('rId11'); // max existing is rId10 + }); + }); + + describe('_rels/.rels synthesis', () => { + it('creates _rels/.rels when absent', () => { + const { relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_WITHOUT_CUSTOM, + 'word/document.xml': '', + 'docProps/core.xml': '', + // no _rels/.rels at all + }, + updatedDocs: {}, + }); + + const rels = getRelationships(relsXml); + expect(rels.find((r) => r.type === REL_OFFICE_DOC)).toBeTruthy(); + expect(rels.find((r) => r.type === REL_CORE)).toBeTruthy(); + }); + }); + + describe('idempotency', () => { + it('produces identical output when run twice', () => { + const input = { + baseFiles: { + '[Content_Types].xml': CT_WITHOUT_CUSTOM, + '_rels/.rels': RELS_WITHOUT_CUSTOM, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + }, + updatedDocs: { + 'docProps/custom.xml': '', + }, + }; + + const first = syncPackageMetadata(input); + + // Run again with the first pass output as base + const second = syncPackageMetadata({ + baseFiles: { + ...input.baseFiles, + '[Content_Types].xml': first.contentTypesXml, + '_rels/.rels': first.relsXml, + }, + updatedDocs: input.updatedDocs, + }); + + expect(second.contentTypesXml).toBe(first.contentTypesXml); + expect(second.relsXml).toBe(first.relsXml); + }); + + it('is idempotent when all registrations already exist', () => { + const input = { + baseFiles: { + '[Content_Types].xml': CT_COMPLETE, + '_rels/.rels': RELS_COMPLETE, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + 'docProps/custom.xml': '', + }, + updatedDocs: {}, + }; + + const first = syncPackageMetadata(input); + const second = syncPackageMetadata({ + ...input, + baseFiles: { + ...input.baseFiles, + '[Content_Types].xml': first.contentTypesXml, + '_rels/.rels': first.relsXml, + }, + }); + + expect(second.contentTypesXml).toBe(first.contentTypesXml); + expect(second.relsXml).toBe(first.relsXml); + }); + }); + + describe('error handling', () => { + it('throws when [Content_Types].xml is missing', () => { + expect(() => + syncPackageMetadata({ + baseFiles: { '_rels/.rels': RELS_WITHOUT_CUSTOM }, + updatedDocs: {}, + }), + ).toThrow('[Content_Types].xml is missing'); + }); + + it('throws when [Content_Types].xml is malformed', () => { + expect(() => + syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': 'not xml at all {{{', + '_rels/.rels': RELS_WITHOUT_CUSTOM, + }, + updatedDocs: {}, + }), + ).toThrow('[Content_Types].xml could not be parsed'); + }); + + it('throws when _rels/.rels is malformed', () => { + expect(() => + syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_WITHOUT_CUSTOM, + '_rels/.rels': 'not xml {{{', + }, + updatedDocs: {}, + }), + ).toThrow('_rels/.rels could not be parsed'); + }); + + it('replaces _rels/.rels that parses but has no root', () => { + const { relsXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_WITHOUT_CUSTOM, + '_rels/.rels': '', + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + }, + updatedDocs: {}, + }); + + // The malformed must be gone — only a valid root should remain + expect(relsXml).not.toContain(' r.type === REL_OFFICE_DOC)).toBeTruthy(); + expect(rels.find((r) => r.type === REL_CORE)).toBeTruthy(); + expect(rels.find((r) => r.type === REL_EXTENDED)).toBeTruthy(); + }); + }); + + describe('Default elements are preserved', () => { + it('preserves Default extension entries in [Content_Types].xml', () => { + const { contentTypesXml } = syncPackageMetadata({ + baseFiles: { + '[Content_Types].xml': CT_WITHOUT_CUSTOM, + '_rels/.rels': RELS_WITHOUT_CUSTOM, + 'word/document.xml': '', + 'docProps/core.xml': '', + 'docProps/app.xml': '', + }, + updatedDocs: {}, + }); + + // The Default elements should still be present + expect(contentTypesXml).toContain('Extension="rels"'); + expect(contentTypesXml).toContain('Extension="xml"'); + }); + }); +}); diff --git a/packages/super-editor/src/core/opc/test-helpers.js b/packages/super-editor/src/core/opc/test-helpers.js new file mode 100644 index 0000000000..3458c808c4 --- /dev/null +++ b/packages/super-editor/src/core/opc/test-helpers.js @@ -0,0 +1,33 @@ +/** + * Shared test helpers for OPC package metadata tests. + * + * These parse XML strings and extract Override / Relationship entries + * for assertions. Used by both unit tests and integration tests. + */ + +import * as xmljs from 'xml-js'; + +/** Parse Override elements from a [Content_Types].xml string. */ +export function getOverrides(xmlString) { + const parsed = xmljs.xml2js(xmlString, { compact: false }); + const types = parsed.elements?.find((el) => el.name === 'Types'); + return (types?.elements || []) + .filter((el) => el.name === 'Override') + .map((el) => ({ + partName: el.attributes.PartName, + contentType: el.attributes.ContentType, + })); +} + +/** Parse Relationship elements from a _rels/.rels string. */ +export function getRelationships(xmlString) { + const parsed = xmljs.xml2js(xmlString, { compact: false }); + const rels = parsed.elements?.find((el) => el.name === 'Relationships'); + return (rels?.elements || []) + .filter((el) => el.name === 'Relationship') + .map((el) => ({ + id: el.attributes.Id, + type: el.attributes.Type, + target: el.attributes.Target, + })); +} diff --git a/packages/super-editor/src/tests/regression/opc-package-metadata-roundtrip.test.js b/packages/super-editor/src/tests/regression/opc-package-metadata-roundtrip.test.js new file mode 100644 index 0000000000..82432a2a74 --- /dev/null +++ b/packages/super-editor/src/tests/regression/opc-package-metadata-roundtrip.test.js @@ -0,0 +1,125 @@ +/** + * Regression test for OPC package metadata synchronization. + * + * Covers the case where a source document has no docProps/custom.xml but + * SuperConverter creates one during export (to store SuperdocVersion and + * DocumentGuid). Before the fix, [Content_Types].xml would be missing the + * custom-properties Override and _rels/.rels would be missing the + * custom-properties Relationship, producing a corrupt package. + */ + +import { describe, it, expect } from 'vitest'; +import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { getOverrides, getRelationships } from '@core/opc/test-helpers.js'; + +const TEST_DOC = 'blank-doc.docx'; + +const CT_CUSTOM = 'application/vnd.openxmlformats-officedocument.custom-properties+xml'; +const REL_CUSTOM = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties'; + +describe('OPC package metadata: custom-properties registration', () => { + it('getUpdatedDocs includes correct [Content_Types].xml and _rels/.rels for new custom.xml', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC); + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + + try { + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); + + // The export should create docProps/custom.xml (SuperdocVersion) + expect(updatedDocs['docProps/custom.xml']).toBeTruthy(); + + // [Content_Types].xml must include the custom-properties Override + expect(updatedDocs['[Content_Types].xml']).toBeTruthy(); + const overrides = getOverrides(updatedDocs['[Content_Types].xml']); + const customOverride = overrides.find((o) => o.partName === '/docProps/custom.xml'); + expect(customOverride).toBeTruthy(); + expect(customOverride.contentType).toBe(CT_CUSTOM); + + // _rels/.rels must include the custom-properties Relationship + expect(updatedDocs['_rels/.rels']).toBeTruthy(); + const rels = getRelationships(updatedDocs['_rels/.rels']); + const customRel = rels.find((r) => r.type === REL_CUSTOM); + expect(customRel).toBeTruthy(); + expect(customRel.target).toBe('docProps/custom.xml'); + } finally { + editor.destroy(); + } + }); + + it('zipped export includes valid package metadata for new custom.xml', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC); + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + + try { + const exportedBuffer = await editor.exportDocx({ compression: 'STORE' }); + const nodeBuffer = + exportedBuffer instanceof ArrayBuffer + ? Buffer.from(new Uint8Array(exportedBuffer)) + : Buffer.from(exportedBuffer); + + const zipper = new DocxZipper(); + const entries = await zipper.getDocxData(nodeBuffer, true); + + // docProps/custom.xml should exist + const customEntry = entries.find((e) => e.name === 'docProps/custom.xml'); + expect(customEntry).toBeTruthy(); + + // [Content_Types].xml must include the Override + const ctEntry = entries.find((e) => e.name === '[Content_Types].xml'); + expect(ctEntry).toBeTruthy(); + const overrides = getOverrides(ctEntry.content); + const customOverride = overrides.find((o) => o.partName === '/docProps/custom.xml'); + expect(customOverride).toBeTruthy(); + expect(customOverride.contentType).toBe(CT_CUSTOM); + + // _rels/.rels must include the Relationship + const relsEntry = entries.find((e) => e.name === '_rels/.rels'); + expect(relsEntry).toBeTruthy(); + const rels = getRelationships(relsEntry.content); + const customRel = rels.find((r) => r.type === REL_CUSTOM); + expect(customRel).toBeTruthy(); + expect(customRel.target).toBe('docProps/custom.xml'); + } finally { + editor.destroy(); + } + }); + + it('preserves existing managed registrations without duplication', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(TEST_DOC); + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + + try { + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); + + // The original blank-doc has overrides for word/document.xml, docProps/core.xml, docProps/app.xml. + // After export, each of those should still appear exactly once. + const overrides = getOverrides(updatedDocs['[Content_Types].xml']); + const docOverrides = overrides.filter((o) => o.partName === '/word/document.xml'); + const coreOverrides = overrides.filter((o) => o.partName === '/docProps/core.xml'); + const appOverrides = overrides.filter((o) => o.partName === '/docProps/app.xml'); + + expect(docOverrides).toHaveLength(1); + expect(coreOverrides).toHaveLength(1); + expect(appOverrides).toHaveLength(1); + + // Same for _rels/.rels relationships + const rels = getRelationships(updatedDocs['_rels/.rels']); + const officeDocRels = rels.filter( + (r) => r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument', + ); + const coreRels = rels.filter( + (r) => r.type === 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties', + ); + const appRels = rels.filter( + (r) => r.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties', + ); + + expect(officeDocRels).toHaveLength(1); + expect(coreRels).toHaveLength(1); + expect(appRels).toHaveLength(1); + } finally { + editor.destroy(); + } + }); +});