Skip to content
135 changes: 135 additions & 0 deletions packages/docx-compare/src/tagged/nativeMoveParagraphParity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect } from 'vitest';
import { testAllure } from '../testing/allure-test.js';
import { parseXml, serializeXml, acceptChanges, rejectChanges, DocxArchive, buildSyntheticDocx } from '@usejunior/docx-core';
import { compareDocuments } from '../index.js';
import { acceptAllChanges, rejectAllChanges, extractTextWithParagraphs } from './trackChangesAcceptorAst.js';

const test = testAllure.epic('Document Comparison').withLabels({ feature: 'Native move paragraph parity' })
.conformance({ spec: 'ECMA-376', edition: 5, part: 1, section: '17.13.5.22' });
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const attrs = 'w:author="Comparator" w:date="2026-09-17T00:00:00Z"';
const source = `<w:moveFromRangeStart w:id="1" w:name="move1" ${attrs}/>` +
`<w:p><w:pPr><w:rPr><w:moveFrom w:id="2" ${attrs}/></w:rPr></w:pPr>` +
`<w:moveFrom w:id="3" ${attrs}><w:r><w:t>Moved paragraph</w:t></w:r></w:moveFrom></w:p><w:moveFromRangeEnd w:id="1"/>`;
const destination = `<w:moveToRangeStart w:id="4" w:name="move1" ${attrs}/>` +
`<w:p><w:pPr><w:rPr><w:moveTo w:id="5" ${attrs}/></w:rPr></w:pPr>` +
`<w:moveTo w:id="6" ${attrs}><w:r><w:t>Moved paragraph</w:t></w:r></w:moveTo></w:p><w:moveToRangeEnd w:id="4"/>`;
const stable = '<w:p><w:r><w:t>Stable paragraph</w:t></w:r></w:p>';
const wrap = (body: string) => `<w:document xmlns:w="${W}"><w:body>${body}<w:sectPr/></w:body></w:document>`;
const texts = (xml: string) => Array.from(parseXml(xml).getElementsByTagNameNS(W, 'p')).map(p =>
Array.from(p.getElementsByTagNameNS(W, 't')).map(t => t.textContent).join(''));

describe('complete move ranges through native paragraph-mark resolution', () => {
for (const [name, body] of [
['middle', source + destination + stable],
['terminal destination', source + stable + destination],
['terminal source', destination + stable + source],
['table cell', '<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="6000"/></w:tblGrid><w:tr><w:tc>' + source + destination + stable + '</w:tc></w:tr></w:tbl>'],
['terminal table cell', '<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="6000"/></w:tblGrid><w:tr><w:tc>' + source + stable + destination + '</w:tc></w:tr></w:tbl>'],
]) {
for (const [operation, nativeProject, astProject] of [
['Accept', acceptChanges, acceptAllChanges], ['Reject', rejectChanges, rejectAllChanges],
] as const) {
test(`${operation}: ${name} agrees with AST without empty moved containers`, () => {
const input = wrap(body!);
const doc = parseXml(input);
nativeProject(doc);
const output = serializeXml(doc);
expect(texts(output)).toEqual(texts(astProject(input)));
expect(texts(output).filter(t => t === 'Moved paragraph')).toHaveLength(1);
expect(texts(output).filter(t => t === '')).toHaveLength(0);
expect(output).not.toContain('moveFrom');
expect(output).not.toContain('moveTo');
});
}
}

test('selective native resolution leaves foreign complete move endpoints and boundaries untouched', () => {
const input = wrap(source + stable + destination);
for (const project of [acceptChanges, rejectChanges]) {
const doc = parseXml(input);
const before = serializeXml(doc);
project(doc, { filter: e => e.getAttributeNS(W, 'author') === 'AI' });
expect(serializeXml(doc)).toBe(before);
}
});

test('content-only move wrappers retain untracked paragraph containers', () => {
const input = wrap((source + stable + destination).replace(/<w:pPr>.*?<\/w:pPr>/g, ''));
for (const [nativeProject, astProject] of [[acceptChanges, acceptAllChanges], [rejectChanges, rejectAllChanges]] as const) {
const doc = parseXml(input);
nativeProject(doc);
expect(texts(serializeXml(doc))).toEqual(texts(astProject(input)));
expect(doc.getElementsByTagNameNS(W, 'p').length).toBe(3);
}
});

for (const movedContent of [false, true]) {
test(`Reject preserves local bookmarks around ${movedContent ? 'mixed moved and' : 'only'} untracked surviving content`, () => {
const surviving = '<w:bookmarkStart w:id="90" w:name="keepme"/><w:r><w:t>Untracked survivor</w:t></w:r><w:bookmarkEnd w:id="90"/>';
const to = (movedContent ? destination : destination.replace(/<w:moveTo w:id="6"[\s\S]*?<\/w:moveTo>/, ''))
.replace('</w:p>', `${surviving}</w:p>`);
const input = wrap(source + to + stable);
for (const project of [(xml: string) => { const doc = parseXml(xml); rejectChanges(doc); return serializeXml(doc); }, rejectAllChanges]) {
const output = parseXml(project(input));
expect(Array.from(output.getElementsByTagNameNS(W, 'bookmarkStart')).map(b => b.getAttributeNS(W, 'name'))).toEqual(['keepme']);
expect(Array.from(output.getElementsByTagNameNS(W, 'bookmarkEnd')).map(b => b.getAttributeNS(W, 'id'))).toEqual(['90']);
expect(texts(serializeXml(output))).toEqual(['Moved paragraph', 'Untracked survivorStable paragraph']);
}
});
}

test('selective resolution resolves one author while preserving another complete move', () => {
const foreign = (source + stable + destination).replaceAll('Comparator', 'Human')
.replaceAll('move1', 'move2').replace(/w:id="(\d+)"/g, (_, id: string) => `w:id="${Number(id) + 10}"`);
for (const project of [acceptChanges, rejectChanges]) {
const doc = parseXml(wrap(source + stable + destination + foreign));
const before = Array.from(doc.getElementsByTagNameNS(W, 'p')).slice(3).map(p => p.toString());
project(doc, { filter: e => e.getAttributeNS(W, 'author') === 'Comparator' });
const remaining = Array.from(doc.getElementsByTagNameNS(W, 'p'));
expect(remaining.slice(-3).map(p => p.toString())).toEqual(before);
expect(remaining.length).toBe(5);
expect(Array.from(doc.getElementsByTagNameNS(W, 'moveTo')).map(e => e.getAttributeNS(W, 'author'))).toEqual(['Human', 'Human']);
expect(Array.from(doc.getElementsByTagNameNS(W, 'moveFrom')).map(e => e.getAttributeNS(W, 'author'))).toEqual(['Human', 'Human']);
}
});

for (const terminal of [false, true]) {
test(`resolves local endpoint bookmarks without relocating removed ${terminal ? 'terminal' : 'middle'} pairs`, () => {
const bookmarked = (xml: string, id: number) => xml.replace('</w:pPr>', `</w:pPr><w:bookmarkStart w:id="${id}" w:name="anchor${id}"/>`)
.replace('</w:p>', `<w:bookmarkEnd w:id="${id}"/></w:p>`);
const from = bookmarked(source, 21);
const to = bookmarked(destination, 22);
const input = wrap(terminal ? from + stable + to : from + to + stable);
for (const [project, astProject, expectedId] of [[acceptChanges, acceptAllChanges, '22'], [rejectChanges, rejectAllChanges, '21']] as const) {
const doc = parseXml(input);
project(doc);
const ast = parseXml(astProject(input));
for (const kind of ['bookmarkStart', 'bookmarkEnd']) {
const ids = (d: Document) => Array.from(d.getElementsByTagNameNS(W, kind)).map(e => e.getAttributeNS(W, 'id'));
expect(ids(doc)).toEqual([expectedId]);
expect(ids(doc)).toEqual(ids(ast));
}
expect(texts(serializeXml(doc))).toEqual(texts(serializeXml(ast)));
}
});
}

test('native Accept/Reject resolve actual comparison-authored terminal moves to source paragraphs', async () => {
const moving = 'the complete movable paragraph changes its position in the document';
const anchored = 'the long stable anchor paragraph remains unchanged in its position';
const finish = 'the second stable anchor paragraph remains unchanged throughout';
const build = (values: string[]) => buildSyntheticDocx({ paragraphs: values });
const original = await build([moving, anchored, finish]);
const revised = await build([anchored, finish, moving]);
const result = await compareDocuments(original, revised, { detectMoves: true });
const xml = await (await DocxArchive.load(result.document)).getDocumentXml();
expect(xml).toContain('moveToRangeStart');
for (const [project, control] of [[acceptChanges, revised], [rejectChanges, original]] as const) {
const doc = parseXml(xml);
project(doc);
expect(extractTextWithParagraphs(serializeXml(doc)))
.toBe(extractTextWithParagraphs(await (await DocxArchive.load(control)).getDocumentXml()));
}
});
});
2 changes: 2 additions & 0 deletions packages/docx-compare/src/tagged/trackChangesAcceptorAst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,8 @@ export function rejectAllChanges(documentXml: string): string {
for (const paragraph of markInsertedParagraphs) {
if (!paragraphHasParaMarker(paragraph, 'w:moveTo')) continue;
const direct = childElements(paragraph);
const substantive = direct.filter(child => !['w:pPr', 'w:bookmarkStart', 'w:bookmarkEnd'].includes(child.tagName));
if (substantive.length === 0 || !substantive.every(child => ['w:ins', 'w:moveTo'].includes(child.tagName))) continue;
const starts = direct.filter((child) => child.tagName === 'w:bookmarkStart');
const ends = direct.filter((child) => child.tagName === 'w:bookmarkEnd');
for (const start of starts) {
Expand Down
9 changes: 5 additions & 4 deletions packages/docx-core/src/primitives/accept_changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ export function acceptChanges(
};
}

// Phase A — Identify paragraphs whose MARK is a tracked deletion
// Phase A — Identify deleted or moved-from paragraph marks.
const markDeletedParagraphs: Element[] = [];
const allParagraphs = collectByLocalName(root, 'p');
// Capture selected direct mark/property histories before other phases remove
Expand All @@ -382,7 +382,7 @@ export function acceptChanges(
// accept. safe-docx's deleted paragraphs always carry the mark now, so the
// mark-based rule suffices and is Word-faithful. (Mirrors acceptAllChanges and the
// reject-side rule.)
if (paragraphHasParaMarker(p, 'del', filter)) {
if (paragraphHasParaMarker(p, 'del', filter) || paragraphHasParaMarker(p, 'moveFrom', filter)) {
markDeletedParagraphs.push(p);
}
}
Expand All @@ -392,7 +392,7 @@ export function acceptChanges(
// text. Accepting the deletion must remove that live original-side endpoint
// as well; otherwise a cross-paragraph range becomes orphaned.
const deletedBookmarkIds = new Set<string>();
for (const deletion of collectByLocalName(root, 'del').filter(filter)) {
for (const deletion of [...collectByLocalName(root, 'del'), ...collectByLocalName(root, 'moveFrom')].filter(filter)) {
for (const localName of ['bookmarkStart', 'bookmarkEnd']) {
for (const boundary of collectByLocalName(deletion, localName)) {
const id = boundary.getAttributeNS(W_NS, 'id') ?? boundary.getAttribute('w:id');
Expand All @@ -405,7 +405,8 @@ export function acceptChanges(
.filter((child): child is Element => child.nodeType === 1);
const substantive = direct.filter((child) =>
!isW(child, 'pPr') && !isW(child, 'bookmarkStart') && !isW(child, 'bookmarkEnd'));
if (substantive.length === 0 || !substantive.every((child) => isW(child, 'del') && filter(child))) {
if (substantive.length === 0 || !substantive.every((child) =>
(isW(child, 'del') || isW(child, 'moveFrom')) && filter(child))) {
continue;
}
for (const boundary of direct.filter((child) =>
Expand Down
113 changes: 113 additions & 0 deletions packages/docx-core/src/primitives/native_move_paragraph_marks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect } from 'vitest';
import { testAllure } from '../testing/allure-test.js';
import { parseXml } from './xml.js';
import { acceptChanges } from './accept_changes.js';
import { rejectChanges } from './reject_changes.js';

const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const test = testAllure.epic('Document Comparison').withLabels({ feature: 'Native move paragraph marks' })
.conformance(
{ spec: 'ECMA-376', edition: 5, part: 1, section: '17.13.5.21' },
{ spec: 'ECMA-376', edition: 5, part: 1, section: '17.13.5.22' },
{ spec: 'ECMA-376', edition: 5, part: 1, section: '17.13.5.25' },
{ spec: 'ECMA-376', edition: 5, part: 1, section: '17.13.5.26' },
);
// Complete range/mark/content shapes are the subjects of these consumer tests.
const endpoint = (kind: 'moveFrom' | 'moveTo', base: number, body: string, bookmark = true) =>
'<w:' + kind + 'RangeStart w:id="' + base + '" w:name="move1" w:author="AI" w:date="2026-09-17T00:00:00Z"/>' +
'<w:p><w:pPr><w:rPr><w:' + kind + ' w:id="' + (base + 1) + '" w:author="AI"/></w:rPr></w:pPr>' +
(bookmark ? '<w:bookmarkStart w:id="' + (base + 2) + '" w:name="anchor' + base + '"/>' : '') +
body +
(bookmark ? '<w:bookmarkEnd w:id="' + (base + 2) + '"/>' : '') +
'</w:p><w:' + kind + 'RangeEnd w:id="' + base + '"/>';
const content = (kind: 'moveFrom' | 'moveTo', id: number, author = 'AI') =>
'<w:' + kind + ' w:id="' + id + '" w:author="' + author + '"><w:r><w:t>MOVED</w:t></w:r></w:' + kind + '>';
const stable = '<w:p><w:r><w:t>STABLE</w:t></w:r></w:p>';
const document = (body: string) => parseXml('<w:document xmlns:w="' + W + '"><w:body>' + body + '<w:sectPr/></w:body></w:document>');
const texts = (doc: Document) => Array.from(doc.getElementsByTagNameNS(W, 'p')).map(p =>
Array.from(p.getElementsByTagNameNS(W, 't')).map(t => t.textContent).join(''));

describe('native move-mark resolution without comparison coverage indirection', () => {
for (const terminal of [false, true]) {
for (const [name, resolve, expectedBookmark] of [
['accept', acceptChanges, '12'], ['reject', rejectChanges, '3'],
] as const) {
test(name + ' resolves ' + (terminal ? 'terminal' : 'middle') + ' move marks and local bookmark pairs', () => {
const from = endpoint('moveFrom', 1, content('moveFrom', 4));
const to = endpoint('moveTo', 10, content('moveTo', 13));
const doc = document(terminal ? from + stable + to : from + to + stable);
resolve(doc);
expect(texts(doc)).toEqual(name === 'accept' && terminal ? ['STABLE', 'MOVED'] : ['MOVED', 'STABLE']);
for (const kind of ['bookmarkStart', 'bookmarkEnd']) {
expect(Array.from(doc.getElementsByTagNameNS(W, kind)).map(n => n.getAttributeNS(W, 'id')))
.toEqual([expectedBookmark]);
}
for (const kind of ['moveFrom', 'moveTo', 'moveFromRangeStart', 'moveFromRangeEnd', 'moveToRangeStart', 'moveToRangeEnd']) {
expect(doc.getElementsByTagNameNS(W, kind)).toHaveLength(0);
}
});
}
}
for (const [name, body] of [
['empty', ''],
['untracked', '<w:r><w:t>KEPT</w:t></w:r>'],
['foreign move', content('moveTo', 13, 'Human')],
['mixed surviving content', content('moveTo', 13) + '<w:r><w:t>KEPT</w:t></w:r>'],
]) {
test('reject preserves local bookmarks around ' + name + ' content', () => {
const doc = document(endpoint('moveTo', 10, body!) + stable);
// Select id-only ends explicitly; author-only orphan ends remain in #941.
rejectChanges(doc, { filter: e => e.getAttributeNS(W, 'author') === 'AI' || e.localName.endsWith('RangeEnd') });
expect(Array.from(doc.getElementsByTagNameNS(W, 'bookmarkStart')).map(n => n.getAttributeNS(W, 'id'))).toEqual(['12']);
expect(Array.from(doc.getElementsByTagNameNS(W, 'bookmarkEnd')).map(n => n.getAttributeNS(W, 'id'))).toEqual(['12']);
expect(texts(doc)).toEqual([name === 'foreign move' ? 'MOVEDSTABLE' : name === 'empty' ? 'STABLE' : 'KEPTSTABLE']);
expect(doc.getElementsByTagNameNS(W, 'moveTo')).toHaveLength(name === 'foreign move' ? 1 : 0);
});
}
test('reject consumes local bookmark pairs around fully selected insertion content at a moved break', () => {
const doc = document(endpoint('moveTo', 10, '<w:ins w:id="13" w:author="AI"><w:r><w:t>INSERTED</w:t></w:r></w:ins>') + stable);
rejectChanges(doc);
expect(texts(doc)).toEqual(['STABLE']);
expect(doc.getElementsByTagNameNS(W, 'bookmarkStart')).toHaveLength(0);
expect(doc.getElementsByTagNameNS(W, 'bookmarkEnd')).toHaveLength(0);
});
for (const [name, body] of [
['empty', ''],
['untracked', '<w:r><w:t>KEPT</w:t></w:r>'],
['foreign move', content('moveFrom', 4, 'Human')],
['mixed surviving content', content('moveFrom', 4) + '<w:r><w:t>KEPT</w:t></w:r>'],
]) {
test('accept preserves local bookmarks around ' + name + ' content', () => {
const doc = document(endpoint('moveFrom', 1, body!) + stable);
// Select id-only ends explicitly; author-only orphan ends remain in #941.
acceptChanges(doc, { filter: e => e.getAttributeNS(W, 'author') === 'AI' || e.localName.endsWith('RangeEnd') });
for (const kind of ['bookmarkStart', 'bookmarkEnd']) {
expect(Array.from(doc.getElementsByTagNameNS(W, kind)).map(n => n.getAttributeNS(W, 'id'))).toEqual(['3']);
}
expect(texts(doc)).toEqual([name === 'foreign move' ? 'MOVEDSTABLE' : name === 'empty' ? 'STABLE' : 'KEPTSTABLE']);
expect(doc.getElementsByTagNameNS(W, 'moveFrom')).toHaveLength(name === 'foreign move' ? 1 : 0);
});
}
for (const [kind, resolve] of [['moveFrom', acceptChanges], ['moveTo', rejectChanges]] as const) {
test('consumes a wrapper-nested ' + kind + ' bookmark and its live counterpart', () => {
const nested = '<w:' + kind + ' w:id="4" w:author="AI"><w:bookmarkStart w:id="77" w:name="span"/>' +
'<w:r><w:t>MOVED</w:t></w:r></w:' + kind + '>';
const following = '<w:p><w:bookmarkEnd w:id="77"/><w:r><w:t>STABLE</w:t></w:r></w:p>';
const doc = document(endpoint(kind, 1, nested, false) + following);
resolve(doc);
expect(texts(doc)).toEqual(['STABLE']);
expect(doc.getElementsByTagNameNS(W, 'bookmarkStart')).toHaveLength(0);
expect(doc.getElementsByTagNameNS(W, 'bookmarkEnd')).toHaveLength(0);
});
}
test('reject does not harvest an untracked spanning start as a local moveTo pair', () => {
const body = '<w:bookmarkStart w:id="77" w:name="span"/>' + content('moveTo', 13);
const following = '<w:p><w:bookmarkEnd w:id="77"/><w:r><w:t>STABLE</w:t></w:r></w:p>';
const doc = document(endpoint('moveTo', 10, body) + following);
rejectChanges(doc);
expect(texts(doc)).toEqual(['STABLE']);
for (const kind of ['bookmarkStart', 'bookmarkEnd']) {
expect(Array.from(doc.getElementsByTagNameNS(W, kind)).map(n => n.getAttributeNS(W, 'id'))).toEqual(['77']);
}
});
});
Loading
Loading