@ansonlai/docx-redline-js 0.8.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +679 -460
- package/CHANGELOG.md +208 -1
- package/README.md +152 -16
- package/core/current-properties.js +136 -0
- package/core/paragraph-text.js +211 -209
- package/core/types.js +9 -3
- package/core/word-revision-semantics.js +256 -0
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +4338 -796
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +111 -108
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +4463 -921
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +4463 -921
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_FAST_START.md +2 -0
- package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
- package/docs/SKILL_AUTHORING.md +169 -150
- package/docs/TESTING.md +1146 -1059
- package/docs/schemas/document-operations.schema.json +55 -2
- package/document/docx-document.js +1022 -1010
- package/engine/oxml-engine.js +21 -22
- package/engine/paragraph-mark-revisions.js +292 -0
- package/engine/reconstruction-mapper.js +312 -311
- package/engine/reconstruction-writer.js +67 -10
- package/engine/rpr-helpers.js +42 -0
- package/engine/run-builders.js +0 -73
- package/engine/surgical-diff-application.js +604 -605
- package/engine/table-mode.js +63 -63
- package/index.d.ts +53 -2
- package/index.js +13 -2
- package/node/cli-help.js +1 -0
- package/node/cli.js +21 -3
- package/orchestration/list-structural-fallback.js +67 -9
- package/package.json +1 -1
- package/pipeline/diff-engine.js +130 -1
- package/pipeline/ingestion-export.js +227 -243
- package/pipeline/ingestion-paragraph.js +93 -98
- package/pipeline/list-generation.js +415 -419
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/pipeline.js +206 -204
- package/scripts/export-validation-fixtures.mjs +19 -6
- package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
- package/scripts/lib/word-coverage-catalogue.mjs +7 -2
- package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
- package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
- package/scripts/word-com-differential.ps1 +443 -335
- package/scripts/word-com-visual-suite.ps1 +2 -2
- package/scripts/word-revision-semantics-matrix.ps1 +108 -0
- package/services/batch-operation-orchestrator.js +23 -3
- package/services/capture-engine.js +228 -226
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +214 -0
- package/services/document-operation-contract.js +76 -0
- package/services/document-operation-mutations.js +688 -51
- package/services/error-recovery.js +17 -0
- package/services/inspection-comparison.js +82 -0
- package/services/list-conversion-diagnostics.js +296 -0
- package/services/list-format-change.js +586 -0
- package/services/list-format-mutation.js +416 -0
- package/services/list-format.js +305 -0
- package/services/numbering-allocation.js +642 -0
- package/services/numbering-helpers.js +354 -524
- package/services/operation-batch-compiler.js +183 -11
- package/services/operation-preflight.js +43 -33
- package/services/receipt-collector.js +288 -288
- package/services/revision-comment-management.js +38 -5
- package/services/revision-fidelity-verifier.js +484 -0
- package/services/standalone-docx-plumbing.js +627 -631
- package/services/standalone-operation-runner.d.ts +182 -5
- package/services/table-reconciliation.js +59 -7
|
@@ -7,14 +7,42 @@
|
|
|
7
7
|
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
8
8
|
import {
|
|
9
9
|
createTrackChange,
|
|
10
|
-
createFormattedRuns
|
|
11
|
-
markParagraphMarkDeleted,
|
|
12
|
-
markParagraphMarkInserted
|
|
10
|
+
createFormattedRuns
|
|
13
11
|
} from './run-builders.js';
|
|
12
|
+
import { markParagraphDeleted, markParagraphInserted } from './paragraph-mark-revisions.js';
|
|
14
13
|
import { getFirstElementByTagNSOrTag } from '../core/xml-query.js';
|
|
15
14
|
import { NS_W, createReplacementRevisionEvent, createRevisionMetadata } from '../core/types.js';
|
|
16
15
|
import { createWordElement, isWordElement } from '../core/word-xml.js';
|
|
17
16
|
|
|
17
|
+
const NS_W14 = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
18
|
+
const IDENTITY_ATTRIBUTES = ['paraId', 'textId'];
|
|
19
|
+
|
|
20
|
+
function hasMarkRevision(paragraph, kind) {
|
|
21
|
+
const pPr = Array.from(paragraph.childNodes).find(node => isWordElement(node, 'pPr'));
|
|
22
|
+
const rPr = pPr && Array.from(pPr.childNodes).find(node => isWordElement(node, 'rPr'));
|
|
23
|
+
return !!rPr && Array.from(rPr.childNodes).some(node => isWordElement(node, kind));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Carries the source paragraph identity (`w14:paraId`, `w14:textId`) onto the rebuilt paragraph that survives
|
|
28
|
+
* for it. Several rebuilt paragraphs can derive from one source paragraph (an inserted split), so the identity
|
|
29
|
+
* goes to exactly one: the paragraph whose mark is not tracked, else one whose mark is not tracked-deleted,
|
|
30
|
+
* else the first. Ids therefore stay unique; inserted paragraphs carry none, as on
|
|
31
|
+
* every other insertion path (Word assigns them on open).
|
|
32
|
+
*/
|
|
33
|
+
function carrySourceIdentity(claims) {
|
|
34
|
+
claims.forEach((rebuilt, source) => {
|
|
35
|
+
const survivor = rebuilt.find(p => !hasMarkRevision(p, 'ins') && !hasMarkRevision(p, 'del'))
|
|
36
|
+
|| rebuilt.find(p => !hasMarkRevision(p, 'del'))
|
|
37
|
+
|| rebuilt[0];
|
|
38
|
+
if (!survivor) return;
|
|
39
|
+
IDENTITY_ATTRIBUTES.forEach(name => {
|
|
40
|
+
const value = source.getAttributeNS?.(NS_W14, name) || source.getAttribute?.(`w14:${name}`);
|
|
41
|
+
if (value) survivor.setAttributeNS(NS_W14, `w14:${name}`, value);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
18
46
|
/**
|
|
19
47
|
* Applies diffs to reconstruction context and writes updated XML.
|
|
20
48
|
*
|
|
@@ -40,8 +68,13 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
40
68
|
isParagraphStart
|
|
41
69
|
} = context;
|
|
42
70
|
|
|
43
|
-
const
|
|
71
|
+
const identityClaims = new Map();
|
|
72
|
+
const createNewParagraph = (pPr, sourceParagraph = null) => {
|
|
44
73
|
const newParagraph = createWordElement(xmlDoc, 'w:p');
|
|
74
|
+
if (sourceParagraph) {
|
|
75
|
+
if (!identityClaims.has(sourceParagraph)) identityClaims.set(sourceParagraph, []);
|
|
76
|
+
identityClaims.get(sourceParagraph).push(newParagraph);
|
|
77
|
+
}
|
|
45
78
|
if (pPr) {
|
|
46
79
|
const clonedPPr = pPr.cloneNode(true);
|
|
47
80
|
// Safeguard: Never place w:sectPr on both paragraphs during split
|
|
@@ -55,7 +88,7 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
55
88
|
};
|
|
56
89
|
|
|
57
90
|
const startInfo = getParagraphInfo(0);
|
|
58
|
-
let currentParagraph = createNewParagraph(startInfo.pPr);
|
|
91
|
+
let currentParagraph = createNewParagraph(startInfo.pPr, startInfo.paragraph);
|
|
59
92
|
const initialFragment = containerFragments.get(startInfo.container);
|
|
60
93
|
if (initialFragment) {
|
|
61
94
|
initialFragment.appendChild(currentParagraph);
|
|
@@ -83,7 +116,7 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
83
116
|
if (diffs[k][0] === 0) break;
|
|
84
117
|
}
|
|
85
118
|
if (pairReplacements && generateRedlines && hasInsert) {
|
|
86
|
-
pendingReplacementEvent = createReplacementRevisionEvent(author, xmlDoc);
|
|
119
|
+
pendingReplacementEvent = createReplacementRevisionEvent(author, xmlDoc, { deferReceipt: true });
|
|
87
120
|
}
|
|
88
121
|
}
|
|
89
122
|
let offset = 0;
|
|
@@ -189,7 +222,7 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
189
222
|
const hasVisibleText = Array.from(node.getElementsByTagNameNS(NS_W, 't')).length > 0;
|
|
190
223
|
const hasDeletedText = Array.from(node.getElementsByTagNameNS(NS_W, 'delText')).length > 0;
|
|
191
224
|
if (paragraphs.length === 1 && !hasVisibleText && (hasDeletedText || context.originalFullText.trim() !== '')) {
|
|
192
|
-
|
|
225
|
+
markParagraphDeleted(node, { author, revisionIdAllocator: xmlDoc });
|
|
193
226
|
}
|
|
194
227
|
}
|
|
195
228
|
});
|
|
@@ -215,6 +248,8 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
215
248
|
}
|
|
216
249
|
});
|
|
217
250
|
|
|
251
|
+
carrySourceIdentity(identityClaims);
|
|
252
|
+
|
|
218
253
|
const paragraphSet = new Set(paragraphs);
|
|
219
254
|
const insertionAnchors = new Map();
|
|
220
255
|
paragraphs.forEach(paragraph => {
|
|
@@ -327,11 +362,11 @@ function appendTextToCurrent(
|
|
|
327
362
|
|
|
328
363
|
if (part === '\n') {
|
|
329
364
|
const info = getParagraphInfo(localBaseIndex + 1);
|
|
330
|
-
const nextParagraph = createNewParagraph(info.pPr);
|
|
365
|
+
const nextParagraph = createNewParagraph(info.pPr, info.paragraph);
|
|
331
366
|
if (generateRedlines && type === 'insert') {
|
|
332
|
-
|
|
367
|
+
markParagraphInserted(localParagraph, { author, revisionIdAllocator: xmlDoc });
|
|
333
368
|
} else if (generateRedlines && type === 'delete') {
|
|
334
|
-
|
|
369
|
+
markParagraphDeleted(localParagraph, { author, revisionIdAllocator: xmlDoc });
|
|
335
370
|
}
|
|
336
371
|
|
|
337
372
|
const fragment = containerFragments.get(info.container);
|
|
@@ -370,6 +405,26 @@ function appendTextToCurrent(
|
|
|
370
405
|
}
|
|
371
406
|
|
|
372
407
|
if (referenceMap.has(part)) {
|
|
408
|
+
if (type === 'delete' && generateRedlines) {
|
|
409
|
+
// A deleted manual break, tab or inline object must be a tracked deletion so Reject All
|
|
410
|
+
// restores it; silently dropping the node loses it from the rejected view.
|
|
411
|
+
const refNode = referenceMap.get(part);
|
|
412
|
+
if (refNode) {
|
|
413
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
414
|
+
if (rPr) run.appendChild(rPr.cloneNode(true));
|
|
415
|
+
run.appendChild(refNode.cloneNode(true));
|
|
416
|
+
let metadata = null;
|
|
417
|
+
if (replacementEvent) {
|
|
418
|
+
const id = replacementEvent.usedDeletionId
|
|
419
|
+
? createRevisionMetadata(author, xmlDoc, 'del').id
|
|
420
|
+
: replacementEvent.deletionId;
|
|
421
|
+
if (!replacementEvent.usedDeletionId) replacementEvent.recordUsed?.('del');
|
|
422
|
+
replacementEvent.usedDeletionId = true;
|
|
423
|
+
metadata = { id, author: replacementEvent.author, date: replacementEvent.date };
|
|
424
|
+
}
|
|
425
|
+
localParagraph.appendChild(createTrackChange(xmlDoc, 'del', run, author, metadata));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
373
428
|
if (type !== 'delete') {
|
|
374
429
|
const refNode = referenceMap.get(part);
|
|
375
430
|
if (refNode) {
|
|
@@ -408,6 +463,7 @@ function appendTextToCurrent(
|
|
|
408
463
|
const id = replacementEvent.usedDeletionId
|
|
409
464
|
? createRevisionMetadata(author, xmlDoc, 'del').id
|
|
410
465
|
: replacementEvent.deletionId;
|
|
466
|
+
if (!replacementEvent.usedDeletionId) replacementEvent.recordUsed?.('del');
|
|
411
467
|
replacementEvent.usedDeletionId = true;
|
|
412
468
|
metadata = {
|
|
413
469
|
id,
|
|
@@ -428,6 +484,7 @@ function appendTextToCurrent(
|
|
|
428
484
|
const id = replacementEvent.usedInsertionId
|
|
429
485
|
? createRevisionMetadata(author, xmlDoc, 'ins').id
|
|
430
486
|
: replacementEvent.insertionId;
|
|
487
|
+
if (!replacementEvent.usedInsertionId) replacementEvent.recordUsed?.('ins');
|
|
431
488
|
replacementEvent.usedInsertionId = true;
|
|
432
489
|
metadata = {
|
|
433
490
|
id,
|
package/engine/rpr-helpers.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createWordElement } from '../core/word-xml.js';
|
|
9
|
+
import { getCurrentChild } from '../core/current-properties.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Canonical OOXML run-property schema ordering.
|
|
@@ -419,9 +420,34 @@ export function checkParagraphPropertiesChanged(pPr, properties) {
|
|
|
419
420
|
if (currentVal !== properties.style) return true;
|
|
420
421
|
}
|
|
421
422
|
|
|
423
|
+
if (properties.numbering !== undefined) {
|
|
424
|
+
const current = readDirectNumbering(pPr);
|
|
425
|
+
const desired = properties.numbering;
|
|
426
|
+
if (desired === null) return current != null;
|
|
427
|
+
if (!current) return true;
|
|
428
|
+
if (current.numId !== String(Number(desired.numId))) return true;
|
|
429
|
+
if (current.level !== (Number(desired.level) || 0)) return true;
|
|
430
|
+
}
|
|
431
|
+
|
|
422
432
|
return false;
|
|
423
433
|
}
|
|
424
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Reads the paragraph's own (direct, non-snapshot) numbering binding.
|
|
437
|
+
*
|
|
438
|
+
* @param {Element|null} pPr
|
|
439
|
+
* @returns {{ numId: string, level: number }|null}
|
|
440
|
+
*/
|
|
441
|
+
function readDirectNumbering(pPr) {
|
|
442
|
+
const numPr = getCurrentChild(pPr, 'numPr');
|
|
443
|
+
if (!numPr) return null;
|
|
444
|
+
const read = (name) => {
|
|
445
|
+
const el = getCurrentChild(numPr, name);
|
|
446
|
+
return el ? (el.getAttribute('w:val') ?? el.getAttribute('val')) : null;
|
|
447
|
+
};
|
|
448
|
+
return { numId: String(Number(read('numId'))), level: Number(read('ilvl')) || 0 };
|
|
449
|
+
}
|
|
450
|
+
|
|
425
451
|
/**
|
|
426
452
|
* Applies explicit paragraph formatting to a pPr in schema order.
|
|
427
453
|
*
|
|
@@ -492,4 +518,20 @@ export function applyParagraphPropertiesToPPr(xmlDoc, pPr, properties) {
|
|
|
492
518
|
insertPPrChildInOrder(pPr, pStyle);
|
|
493
519
|
}
|
|
494
520
|
}
|
|
521
|
+
|
|
522
|
+
if (properties.numbering !== undefined) {
|
|
523
|
+
removeTags(['w:numPr']);
|
|
524
|
+
if (properties.numbering) {
|
|
525
|
+
// CT_PPr sequence: numPr follows widowControl and precedes suppressLineNumbers ... ind; inside
|
|
526
|
+
// CT_NumPr the order is ilvl, numId.
|
|
527
|
+
const numPr = createWordElement(xmlDoc, 'w:numPr');
|
|
528
|
+
const ilvl = createWordElement(xmlDoc, 'w:ilvl');
|
|
529
|
+
ilvl.setAttribute('w:val', String(Number(properties.numbering.level) || 0));
|
|
530
|
+
const numId = createWordElement(xmlDoc, 'w:numId');
|
|
531
|
+
numId.setAttribute('w:val', String(Number(properties.numbering.numId)));
|
|
532
|
+
numPr.appendChild(ilvl);
|
|
533
|
+
numPr.appendChild(numId);
|
|
534
|
+
insertPPrChildInOrder(pPr, numPr);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
495
537
|
}
|
package/engine/run-builders.js
CHANGED
|
@@ -33,79 +33,6 @@ export function createTrackChange(xmlDoc, type, run, author, revisionMetadata =
|
|
|
33
33
|
return wrapper;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function getDirectWordChild(node, localName) {
|
|
37
|
-
return Array.from(node?.childNodes || []).find(child => child.nodeType === 1
|
|
38
|
-
&& (child.localName === localName || child.nodeName === `w:${localName}`)) || null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function ensureParagraphProperties(xmlDoc, paragraph) {
|
|
42
|
-
let pPr = getDirectWordChild(paragraph, 'pPr');
|
|
43
|
-
if (!pPr) {
|
|
44
|
-
pPr = createWordElement(xmlDoc, 'w:pPr');
|
|
45
|
-
paragraph.insertBefore(pPr, paragraph.firstChild || null);
|
|
46
|
-
} else if (paragraph.firstChild !== pPr) {
|
|
47
|
-
paragraph.insertBefore(pPr, paragraph.firstChild || null);
|
|
48
|
-
}
|
|
49
|
-
return pPr;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function ensureParagraphMarkRunProperties(xmlDoc, pPr) {
|
|
53
|
-
let rPr = getDirectWordChild(pPr, 'rPr');
|
|
54
|
-
if (!rPr) {
|
|
55
|
-
rPr = createWordElement(xmlDoc, 'w:rPr');
|
|
56
|
-
pPr.appendChild(rPr);
|
|
57
|
-
} else if (pPr.lastChild !== rPr) {
|
|
58
|
-
pPr.appendChild(rPr);
|
|
59
|
-
}
|
|
60
|
-
return rPr;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function markParagraphMark(xmlDoc, paragraph, author, type, revisionMetadata = null) {
|
|
64
|
-
const pPr = ensureParagraphProperties(xmlDoc, paragraph);
|
|
65
|
-
const rPr = ensureParagraphMarkRunProperties(xmlDoc, pPr);
|
|
66
|
-
|
|
67
|
-
for (const child of Array.from(rPr.childNodes || [])) {
|
|
68
|
-
if (child.nodeType !== 1) continue;
|
|
69
|
-
if (child.localName === 'ins' || child.localName === 'del' || child.nodeName === 'w:ins' || child.nodeName === 'w:del') {
|
|
70
|
-
rPr.removeChild(child);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const marker = createWordElement(xmlDoc, type === 'ins' ? 'w:ins' : 'w:del');
|
|
75
|
-
const metadata = revisionMetadata || createRevisionMetadata(author, xmlDoc, type === 'ins' ? 'ins' : 'del');
|
|
76
|
-
marker.setAttribute('w:id', String(metadata.id));
|
|
77
|
-
marker.setAttribute('w:author', metadata.author);
|
|
78
|
-
marker.setAttribute('w:date', metadata.date);
|
|
79
|
-
rPr.appendChild(marker);
|
|
80
|
-
return marker;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Marks a paragraph mark as inserted.
|
|
85
|
-
*
|
|
86
|
-
* @param {Document} xmlDoc - XML document
|
|
87
|
-
* @param {Element} paragraph - Paragraph to mark
|
|
88
|
-
* @param {string} author - Change author
|
|
89
|
-
* @param {Object|null} [revisionMetadata=null] - Optional explicit revision metadata
|
|
90
|
-
* @returns {Element}
|
|
91
|
-
*/
|
|
92
|
-
export function markParagraphMarkInserted(xmlDoc, paragraph, author, revisionMetadata = null) {
|
|
93
|
-
return markParagraphMark(xmlDoc, paragraph, author, 'ins', revisionMetadata);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Marks a paragraph mark as deleted.
|
|
98
|
-
*
|
|
99
|
-
* @param {Document} xmlDoc - XML document
|
|
100
|
-
* @param {Element} paragraph - Paragraph to mark
|
|
101
|
-
* @param {string} author - Change author
|
|
102
|
-
* @param {Object|null} [revisionMetadata=null] - Optional explicit revision metadata
|
|
103
|
-
* @returns {Element}
|
|
104
|
-
*/
|
|
105
|
-
export function markParagraphMarkDeleted(xmlDoc, paragraph, author, revisionMetadata = null) {
|
|
106
|
-
return markParagraphMark(xmlDoc, paragraph, author, 'del', revisionMetadata);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
36
|
/**
|
|
110
37
|
* Creates a text run with optional formatting.
|
|
111
38
|
*
|