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
Original file line number Diff line number Diff line change
@@ -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<string, string> }>;
};
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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<w:numbering>` 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<string, string> = {
'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)
Expand Down Expand Up @@ -147,7 +159,7 @@ export const numberingPartDescriptor: PartDescriptor = {
{
type: 'element',
name: 'w:numbering',
attributes: { 'xmlns:w': NUMBERING_XMLNS },
attributes: { ...NUMBERING_ROOT_ATTRS },
elements: [],
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 <w:numbering ...> 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 <w:numbering>`).toMatch(
new RegExp(`xmlns:${prefix}=`),
);
}
});
});
Loading