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
22 changes: 22 additions & 0 deletions packages/super-editor/src/core/DocxZipper.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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';
import { reconcileDocumentRelationships, MANAGED_DOCUMENT_PARTS } from './opc/reconcile-document-relationships.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']);
Expand Down Expand Up @@ -239,6 +240,13 @@ class DocxZipper {
if (!hasFootnotes) typesString += footnotesDef;
}

// Update for managed document-level singleton parts (e.g., numbering)
for (const entry of MANAGED_DOCUMENT_PARTS) {
if (hasFile(entry.zipPath) && !hasPartOverride(`/${entry.zipPath}`)) {
typesString += `<Override PartName="/${entry.zipPath}" ContentType="${entry.contentType}" />`;
}
}

const partNames = new Set(additionalPartNames);
if (docx?.files) {
if (fromJson && Array.isArray(docx.files)) {
Expand Down Expand Up @@ -331,6 +339,20 @@ class DocxZipper {
updatedContentTypesXml = updatedContentTypesXml.replace('</Types>', `${extendedDef}</Types>`);
});

// Reconcile document-level singleton relationships (e.g., numbering).
// Parts auto-created at runtime (via mutatePart/ensurePart) may exist in
// the package without a corresponding word/_rels/document.xml.rels entry.
if (relationshipsXml) {
const reconciledRels = reconcileDocumentRelationships(relationshipsXml, hasFile);
if (reconciledRels !== relationshipsXml) {
if (fromJson) {
updatedDocs['word/_rels/document.xml.rels'] = reconciledRels;
} else {
docx.file('word/_rels/document.xml.rels', reconciledRels);
}
}
}

if (fromJson) return updatedContentTypesXml;

docx.file(contentTypesPath, updatedContentTypesXml);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Reconcile document-level singleton relationships.
*
* Document-level singletons (e.g., word/numbering.xml) need a Relationship
* entry in word/_rels/document.xml.rels when present in the package. Parts
* auto-created at runtime (via mutatePart/ensurePart) may not have one.
*
* This module defines which parts require a document relationship and provides
* a reconciliation function that adds missing entries with collision-free rId
* allocation.
*
* Analogous to managed-parts-registry.js + sync-package-metadata.js, but for
* document-level (word/_rels/document.xml.rels) rather than package-level
* (_rels/.rels) relationships.
*
* @module opc/reconcile-document-relationships
*/

import * as xmljs from 'xml-js';

// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------

/**
* @typedef {Object} ManagedDocumentPartEntry
* @property {string} zipPath - Path inside the zip (e.g. "word/numbering.xml")
* @property {string} contentType - Required Override ContentType value
* @property {string} relationshipType - Required document Relationship Type URI
* @property {string} relTarget - Relationship Target (relative to word/)
*/

/**
* Document-level singleton parts that require both a content-type Override
* in [Content_Types].xml and a Relationship in word/_rels/document.xml.rels
* when present in the final package.
*
* Values sourced from ECMA-376 / ISO 29500.
*
* @type {ManagedDocumentPartEntry[]}
*/
export const MANAGED_DOCUMENT_PARTS = [
{
zipPath: 'word/numbering.xml',
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml',
relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering',
relTarget: 'numbering.xml',
},
];

// ---------------------------------------------------------------------------
// Reconciliation
// ---------------------------------------------------------------------------

/**
* Find the highest rId number in a Relationships element.
*/
function findMaxRId(elements) {
let max = 0;
for (const el of elements) {
const match = el.attributes?.Id?.match(/^rId(\d+)$/);
if (match) max = Math.max(max, Number(match[1]));
}
return max;
}

/**
* Ensure document-level relationships exist for all managed parts that are
* present in the package.
*
* Adds missing relationships with collision-free rId allocation. Does not
* remove or modify existing relationships.
*
* @param {string} relsXml - Current word/_rels/document.xml.rels XML string
* @param {(zipPath: string) => boolean} fileExists - Predicate: does this file exist in the package?
* @returns {string} Reconciled rels XML (reference-identical if no changes needed)
*/
export function reconcileDocumentRelationships(relsXml, fileExists) {
if (!relsXml) return relsXml;

let parsed;
try {
parsed = xmljs.xml2js(relsXml, { compact: false });
} catch {
return relsXml;
}

const relsTag = parsed?.elements?.find((el) => el.name === 'Relationships');
if (!relsTag) return relsXml;
if (!relsTag.elements) relsTag.elements = [];

let changed = false;
let maxId = findMaxRId(relsTag.elements);

for (const entry of MANAGED_DOCUMENT_PARTS) {
if (!fileExists(entry.zipPath)) continue;

const alreadyRegistered = relsTag.elements.some((el) => el.attributes?.Type === entry.relationshipType);
if (alreadyRegistered) continue;

maxId++;
relsTag.elements.push({
type: 'element',
name: 'Relationship',
attributes: {
Id: `rId${maxId}`,
Type: entry.relationshipType,
Target: entry.relTarget,
},
});
changed = true;
}

if (!changed) return relsXml;
return xmljs.js2xml(parsed, { spaces: 0 });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest';
import { reconcileDocumentRelationships, MANAGED_DOCUMENT_PARTS } from './reconcile-document-relationships.js';
import { getRelationships } from './test-helpers.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function buildDocRelsXml(relationships = []) {
const relElements = relationships
.map(({ id, type, target }) => `<Relationship Id="${id}" Type="${type}" Target="${target}"/>`)
.join('');

return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
relElements +
'</Relationships>'
);
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const REL_NUMBERING = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering';
const REL_IMAGE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image';
const REL_HYPERLINK = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink';

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('MANAGED_DOCUMENT_PARTS registry', () => {
it('contains numbering as a managed document part', () => {
const numbering = MANAGED_DOCUMENT_PARTS.find((p) => p.zipPath === 'word/numbering.xml');
expect(numbering).toBeTruthy();
expect(numbering.relationshipType).toBe(REL_NUMBERING);
expect(numbering.relTarget).toBe('numbering.xml');
});
});

describe('reconcileDocumentRelationships', () => {
describe('adding missing relationships', () => {
it('adds numbering relationship when the part exists but the relationship is missing', () => {
const relsXml = buildDocRelsXml([{ id: 'rId1', type: REL_IMAGE, target: 'media/image1.png' }]);

const result = reconcileDocumentRelationships(relsXml, (path) => path === 'word/numbering.xml');

const rels = getRelationships(result);
const numbering = rels.find((r) => r.type === REL_NUMBERING);
expect(numbering).toBeTruthy();
expect(numbering.target).toBe('numbering.xml');
expect(numbering.id).toBe('rId2');
});

it('adds relationship to empty rels document', () => {
const relsXml = buildDocRelsXml([]);

const result = reconcileDocumentRelationships(relsXml, (path) => path === 'word/numbering.xml');

const rels = getRelationships(result);
expect(rels).toHaveLength(1);
expect(rels[0].type).toBe(REL_NUMBERING);
expect(rels[0].id).toBe('rId1');
});
});

describe('skipping existing relationships', () => {
it('does not duplicate numbering relationship when it already exists', () => {
const relsXml = buildDocRelsXml([{ id: 'rId1', type: REL_NUMBERING, target: 'numbering.xml' }]);

const result = reconcileDocumentRelationships(relsXml, () => true);

const rels = getRelationships(result);
const numberingRels = rels.filter((r) => r.type === REL_NUMBERING);
expect(numberingRels).toHaveLength(1);
});

it('returns input unchanged (reference-identical) when no reconciliation needed', () => {
const relsXml = buildDocRelsXml([{ id: 'rId1', type: REL_NUMBERING, target: 'numbering.xml' }]);

const result = reconcileDocumentRelationships(relsXml, () => true);
expect(result).toBe(relsXml);
});
});

describe('skipping absent parts', () => {
it('does not add relationship when the part does not exist', () => {
const relsXml = buildDocRelsXml([{ id: 'rId1', type: REL_IMAGE, target: 'media/image1.png' }]);

const result = reconcileDocumentRelationships(relsXml, () => false);

const rels = getRelationships(result);
expect(rels.find((r) => r.type === REL_NUMBERING)).toBeUndefined();
});

it('returns input unchanged (reference-identical) when part is absent', () => {
const relsXml = buildDocRelsXml([{ id: 'rId1', type: REL_IMAGE, target: 'media/image1.png' }]);

const result = reconcileDocumentRelationships(relsXml, () => false);
expect(result).toBe(relsXml);
});
});

describe('rId allocation', () => {
it('allocates rId after the highest existing rId', () => {
const relsXml = buildDocRelsXml([
{ id: 'rId1', type: REL_IMAGE, target: 'media/image1.png' },
{ id: 'rId10', type: REL_HYPERLINK, target: 'http://example.com' },
]);

const result = reconcileDocumentRelationships(relsXml, (path) => path === 'word/numbering.xml');

const rels = getRelationships(result);
const numbering = rels.find((r) => r.type === REL_NUMBERING);
expect(numbering.id).toBe('rId11');
});
});

describe('preserving existing relationships', () => {
it('preserves all pre-existing relationships', () => {
const relsXml = buildDocRelsXml([
{ id: 'rId1', type: REL_IMAGE, target: 'media/image1.png' },
{ id: 'rId2', type: REL_HYPERLINK, target: 'http://example.com' },
]);

const result = reconcileDocumentRelationships(relsXml, (path) => path === 'word/numbering.xml');

const rels = getRelationships(result);
expect(rels.find((r) => r.id === 'rId1')).toBeTruthy();
expect(rels.find((r) => r.id === 'rId2')).toBeTruthy();
expect(rels).toHaveLength(3);
});
});

describe('idempotency', () => {
it('produces identical output when run twice', () => {
const relsXml = buildDocRelsXml([{ id: 'rId1', type: REL_IMAGE, target: 'media/image1.png' }]);

const fileExists = (path) => path === 'word/numbering.xml';
const first = reconcileDocumentRelationships(relsXml, fileExists);
const second = reconcileDocumentRelationships(first, fileExists);

expect(second).toBe(first);
});
});

describe('error handling', () => {
it('returns null input unchanged', () => {
expect(reconcileDocumentRelationships(null, () => true)).toBeNull();
});

it('returns undefined input unchanged', () => {
expect(reconcileDocumentRelationships(undefined, () => true)).toBeUndefined();
});

it('returns malformed XML unchanged', () => {
const bad = 'not xml {{{';
expect(reconcileDocumentRelationships(bad, () => true)).toBe(bad);
});

it('returns XML without Relationships root unchanged', () => {
const noRoot = '<?xml version="1.0"?><foo/>';
expect(reconcileDocumentRelationships(noRoot, () => true)).toBe(noRoot);
});
});
});
Loading
Loading