@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.
Files changed (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. 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 createNewParagraph = (pPr) => {
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
- markParagraphMarkDeleted(xmlDoc, node, author);
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
- markParagraphMarkInserted(xmlDoc, localParagraph, author);
367
+ markParagraphInserted(localParagraph, { author, revisionIdAllocator: xmlDoc });
333
368
  } else if (generateRedlines && type === 'delete') {
334
- markParagraphMarkDeleted(xmlDoc, localParagraph, author);
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,
@@ -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
  }
@@ -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
  *