Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/docx-core/SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.** |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
});
});

Expand Down
236 changes: 236 additions & 0 deletions packages/docx-core/src/primitives/text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<w:p><w:r><w:rPr><w:b w:val="1"/></w:rPr><w:t>Hello</w:t></w:r></w:p>',
);
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(
'<w:p><w:r><w:rPr><w:i/></w:rPr><w:t>Hello</w:t></w:r></w:p>',
);
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(
'<w:p><w:r><w:rPr>\n <w:b w:val="1"/>\n</w:rPr><w:t>Hello</w:t></w:r></w:p>',
);
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('<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Hello</w:t></w:r></w:p>');
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(
'<w:p><w:r><w:rPr><w:highlight w:val="yellow"/></w:rPr><w:t>Hello</w:t></w:r></w:p>',
);
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(
'<w:p>' +
'<w:r><w:rPr><w:i/></w:rPr><w:t>Hello </w:t></w:r>' +
'<w:r><w:rPr><w:b/></w:rPr><w:t>World</w:t></w:r>' +
'</w:p>',
);
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;
Expand Down
82 changes: 81 additions & 1 deletion packages/docx-core/src/primitives/text.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { OOXML, W } from './namespaces.js';
import { SafeDocxError } from './errors.js';
import {
buildRPrChangeElement,
createRevisionContainer,
prepareElementForDeletion,
type RevisionContext,
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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<string>([
'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;
Expand Down Expand Up @@ -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);
Expand Down
Loading