diff --git a/packages/docx-core/SUPPORT.md b/packages/docx-core/SUPPORT.md
index e77021c..6e22fb8 100644
--- a/packages/docx-core/SUPPORT.md
+++ b/packages/docx-core/SUPPORT.md
@@ -12,7 +12,7 @@ The "OOXML revision element" column uses ECMA-376 element names from the tracked
| Primitive / tool | Source path | OOXML revision element | Notes |
| --- | --- | --- | --- |
-| `text.ts` — `replaceParagraphTextRange` | `packages/docx-core/src/primitives/text.ts` | `w:ins`, `w:del`, *`w:rPrChange` (pending #173)* | Run-level text replacement; current implementation emits `w:ins`/`w:del` only. `w:rPrChange` for formatting-aware replacements is tracked as **#173**. **Verified by [120.8] (#143) regression test (locks ins/del behavior).** |
+| `text.ts` — `replaceParagraphTextRange` | `packages/docx-core/src/primitives/text.ts` | `w:ins`, `w:del`, `w:rPrChange` | Run-level text replacement emits insertion/deletion wrappers, and formatting-aware replacements record the prior run properties with `w:rPrChange`. **Verified by [120.8] (#143) regression test (after #173).** |
| `layout.ts` — `setParagraphSpacing` | `packages/docx-core/src/primitives/layout.ts` | `w:pPrChange` | Paragraph spacing mutations change paragraph properties, not spacer-paragraph structure. **Verified by [120.8] (#143) regression test.** |
| `layout.ts` — `setTableRowHeight` | `packages/docx-core/src/primitives/layout.ts` | `w:trPrChange` | Row geometry changes belong under row-property revisions. **Verified by [120.8] (#143) regression test.** |
| `layout.ts` — `setTableCellPadding` | `packages/docx-core/src/primitives/layout.ts` | `w:tcPrChange` | Cell padding changes belong under cell-property revisions. **Verified by [120.8] (#143) regression test.** |
diff --git a/packages/docx-core/src/integration/canonical-emission-regression.test.ts b/packages/docx-core/src/integration/canonical-emission-regression.test.ts
index 7b6ac5d..063d4e2 100644
--- a/packages/docx-core/src/integration/canonical-emission-regression.test.ts
+++ b/packages/docx-core/src/integration/canonical-emission-regression.test.ts
@@ -145,7 +145,7 @@ function revisionTuples(xml: string, requiredAuthor?: string): RevisionTuple[] {
}
describe('Canonical emission catalog', () => {
- test('Table A: text.ts replaceParagraphTextRange emits tracked insertion and deletion wrappers', async ({
+ test('Table A: text.ts replaceParagraphTextRange emits tracked insertion, deletion, and run-property change wrappers', async ({
given,
when,
then,
@@ -172,11 +172,11 @@ describe('Canonical emission catalog', () => {
});
});
- await then('document.xml contains tracked insertion and deletion metadata', () => {
- // Current implementation emits tracked insertion/deletion containers but
- // does not currently emit w:rPrChange from text replacement itself.
- expectTrackedElementsWithFixedMetadata(documentXml, ['ins', 'del']);
+ await then('document.xml contains tracked insertion, deletion, and run-property metadata', () => {
+ expectTrackedElementsWithFixedMetadata(documentXml, ['ins', 'del', 'rPrChange']);
expect(elementsByName(documentXml, 'b').length).toBeGreaterThan(0);
+ const rPrChange = elementsByName(documentXml, 'rPrChange')[0]!;
+ expect(rPrChange.getElementsByTagNameNS(W_NS, 'rPr')).toHaveLength(1);
});
});
diff --git a/packages/docx-core/src/primitives/text.test.ts b/packages/docx-core/src/primitives/text.test.ts
index 8c95870..e316412 100644
--- a/packages/docx-core/src/primitives/text.test.ts
+++ b/packages/docx-core/src/primitives/text.test.ts
@@ -787,6 +787,242 @@ describe('replaceParagraphTextRange tracked-change emission', () => {
});
});
+ test('does not emit rPrChange when explicit replacement formatting leaves run properties unchanged', async ({
+ given,
+ when,
+ then,
+ }: AllureBddContext) => {
+ let p: Element;
+
+ await given('a paragraph whose source run is already bold', () => {
+ const doc = makeDoc(
+ 'Hello',
+ );
+ p = firstParagraph(doc);
+ });
+
+ await when('the replacement explicitly asks for the same bold formatting', () => {
+ replaceParagraphTextRange(
+ p,
+ 0,
+ 5,
+ [{ text: 'New', addRunProps: { bold: true } }],
+ createRevisionContext({
+ author: 'SafeDocX AI',
+ date: '2026-05-03T14:15:16Z',
+ idState: createRevisionIdState(),
+ }),
+ );
+ });
+
+ await then('tracked insertion and deletion are emitted without a property-change record', () => {
+ expect(p.getElementsByTagNameNS(W_NS, 'ins')).toHaveLength(1);
+ expect(p.getElementsByTagNameNS(W_NS, 'del')).toHaveLength(1);
+ expect(p.getElementsByTagNameNS(W_NS, 'rPrChange')).toHaveLength(0);
+ });
+ });
+
+ test('emits rPrChange with the prior run properties when replacement formatting changes', async ({
+ given,
+ when,
+ then,
+ }: AllureBddContext) => {
+ let p: Element;
+ let rPrChange: Element;
+
+ await given('a paragraph whose source run is italic', () => {
+ const doc = makeDoc(
+ 'Hello',
+ );
+ p = firstParagraph(doc);
+ });
+
+ await when('the replacement adds bold formatting under tracked changes', () => {
+ replaceParagraphTextRange(
+ p,
+ 0,
+ 5,
+ [{ text: 'New', addRunProps: { bold: true } }],
+ createRevisionContext({
+ author: 'SafeDocX AI',
+ date: '2026-05-03T14:15:16Z',
+ idState: createRevisionIdState(),
+ }),
+ );
+ rPrChange = p.getElementsByTagNameNS(W_NS, 'rPrChange').item(0) as Element;
+ });
+
+ await then('the inserted run records the previous italic rPr inside w:rPrChange', () => {
+ expect(p.getElementsByTagNameNS(W_NS, 'ins')).toHaveLength(1);
+ expect(p.getElementsByTagNameNS(W_NS, 'del')).toHaveLength(1);
+ expect(p.getElementsByTagNameNS(W_NS, 'rPrChange')).toHaveLength(1);
+ expect(rPrChange.getAttribute('w:id')).toBeTruthy();
+ expect(rPrChange.getAttribute('w:author')).toBe('SafeDocX AI');
+ expect(rPrChange.getAttribute('w:date')).toBe('2026-05-03T14:15:16Z');
+
+ const previousRPr = rPrChange.getElementsByTagNameNS(W_NS, W.rPr).item(0) as Element;
+ expect(previousRPr).toBeTruthy();
+ expect(previousRPr.getElementsByTagNameNS(W_NS, W.i)).toHaveLength(1);
+ expect(previousRPr.getElementsByTagNameNS(W_NS, W.b)).toHaveLength(0);
+
+ const insertedRun = p.getElementsByTagNameNS(W_NS, 'ins').item(0)!.getElementsByTagNameNS(W_NS, W.r).item(0)!;
+ expect(insertedRun.getElementsByTagNameNS(W_NS, W.b)).toHaveLength(1);
+ });
+ });
+
+ test('does not emit rPrChange when source rPr only differs by pretty-printing whitespace', async ({
+ given,
+ when,
+ then,
+ }: AllureBddContext) => {
+ let p: Element;
+
+ await given('a paragraph whose source rPr is pretty-printed with whitespace text nodes', () => {
+ const doc = makeDoc(
+ '\n \nHello',
+ );
+ p = firstParagraph(doc);
+ });
+
+ await when('the replacement re-asserts the existing bold formatting', () => {
+ replaceParagraphTextRange(
+ p,
+ 0,
+ 5,
+ [{ text: 'New', addRunProps: { bold: true } }],
+ createRevisionContext({
+ author: 'SafeDocX AI',
+ date: '2026-05-03T14:15:16Z',
+ idState: createRevisionIdState(),
+ }),
+ );
+ });
+
+ await then('insignificant whitespace is ignored and no rPrChange is emitted', () => {
+ expect(p.getElementsByTagNameNS(W_NS, 'ins')).toHaveLength(1);
+ expect(p.getElementsByTagNameNS(W_NS, 'del')).toHaveLength(1);
+ expect(p.getElementsByTagNameNS(W_NS, 'rPrChange')).toHaveLength(0);
+ });
+ });
+
+ test('does not emit rPrChange when toggle properties differ only by ST_OnOff canonical form', async ({
+ given,
+ when,
+ then,
+ }: AllureBddContext) => {
+ let p: Element;
+
+ await given('a paragraph whose source bold toggle has no explicit w:val', () => {
+ const doc = makeDoc('Hello');
+ p = firstParagraph(doc);
+ });
+
+ await when('the replacement asks for the same bold formatting (which normalizes to w:val="1")', () => {
+ replaceParagraphTextRange(
+ p,
+ 0,
+ 5,
+ [{ text: 'New', addRunProps: { bold: true } }],
+ createRevisionContext({
+ author: 'SafeDocX AI',
+ date: '2026-05-03T14:15:16Z',
+ idState: createRevisionIdState(),
+ }),
+ );
+ });
+
+ await then('absent w:val and w:val="1" are treated as equal and no rPrChange is emitted', () => {
+ expect(p.getElementsByTagNameNS(W_NS, 'ins')).toHaveLength(1);
+ expect(p.getElementsByTagNameNS(W_NS, 'del')).toHaveLength(1);
+ expect(p.getElementsByTagNameNS(W_NS, 'rPrChange')).toHaveLength(0);
+ });
+ });
+
+ test('emits rPrChange when clearHighlight removes a highlight from the source rPr', async ({
+ given,
+ when,
+ then,
+ }: AllureBddContext) => {
+ let p: Element;
+ let rPrChange: Element;
+
+ await given('a paragraph whose source run carries a yellow highlight', () => {
+ const doc = makeDoc(
+ 'Hello',
+ );
+ p = firstParagraph(doc);
+ });
+
+ await when('the replacement clears the highlight under tracked changes', () => {
+ replaceParagraphTextRange(
+ p,
+ 0,
+ 5,
+ [{ text: 'New', clearHighlight: true }],
+ createRevisionContext({
+ author: 'SafeDocX AI',
+ date: '2026-05-03T14:15:16Z',
+ idState: createRevisionIdState(),
+ }),
+ );
+ rPrChange = p.getElementsByTagNameNS(W_NS, 'rPrChange').item(0) as Element;
+ });
+
+ await then('the inserted run records the previous highlight inside w:rPrChange', () => {
+ expect(p.getElementsByTagNameNS(W_NS, 'rPrChange')).toHaveLength(1);
+ const previousRPr = rPrChange.getElementsByTagNameNS(W_NS, W.rPr).item(0) as Element;
+ expect(previousRPr).toBeTruthy();
+ expect(previousRPr.getElementsByTagNameNS(W_NS, W.highlight)).toHaveLength(1);
+ });
+ });
+
+ test('multi-run deletion snapshots the chosen template run rPr in rPrChange', async ({
+ given,
+ when,
+ then,
+ }: AllureBddContext) => {
+ let p: Element;
+ let rPrChange: Element;
+
+ await given('a paragraph that spans an italic run followed by a bold run', () => {
+ const doc = makeDoc(
+ '' +
+ 'Hello ' +
+ 'World' +
+ '',
+ );
+ p = firstParagraph(doc);
+ });
+
+ await when('a single replacement part covers the full span and requests bold', () => {
+ replaceParagraphTextRange(
+ p,
+ 0,
+ 11,
+ [{ text: 'New', addRunProps: { bold: true } }],
+ createRevisionContext({
+ author: 'SafeDocX AI',
+ date: '2026-05-03T14:15:16Z',
+ idState: createRevisionIdState(),
+ }),
+ );
+ rPrChange = p.getElementsByTagNameNS(W_NS, 'rPrChange').item(0) as Element;
+ });
+
+ await then('the rPrChange records the predominant-template prior rPr (italic) and the deleted runs preserve full per-run formatting', () => {
+ expect(p.getElementsByTagNameNS(W_NS, 'rPrChange')).toHaveLength(1);
+ const previousRPr = rPrChange.getElementsByTagNameNS(W_NS, W.rPr).item(0) as Element;
+ expect(previousRPr.getElementsByTagNameNS(W_NS, W.i)).toHaveLength(1);
+ expect(previousRPr.getElementsByTagNameNS(W_NS, W.b)).toHaveLength(0);
+
+ const deletion = p.getElementsByTagNameNS(W_NS, 'del').item(0)!;
+ const deletedRuns = deletion.getElementsByTagNameNS(W_NS, W.r);
+ expect(deletedRuns).toHaveLength(2);
+ expect(deletedRuns.item(0)!.getElementsByTagNameNS(W_NS, W.i)).toHaveLength(1);
+ expect(deletedRuns.item(1)!.getElementsByTagNameNS(W_NS, W.b)).toHaveLength(1);
+ });
+ });
+
test('preserves per-run formatting inside tracked deletions spanning multiple runs', async ({ given, when, then }: AllureBddContext) => {
let p: Element;
let deletion: Element;
diff --git a/packages/docx-core/src/primitives/text.ts b/packages/docx-core/src/primitives/text.ts
index c9a14c6..e0959a2 100644
--- a/packages/docx-core/src/primitives/text.ts
+++ b/packages/docx-core/src/primitives/text.ts
@@ -1,6 +1,7 @@
import { OOXML, W } from './namespaces.js';
import { SafeDocxError } from './errors.js';
import {
+ buildRPrChangeElement,
createRevisionContainer,
prepareElementForDeletion,
type RevisionContext,
@@ -126,13 +127,24 @@ function cloneRunFormattingOnly(doc: Document, sourceRun: Element): Element {
if (child.nodeType !== 1) continue;
const el = child as Element;
if (isW(el, W.rPr)) {
- r.appendChild(el.cloneNode(true));
+ r.appendChild(cloneRPrWithoutChangeRecords(doc, el));
break;
}
}
return r;
}
+function cloneRPrWithoutChangeRecords(doc: Document, rPr: Element): Element {
+ const clone = doc.createElementNS(OOXML.W_NS, `w:${W.rPr}`);
+ for (const child of Array.from(rPr.childNodes)) {
+ if (child.nodeType !== 1) continue;
+ const el = child as Element;
+ if (isW(el, 'rPrChange')) continue;
+ clone.appendChild(el.cloneNode(true));
+ }
+ return clone;
+}
+
function appendTextToRun(doc: Document, run: Element, text: string): void {
// Convert \t and \n to OOXML equivalents where possible.
let buf = '';
@@ -302,6 +314,67 @@ function getDirectChild(parent: Element, localName: string): Element | null {
return null;
}
+// OOXML on/off toggle properties (ECMA-376 ST_OnOff). Absence of w:val means
+// "1", and the values "1"/"true"/"on" are equivalent (likewise for the falsy
+// triple). We normalize so semantically-identical inputs hash the same.
+const W_BOOL_TOGGLES = new Set([
+ 'b', 'bCs', 'i', 'iCs', 'caps', 'smallCaps', 'strike', 'dstrike',
+ 'outline', 'shadow', 'emboss', 'imprint', 'vanish', 'specVanish',
+ 'webHidden', 'noProof', 'snapToGrid', 'rtl', 'cs',
+]);
+
+function normalizedBoolValAttr(raw: string | null): string {
+ const s = raw === null ? '' : raw.trim().toLowerCase();
+ if (s === '' || s === '1' || s === 'true' || s === 'on') return '1';
+ if (s === '0' || s === 'false' || s === 'off') return '0';
+ return s;
+}
+
+function rPrComparableSignature(rPr: Element | null): string {
+ if (!rPr) return '';
+
+ const nodeSignature = (node: Node): string => {
+ // Text nodes inside w:rPr are insignificant whitespace from pretty-printing;
+ // the schema only permits element children, so dropping them matches
+ // semantics and avoids false positives against re-emitted (whitespace-free)
+ // run-property blocks.
+ if (node.nodeType !== 1) return '';
+
+ const el = node as Element;
+ if (isW(el, 'rPrChange')) return '';
+
+ const isWBoolToggle =
+ el.namespaceURI === OOXML.W_NS && W_BOOL_TOGGLES.has(el.localName ?? '');
+
+ const tuples = Array.from(el.attributes).map((attr) => {
+ const attrNs = attr.namespaceURI ?? (attr.name.startsWith('w:') ? OOXML.W_NS : '');
+ const attrName = attr.name.includes(':') ? attr.name.slice(attr.name.indexOf(':') + 1) : attr.localName;
+ let value = attr.value;
+ if (isWBoolToggle && attrNs === OOXML.W_NS && attrName === 'val') {
+ value = normalizedBoolValAttr(value);
+ }
+ return [attrNs, attrName, value] as const;
+ });
+
+ if (isWBoolToggle && !tuples.some(([ns, name]) => ns === OOXML.W_NS && name === 'val')) {
+ tuples.push([OOXML.W_NS, 'val', '1']);
+ }
+
+ const attrs = tuples
+ .sort(([aNs, aName], [bNs, bName]) => aNs.localeCompare(bNs) || aName.localeCompare(bName))
+ .map(([ns, name, value]) => `${ns}:${name}=${value}`)
+ .join('|');
+ const children = Array.from(el.childNodes).map(nodeSignature).join('');
+ return `<${el.namespaceURI ?? ''}:${el.localName} ${attrs}>${children}${el.namespaceURI ?? ''}:${el.localName}>`;
+ };
+
+ return Array.from(rPr.childNodes).map(nodeSignature).join('');
+}
+
+function getSnapshotRPr(doc: Document, sourceRPr: Element | null): Element {
+ return sourceRPr ? cloneRPrWithoutChangeRecords(doc, sourceRPr) : doc.createElementNS(OOXML.W_NS, `w:${W.rPr}`);
+}
+
function ensureRPr(doc: Document, run: Element): Element {
const existing = getDirectChild(run, W.rPr);
if (existing) return existing;
@@ -519,8 +592,15 @@ export function replaceParagraphTextRange(
const replacementRuns: Element[] = [];
for (const part of parts) {
const tmpl = part.templateRun ?? templateRun;
+ const sourceRPr = getDirectChild(tmpl, W.rPr);
+ const sourceRPrSignature = rPrComparableSignature(sourceRPr);
const newRun = cloneRunFormattingOnly(doc, tmpl);
applyRunProps(doc, newRun, part.addRunProps, part.clearHighlight);
+ const newRPr = getDirectChild(newRun, W.rPr);
+ const hasExplicitFormattingMutation = !!part.addRunProps || !!part.clearHighlight;
+ if (ctx && hasExplicitFormattingMutation && rPrComparableSignature(newRPr) !== sourceRPrSignature) {
+ ensureRPr(doc, newRun).appendChild(buildRPrChangeElement(getSnapshotRPr(doc, sourceRPr), ctx));
+ }
appendTextToRun(doc, newRun, part.text);
if (getRunVisibleLength(newRun) > 0) {
replacementRuns.push(newRun);