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
9 changes: 1 addition & 8 deletions coverage/emitted-schema-known-failures.json
Original file line number Diff line number Diff line change
@@ -1,8 +1 @@
[
{
"id": "synthetic-table-missing-tblgrid",
"issue": "#453",
"match": "}tr': This element is not expected.",
"reason": "synthetic table fixtures omit the required w:tblGrid, and the engine faithfully preserves the gap into emitted output"
}
]
[]
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ describe('Canonical emission catalog', () => {
await given('a table row with a prior height definition', async () => {
const { doc } = await loadIndexedDoc(
await makeMinimalDocx(
'<w:tbl><w:tr><w:trPr><w:trHeight w:val="360" w:hRule="atLeast"/></w:trPr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>',
'<w:tbl><w:tblPr/><w:tblGrid><w:gridCol/></w:tblGrid><w:tr><w:trPr><w:trHeight w:val="360" w:hRule="atLeast"/></w:trPr><w:tc><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>',
),
);

Expand All @@ -240,7 +240,7 @@ describe('Canonical emission catalog', () => {
await given('a table cell with prior padding properties', async () => {
const { doc } = await loadIndexedDoc(
await makeMinimalDocx(
'<w:tbl><w:tr><w:tc><w:tcPr><w:tcMar><w:top w:w="100" w:type="dxa"/></w:tcMar></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>',
'<w:tbl><w:tblPr/><w:tblGrid><w:gridCol/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcMar><w:top w:w="100" w:type="dxa"/></w:tcMar></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>',
),
);

Expand Down
105 changes: 55 additions & 50 deletions packages/docx-core/src/primitives/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,56 +143,61 @@ describe('layout tracked-change emission', () => {
});
});

test('setTableCellPadding emits tcPrChange with the prior cell properties snapshot', async ({ given, when, then }: AllureBddContext) => {
let doc: Document;
let tcMar: Element;
let left: Element;
let tcPrChange: Element;
let previousTcMar: Element;

await given('a table cell that already has top padding', () => {
doc = makeDocument(
`<w:tbl><w:tr><w:tc><w:tcPr><w:tcMar><w:top w:w="100" w:type="dxa"/></w:tcMar></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>`,
);
});

await when('tracked left padding is added', () => {
const result = setTableCellPadding(
doc,
{ tableIndexes: [0], leftDxa: 240 },
createRevisionContext({
author: 'SafeDocX AI',
date: '2026-05-03T14:15:16Z',
idState: createRevisionIdState(),
}),
);
expect(result).toEqual({
affectedCells: 1,
missingTableIndexes: [],
missingRowIndexes: [],
missingCellIndexes: [],
});

const table = doc.getElementsByTagNameNS(W_NS, W.tbl).item(0) as Element;
const row = firstDirectChild(table, W.tr);
const cell = firstDirectChild(row, W.tc);
const tcPr = firstDirectChild(cell, W.tcPr);
tcMar = firstDirectChild(tcPr, W.tcMar);
left = firstDirectChild(tcMar, W.left);
tcPrChange = firstDirectChild(tcPr, 'tcPrChange');
previousTcMar = firstDirectChild(firstDirectChild(tcPrChange, W.tcPr), W.tcMar);
});

await then('the outer cell properties are updated while the inner tcPr snapshot preserves the old padding', () => {
expect(wordAttr(tcPrChange, 'author')).toBe('SafeDocX AI');
expect(wordAttr(tcPrChange, 'date')).toBe('2026-05-03T14:15:16Z');
expect(revisionId(tcPrChange)).toBe(1);
expect(wordAttr(left, 'w')).toBe('240');
expect(wordAttr(left, 'type')).toBe('dxa');
expect(getDirectChildrenByName(previousTcMar, W.left)).toHaveLength(0);
expect(firstDirectChild(previousTcMar, W.top)).toBeDefined();
});
});
test
.conformance({ spec: 'ECMA-376', edition: 5, part: 1, section: '17.4.68' })(
'setTableCellPadding emits tcPrChange with the prior cell properties snapshot',
async ({ given, when, then }: AllureBddContext) => {
let doc: Document;
let tcMar: Element;
let left: Element;
let tcPrChange: Element;
let previousTcMar: Element;

await given('a table cell that already has top padding', () => {
doc = makeDocument(
`<w:tbl><w:tr><w:tc><w:tcPr><w:tcMar><w:top w:w="100" w:type="dxa"/></w:tcMar></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>`,
);
});

await when('tracked left padding is added', () => {
const result = setTableCellPadding(
doc,
{ tableIndexes: [0], leftDxa: 240 },
createRevisionContext({
author: 'SafeDocX AI',
date: '2026-05-03T14:15:16Z',
idState: createRevisionIdState(),
}),
);
expect(result).toEqual({
affectedCells: 1,
missingTableIndexes: [],
missingRowIndexes: [],
missingCellIndexes: [],
});

const table = doc.getElementsByTagNameNS(W_NS, W.tbl).item(0) as Element;
const row = firstDirectChild(table, W.tr);
const cell = firstDirectChild(row, W.tc);
const tcPr = firstDirectChild(cell, W.tcPr);
tcMar = firstDirectChild(tcPr, W.tcMar);
left = firstDirectChild(tcMar, W.left);
tcPrChange = firstDirectChild(tcPr, 'tcPrChange');
previousTcMar = firstDirectChild(firstDirectChild(tcPrChange, W.tcPr), W.tcMar);
});

await then('the outer cell properties are updated while the inner tcPr snapshot preserves the old padding', () => {
expect(wordAttr(tcPrChange, 'author')).toBe('SafeDocX AI');
expect(wordAttr(tcPrChange, 'date')).toBe('2026-05-03T14:15:16Z');
expect(revisionId(tcPrChange)).toBe(1);
expect(Array.from(tcMar.children).map((child) => child.localName)).toEqual([W.top, W.left]);
expect(wordAttr(left, 'w')).toBe('240');
expect(wordAttr(left, 'type')).toBe('dxa');
expect(getDirectChildrenByName(previousTcMar, W.left)).toHaveLength(0);
expect(firstDirectChild(previousTcMar, W.top)).toBeDefined();
});
},
);

test('layout primitives preserve legacy mutation behavior when revision context is omitted', async ({ given, when, then }: AllureBddContext) => {
let doc: Document;
Expand Down
14 changes: 14 additions & 0 deletions packages/docx-core/src/primitives/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ function ensureChild(parent: Element, localName: string): Element {
return created;
}

/**
* Keep per-cell margins in the CT_TcMar child sequence required by the schema.
*
* @conformance ECMA-376 edition 5, Part 1 § 17.4.68
*/
function reorderCellMarginEdges(tcMar: Element): void {
const orderedNames = [W.top, W.left, W.bottom, W.right];
const ordered = orderedNames.flatMap((name) => getDirectChildrenByName(tcMar, name));
for (const child of ordered) {
tcMar.appendChild(child);
}
}

function setWAttr(el: Element, localName: string, value: string): void {
el.setAttributeNS(OOXML.W_NS, `w:${localName}`, value);
}
Expand Down Expand Up @@ -274,6 +287,7 @@ export function setTableCellPadding(
setWAttr(right, W.w, String(mutation.rightDxa));
setWAttr(right, W.type, 'dxa');
}
reorderCellMarginEdges(tcMar);
if (ctx) {
// CT_TcPr permits at most one <w:tcPrChange> child.
for (const stale of getDirectChildrenByName(tcPr, 'tcPrChange')) {
Expand Down
8 changes: 4 additions & 4 deletions packages/docx-core/src/primitives/minimal_save.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe('minimal re-serialization on save (issue #408)', () => {
`<w:p><w:proofErr w:type="spellStart"/><w:r w:rsidR="00CC0001"><w:t>cell</w:t></w:r>` +
`<w:proofErr w:type="spellEnd"/><w:r w:rsidR="00CC0002"><w:t xml:space="preserve"> text</w:t></w:r></w:p>`;
const TBL = (cellP: string) =>
`<w:tbl><w:tr><w:tc>${cellP}</w:tc></w:tr></w:tbl>`;
`<w:tbl><w:tblPr/><w:tblGrid><w:gridCol/></w:tblGrid><w:tr><w:tc>${cellP}</w:tc></w:tr></w:tbl>`;
let untouched: { originalXml: string; savedXml: string };
let modified: { originalXml: string; savedXml: string };

Expand Down Expand Up @@ -240,10 +240,10 @@ describe('minimal re-serialization on save (issue #408)', () => {
// proofErr/rsid-bearing paragraph in the table must keep its original
// XML — issue #408's repro document is almost entirely tables.
const cellP = (id: string, marker: string) =>
`<w:p w14:paraId="${id}"><w:proofErr w:type="spellStart"/><w:r w:rsidR="00${marker}01"><w:t>${marker}</w:t></w:r>` +
`<w:proofErr w:type="spellEnd"/><w:r w:rsidR="00${marker}02"><w:t xml:space="preserve"> tail</w:t></w:r></w:p>`;
`<w:p w14:paraId="${id}"><w:proofErr w:type="spellStart"/><w:r w:rsidR="0000${marker}01"><w:t>${marker}</w:t></w:r>` +
`<w:proofErr w:type="spellEnd"/><w:r w:rsidR="0000${marker}02"><w:t xml:space="preserve"> tail</w:t></w:r></w:p>`;
const tableBody =
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>` +
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr><w:tblGrid><w:gridCol/><w:gridCol/></w:tblGrid>` +
`<w:tr><w:tc>${cellP('00000A01', 'AA')}${cellP('00000A02', 'AB')}</w:tc><w:tc>${cellP('00000B01', 'BA')}</w:tc></w:tr>` +
`<w:tr><w:tc>${cellP('00000C01', 'CA')}</w:tc><w:tc>${cellP('00000D01', 'DA')}</w:tc></w:tr>` +
`</w:tbl>`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ describe('Tool integration through SessionManager: canonical revision emission',
xml: makeDocXml(
'<w:p><w:r><w:t>Spacing paragraph.</w:t></w:r></w:p>' +
'<w:tbl>' +
'<w:tblPr/>' +
'<w:tblGrid><w:gridCol/><w:gridCol/></w:tblGrid>' +
'<w:tr>' +
'<w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>' +
'<w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>' +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ describe('Traceability: Accept Tracked Changes', () => {
`<w:pPr><w:pStyle w:val="Heading1"/><w:pPrChange w:id="10" w:author="A"><w:pPr/></w:pPrChange></w:pPr>` +
`<w:r><w:rPr><w:b/><w:rPrChange w:id="11" w:author="A"><w:rPr/></w:rPrChange></w:rPr><w:t>bold text</w:t></w:r>` +
`</w:p>` +
`<w:tbl><w:tblPr><w:tblPrChange w:id="12" w:author="A"><w:tblPr/></w:tblPrChange></w:tblPr>` +
`<w:tbl><w:tblPr><w:tblPrChange w:id="12" w:author="A"><w:tblPr/></w:tblPrChange></w:tblPr><w:tblGrid><w:gridCol/></w:tblGrid>` +
`<w:tr><w:tc><w:tcPr><w:tcPrChange w:id="13" w:author="A"><w:tcPr/></w:tcPrChange></w:tcPr>` +
`<w:p><w:r><w:t>cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>`;
const filePath = await writeTestDocx(dir, 'prchange.docx', bodyXml);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ describe('Traceability: Layout Format Controls', () => {
`<w:body>` +
`<w:p><w:r><w:t>Table heading</w:t></w:r></w:p>` +
`<w:tbl>` +
`<w:tblPr/>` +
`<w:tblGrid><w:gridCol/><w:gridCol/></w:tblGrid>` +
`<w:tr>` +
`<w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>` +
`<w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>` +
Expand Down
2 changes: 1 addition & 1 deletion packages/docx-mcp/src/tools/extract_revisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,7 @@ describe('extract_revisions tool', () => {
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<w:document xmlns:w="${W_NS}">` +
`<w:body>` +
`<w:tbl><w:tr><w:tc>` +
`<w:tbl><w:tblPr/><w:tblGrid><w:gridCol/></w:tblGrid><w:tr><w:tc>` +
`<w:p><w:r><w:t>Cell </w:t></w:r>` +
`<w:ins w:author="X"><w:r><w:t>edited</w:t></w:r></w:ins></w:p>` +
`</w:tc></w:tr></w:tbl>` +
Expand Down
4 changes: 4 additions & 0 deletions packages/docx-mcp/src/tools/format_layout.validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ describe('format_layout validation + strictness', () => {
`<w:body>` +
`<w:p><w:r><w:t>Only paragraph</w:t></w:r></w:p>` +
`<w:tbl>` +
`<w:tblPr/>` +
`<w:tblGrid><w:gridCol/></w:tblGrid>` +
`<w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc></w:tr>` +
`</w:tbl>` +
`</w:body>` +
Expand Down Expand Up @@ -109,6 +111,8 @@ describe('format_layout validation + strictness', () => {
`<w:body>` +
`<w:p><w:r><w:t>P</w:t></w:r></w:p>` +
`<w:tbl>` +
`<w:tblPr/>` +
`<w:tblGrid><w:gridCol/></w:tblGrid>` +
`<w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc></w:tr>` +
`</w:tbl>` +
`</w:body>` +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ describe('format_layout: non-body part preservation', () => {
`<w:body>` +
`<w:p><w:r><w:t>Cover Terms</w:t></w:r></w:p>` +
`<w:tbl>` +
`<w:tblPr/>` +
`<w:tblGrid><w:gridCol/><w:gridCol/></w:tblGrid>` +
`<w:tr>` +
`<w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>` +
`<w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>` +
Expand Down
2 changes: 2 additions & 0 deletions packages/docx-mcp/src/tools/strict_transactionality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ describe('format_layout: strict failure transactionality', () => {
`<w:body>` +
`<w:p><w:r><w:t>Alpha</w:t></w:r></w:p>` +
`<w:tbl>` +
`<w:tblPr/>` +
`<w:tblGrid><w:gridCol/></w:tblGrid>` +
`<w:tr><w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc></w:tr>` +
`<w:tr><w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc></w:tr>` +
`</w:tbl>` +
Expand Down