From 8261c7c054aaac2d66b09bc5a0e4798e1a26e3b1 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Mar 2026 10:52:58 -0700 Subject: [PATCH] fix: declare w15 namespace when bootstrapping numbering.xml --- .../numbering-part-descriptor.test.ts | 16 +++++++++++- .../adapters/numbering-part-descriptor.ts | 16 ++++++++++-- .../lists/numbering-metadata-regression.ts | 25 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/core/parts/adapters/numbering-part-descriptor.test.ts b/packages/super-editor/src/core/parts/adapters/numbering-part-descriptor.test.ts index 9586e6eaeb..0c96028ea6 100644 --- a/packages/super-editor/src/core/parts/adapters/numbering-part-descriptor.test.ts +++ b/packages/super-editor/src/core/parts/adapters/numbering-part-descriptor.test.ts @@ -1,5 +1,19 @@ import { describe, it, expect } from 'vitest'; -import { syncNumberingToXmlTree } from './numbering-part-descriptor.js'; +import { numberingPartDescriptor, syncNumberingToXmlTree } from './numbering-part-descriptor.js'; + +describe('numberingPartDescriptor.ensurePart', () => { + it('declares xmlns:w15 so w15:* attributes in list definitions are namespace-valid (SD-2252)', () => { + const part = numberingPartDescriptor.ensurePart() as { + elements: Array<{ attributes: Record }>; + }; + const root = part.elements[0]; + + expect(root.attributes['xmlns:w']).toBe('http://schemas.openxmlformats.org/wordprocessingml/2006/main'); + expect(root.attributes['xmlns:w15']).toBe('http://schemas.microsoft.com/office/word/2012/wordml'); + expect(root.attributes['xmlns:mc']).toBe('http://schemas.openxmlformats.org/markup-compatibility/2006'); + expect(root.attributes['mc:Ignorable']).toContain('w15'); + }); +}); describe('syncNumberingToXmlTree', () => { it('preserves non-abstract/definition children like w:numPicBullet', () => { diff --git a/packages/super-editor/src/core/parts/adapters/numbering-part-descriptor.ts b/packages/super-editor/src/core/parts/adapters/numbering-part-descriptor.ts index 1c9da194ca..0cec74ad16 100644 --- a/packages/super-editor/src/core/parts/adapters/numbering-part-descriptor.ts +++ b/packages/super-editor/src/core/parts/adapters/numbering-part-descriptor.ts @@ -17,7 +17,19 @@ import { isPartCacheStale, clearPartCacheStale } from '../cache-staleness.js'; const NUMBERING_PART_ID = 'word/numbering.xml' as const; -const NUMBERING_XMLNS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +/** + * Namespace attributes for the `` root element. + * + * Includes `xmlns:w15` because base list definitions use + * `w15:restartNumberingAfterBreak` — without this declaration the + * numbering part is namespace-invalid and Word shows a repair prompt. + */ +const NUMBERING_ROOT_ATTRS: Record = { + 'xmlns:w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + 'xmlns:w15': 'http://schemas.microsoft.com/office/word/2012/wordml', + 'xmlns:mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006', + 'mc:Ignorable': 'w15', +}; // --------------------------------------------------------------------------- // Converter shape (minimal interface to avoid importing SuperConverter) @@ -147,7 +159,7 @@ export const numberingPartDescriptor: PartDescriptor = { { type: 'element', name: 'w:numbering', - attributes: { 'xmlns:w': NUMBERING_XMLNS }, + attributes: { ...NUMBERING_ROOT_ATTRS }, elements: [], }, ], diff --git a/tests/doc-api-stories/tests/lists/numbering-metadata-regression.ts b/tests/doc-api-stories/tests/lists/numbering-metadata-regression.ts index b720b19f44..c84388cb36 100644 --- a/tests/doc-api-stories/tests/lists/numbering-metadata-regression.ts +++ b/tests/doc-api-stories/tests/lists/numbering-metadata-regression.ts @@ -130,6 +130,15 @@ function countMatches(source: string, pattern: RegExp): number { return source.match(pattern)?.length ?? 0; } +function getRootStartTag(xml: string, tagName: string): string { + const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = xml.match(new RegExp(`<${escapedTagName}\\b[^>]*>`)); + if (!match) { + throw new Error(`Missing root start tag <${tagName}>.`); + } + return match[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'); @@ -180,5 +189,21 @@ describe('document-api story: lists.create numbering metadata regression', () => ), ).toBe(1); expect(countMatches(resultDocumentRels, /Target="numbering\.xml"/g)).toBe(1); + + // SD-2252: every namespace prefix used in the numbering part must be + // declared on the root element, otherwise Word flags the file as + // unreadable. Check the actual start tag so we do not + // false-pass on an xmlns declaration that appears later or in a narrower scope. + const numberingRootStartTag = getRootStartTag(resultNumbering, 'w:numbering'); + const usedPrefixes = new Set([...resultNumbering.matchAll(/(?:^|[\s<])(\w+):/g)].map((m) => m[1])); + // xml and xmlns are built-in prefixes that never need an explicit declaration. + usedPrefixes.delete('xml'); + usedPrefixes.delete('xmlns'); + + for (const prefix of usedPrefixes) { + expect(numberingRootStartTag, `missing xmlns:${prefix} declaration on `).toMatch( + new RegExp(`xmlns:${prefix}=`), + ); + } }); });