diff --git a/packages/super-editor/src/core/DocxZipper.js b/packages/super-editor/src/core/DocxZipper.js index 29b17d10cb..17fffcc659 100644 --- a/packages/super-editor/src/core/DocxZipper.js +++ b/packages/super-editor/src/core/DocxZipper.js @@ -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']); @@ -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 += ``; + } + } + const partNames = new Set(additionalPartNames); if (docx?.files) { if (fromJson && Array.isArray(docx.files)) { @@ -331,6 +339,20 @@ class DocxZipper { updatedContentTypesXml = updatedContentTypesXml.replace('', `${extendedDef}`); }); + // 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); diff --git a/packages/super-editor/src/core/opc/reconcile-document-relationships.js b/packages/super-editor/src/core/opc/reconcile-document-relationships.js new file mode 100644 index 0000000000..2d99f7b631 --- /dev/null +++ b/packages/super-editor/src/core/opc/reconcile-document-relationships.js @@ -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 }); +} diff --git a/packages/super-editor/src/core/opc/reconcile-document-relationships.test.js b/packages/super-editor/src/core/opc/reconcile-document-relationships.test.js new file mode 100644 index 0000000000..2e66c99feb --- /dev/null +++ b/packages/super-editor/src/core/opc/reconcile-document-relationships.test.js @@ -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 }) => ``) + .join(''); + + return ( + '' + + '' + + relElements + + '' + ); +} + +// --------------------------------------------------------------------------- +// 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 = ''; + expect(reconcileDocumentRelationships(noRoot, () => true)).toBe(noRoot); + }); + }); +}); diff --git a/tests/doc-api-stories/tests/lists/numbering-metadata-regression.ts b/tests/doc-api-stories/tests/lists/numbering-metadata-regression.ts new file mode 100644 index 0000000000..b720b19f44 --- /dev/null +++ b/tests/doc-api-stories/tests/lists/numbering-metadata-regression.ts @@ -0,0 +1,184 @@ +import { execFile } from 'node:child_process'; +import { copyFile, mkdir, rm, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); + +const REPO_ROOT = path.resolve(import.meta.dirname, '../../../..'); +const STORIES_ROOT = path.resolve(import.meta.dirname, '../..'); +const CLI_SRC_BIN = path.join(REPO_ROOT, 'apps/cli/src/index.ts'); +const BASIC_PARAGRAPH_FIXTURE = path.join(REPO_ROOT, 'packages/super-editor/src/tests/data/basic-paragraph.docx'); + +const NUMBERING_PART = 'word/numbering.xml'; +const CONTENT_TYPES_PART = '[Content_Types].xml'; +const DOCUMENT_RELS_PART = 'word/_rels/document.xml.rels'; +const NUMBERING_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml'; +const NUMBERING_REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering'; + +function unwrap(payload: any): T { + return payload?.result ?? payload?.undefined ?? payload; +} + +function parseJsonEnvelope(stdout: string, stderr: string): any { + const sources = [stdout.trim(), stderr.trim()].filter((source) => source.length > 0); + if (sources.length === 0) { + throw new Error('No CLI JSON envelope output found.'); + } + + for (const source of sources) { + try { + return JSON.parse(source); + } catch { + const lines = source.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const candidate = lines.slice(index).join('\n').trim(); + if (!candidate.startsWith('{')) continue; + try { + return JSON.parse(candidate); + } catch { + // continue scanning + } + } + } + } + + throw new Error(`Failed to parse CLI JSON envelope:\n${sources.join('\n')}`); +} + +async function runCli(resultsDir: string, args: string[], options?: { allowError?: boolean }): Promise { + const stateDir = path.join(resultsDir, '.superdoc-cli-state'); + const executed = await execFileAsync('bun', [CLI_SRC_BIN, ...args, '--output', 'json'], { + cwd: REPO_ROOT, + env: { + ...process.env, + SUPERDOC_CLI_STATE_DIR: stateDir, + }, + }).catch((error) => error as { stdout?: string; stderr?: string }); + + const envelope = parseJsonEnvelope(executed.stdout ?? '', executed.stderr ?? ''); + if (envelope?.ok === false && options?.allowError !== true) { + const code = envelope.error?.code ?? 'UNKNOWN'; + const message = envelope.error?.message ?? 'Unknown CLI error'; + throw new Error(`${code}: ${message}`); + } + return envelope; +} + +async function readZipEntry(docPath: string, zipPath: string): Promise { + const JSZipModule = await import('../../../../packages/superdoc/node_modules/jszip'); + const JSZip = JSZipModule.default; + const buffer = await readFile(docPath); + const zip = await JSZip.loadAsync(buffer); + const file = zip.file(zipPath); + return file ? file.async('string') : null; +} + +async function requireZipEntry(docPath: string, zipPath: string): Promise { + const content = await readZipEntry(docPath, zipPath); + if (content == null) { + throw new Error(`Missing zip entry "${zipPath}" in ${docPath}`); + } + return content; +} + +async function callDocOperation( + resultsDir: string, + operationId: string, + input: Record, +): Promise { + const normalizedInput = { ...input }; + if (typeof normalizedInput.out === 'string' && normalizedInput.out.length > 0 && normalizedInput.force == null) { + normalizedInput.force = true; + } + + const envelope = await runCli(resultsDir, [ + 'call', + `doc.${operationId}`, + '--input-json', + JSON.stringify(normalizedInput), + ]); + return unwrap(unwrap(envelope?.data)); +} + +async function discoverParagraph( + resultsDir: string, + docPath: string, +): Promise<{ kind: 'block'; nodeType: 'paragraph'; nodeId: string }> { + const matchResult = await callDocOperation(resultsDir, 'query.match', { + doc: docPath, + select: { type: 'node', nodeType: 'paragraph' }, + require: 'first', + }); + + const paragraph = matchResult?.items?.[0]; + const nodeId = paragraph?.address?.nodeId; + + if (typeof nodeId !== 'string' || nodeId.length === 0) { + throw new Error(`No paragraph address found in ${docPath}`); + } + + return { + kind: 'block', + nodeType: 'paragraph', + nodeId, + }; +} + +function countMatches(source: string, pattern: RegExp): number { + return source.match(pattern)?.length ?? 0; +} + +describe('document-api story: lists.create numbering metadata regression', () => { + it('registers numbering metadata when bullet list creation adds numbering to a numbering-less source docx', async () => { + const resultsDir = path.join(STORIES_ROOT, 'results', 'lists', 'numbering-metadata-regression'); + await rm(resultsDir, { recursive: true, force: true }); + await mkdir(resultsDir, { recursive: true }); + + const sourceDoc = path.join(resultsDir, 'basic-paragraph-source.docx'); + const resultDoc = path.join(resultsDir, 'basic-paragraph-bullet-list.docx'); + await copyFile(BASIC_PARAGRAPH_FIXTURE, sourceDoc); + + const sourceContentTypes = await requireZipEntry(sourceDoc, CONTENT_TYPES_PART); + const sourceDocumentRels = await requireZipEntry(sourceDoc, DOCUMENT_RELS_PART); + const sourceNumbering = await readZipEntry(sourceDoc, NUMBERING_PART); + + // This fixture only guards the regression if it starts without numbering metadata. + expect(sourceNumbering).toBeNull(); + expect(sourceContentTypes).not.toContain('/word/numbering.xml'); + expect(sourceContentTypes).not.toContain(NUMBERING_CONTENT_TYPE); + expect(sourceDocumentRels).not.toContain(NUMBERING_REL_TYPE); + expect(sourceDocumentRels).not.toContain('Target="numbering.xml"'); + + const paragraphAddress = await discoverParagraph(resultsDir, sourceDoc); + const createResult = await callDocOperation(resultsDir, 'lists.create', { + doc: sourceDoc, + out: resultDoc, + mode: 'fromParagraphs', + target: paragraphAddress, + kind: 'bullet', + }); + + expect(createResult?.success).toBe(true); + + const listResult = await callDocOperation(resultsDir, 'lists.list', { doc: resultDoc }); + expect(listResult?.total).toBe(1); + expect(listResult?.items?.[0]?.kind).toBe('bullet'); + + const resultNumbering = await requireZipEntry(resultDoc, NUMBERING_PART); + const resultContentTypes = await requireZipEntry(resultDoc, CONTENT_TYPES_PART); + const resultDocumentRels = await requireZipEntry(resultDoc, DOCUMENT_RELS_PART); + + expect(resultNumbering).toContain('