From bf58d3d315f47043f0b81e383d0321f0b0655100 Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Thu, 11 Jun 2026 19:08:25 -0400 Subject: [PATCH] fix(docx-core): replace duplicate rPrChange snapshots Comparison can visit multiple format-changed atoms that belong to the same split run. Appending a fresh w:rPrChange each time violates CT_RPr, which admits at most one run-property revision child under a single w:rPr. This keeps the latest format snapshot by removing any existing w:rPrChange before appending the replacement. The regression locks the single-snapshot invariant and the emitted-schema known failure for #451 is removed. Fixes: #451 --- coverage/emitted-schema-known-failures.json | 6 ----- .../atomizer/inPlaceModifier-wrappers.ts | 9 +++++++ .../atomizer/inPlaceModifier.test.ts | 27 +++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/coverage/emitted-schema-known-failures.json b/coverage/emitted-schema-known-failures.json index 02007b7d..ee3e96a8 100644 --- a/coverage/emitted-schema-known-failures.json +++ b/coverage/emitted-schema-known-failures.json @@ -1,10 +1,4 @@ [ - { - "id": "duplicate-rprchange-in-rpr", - "issue": "#451", - "match": "}rPrChange': This element is not expected.", - "reason": "comparison stacks duplicate w:rPrChange children in a single w:rPr; CT_RPr allows at most one" - }, { "id": "duplicate-para-mark-ins", "issue": "#452", diff --git a/packages/docx-core/src/baselines/atomizer/inPlaceModifier-wrappers.ts b/packages/docx-core/src/baselines/atomizer/inPlaceModifier-wrappers.ts index 2c52a28f..5c81380b 100644 --- a/packages/docx-core/src/baselines/atomizer/inPlaceModifier-wrappers.ts +++ b/packages/docx-core/src/baselines/atomizer/inPlaceModifier-wrappers.ts @@ -421,6 +421,15 @@ export function addFormatChange( run.insertBefore(rPr, run.firstChild); } + // CT_RPr permits at most one w:rPrChange. Comparison can visit multiple + // format-changed atoms from the same split run, so keep the latest snapshot + // instead of stacking invalid siblings. + for (const child of childElements(rPr)) { + if (child.tagName === 'w:rPrChange') { + rPr.removeChild(child); + } + } + // Create rPrChange const id = allocateRevisionId(state); const rPrChange = createEl('w:rPrChange', { diff --git a/packages/docx-core/src/baselines/atomizer/inPlaceModifier.test.ts b/packages/docx-core/src/baselines/atomizer/inPlaceModifier.test.ts index 6ca7fe34..0a8e5983 100644 --- a/packages/docx-core/src/baselines/atomizer/inPlaceModifier.test.ts +++ b/packages/docx-core/src/baselines/atomizer/inPlaceModifier.test.ts @@ -1653,6 +1653,33 @@ describe('inPlaceModifier', () => { expect(childElements(innerRPr)).toHaveLength(3); }); }); + + test('replaces an existing rPrChange instead of stacking duplicates', async ({ given, when, then }: AllureBddContext) => { + let r: Element; + let firstOldRPr: Element; + let secondOldRPr: Element; + let state: ReturnType; + + await given('a run whose formatting is visited twice by split format-changed atoms', () => { + r = el('w:r', {}, [el('w:rPr', {}, [el('w:b')]), el('w:t', {}, undefined, 'text')]); + firstOldRPr = el('w:rPr', {}, [el('w:i')]); + secondOldRPr = el('w:rPr', {}, [el('w:color', { 'w:val': 'FF0000' })]); + state = createRevisionIdState(); + }); + + await when('addFormatChange is called twice for the same run', () => { + addFormatChange(r, firstOldRPr, author, dateStr, state); + addFormatChange(r, secondOldRPr, author, dateStr, state); + }); + + await then('the current rPr contains one replacement rPrChange snapshot', () => { + const rPr = childElements(r).find((c) => c.tagName === 'w:rPr')!; + const rPrChanges = childElements(rPr).filter((c) => c.tagName === 'w:rPrChange'); + expect(rPrChanges).toHaveLength(1); + const innerRPr = childElements(rPrChanges[0]!).find((c) => c.tagName === 'w:rPr')!; + expect(childElements(innerRPr).map((c) => c.tagName)).toEqual(['w:color']); + }); + }); }); // ── Branch coverage: preSplitMixedStatusRuns ──────────────────────