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('