@ansonlai/docx-redline-js 0.8.3 → 0.9.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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  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/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  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 +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  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/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  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 +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -5,7 +5,9 @@
5
5
 
6
6
  import { createSerializer, parseOoxmlSafe } from '../adapters/xml-adapter.js';
7
7
  import { getXmlParseError } from '../core/xml-query.js';
8
- import { NS_W } from '../core/types.js';
8
+ import { NS_W, createRevisionMetadata } from '../core/types.js';
9
+ import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
10
+ import { getCurrentNumIdElements } from '../core/current-properties.js';
9
11
  import { createWordElement } from '../core/word-xml.js';
10
12
  import {
11
13
  getDocumentParagraphNodes,
@@ -62,12 +64,47 @@ export function stripSingleLineListMarkerPrefix(text) {
62
64
  return String(candidate.contentText || '').trim();
63
65
  }
64
66
 
65
- function parseMarkerStart(marker, numberingStyle) {
66
- if (numberingStyle !== 'decimal') return null;
67
- const match = String(marker || '').trim().match(/^(\d+)\.?$/);
68
- if (!match) return null;
69
- const value = Number.parseInt(match[1], 10);
70
- return Number.isFinite(value) && value > 0 ? value : null;
67
+ const ROMAN_VALUES = { i: 1, v: 5, x: 10, l: 50, c: 100, d: 500, m: 1000 };
68
+
69
+ function parseRomanNumeral(text) {
70
+ const letters = String(text || '').toLowerCase();
71
+ if (!/^[ivxlcdm]+$/.test(letters)) return null;
72
+ let total = 0;
73
+ for (let i = 0; i < letters.length; i++) {
74
+ const value = ROMAN_VALUES[letters[i]];
75
+ const next = ROMAN_VALUES[letters[i + 1]] || 0;
76
+ total += value < next ? -value : value;
77
+ }
78
+ return total;
79
+ }
80
+
81
+ /**
82
+ * Derives the numeric start a literal marker implies: `7.` -> 7, `C.` -> 3, `iv.` -> 4.
83
+ * Decimal markers keep every positive value (including 1). Letter and roman markers report only
84
+ * values above 1, because the default start (1) needs no override and keeps the shared-list behavior.
85
+ *
86
+ * @param {string} marker
87
+ * @param {string} numberingStyle
88
+ * @returns {number|null}
89
+ */
90
+ export function parseMarkerStart(marker, numberingStyle) {
91
+ const text = String(marker || '').trim();
92
+ let value = null;
93
+ if (numberingStyle === 'decimal') {
94
+ const match = text.match(/^(\d+)\.?$/);
95
+ value = match ? Number.parseInt(match[1], 10) : null;
96
+ } else if (numberingStyle === 'lowerRoman' || numberingStyle === 'upperRoman') {
97
+ const match = text.match(/^([A-Za-z]+)[.)]?$/);
98
+ value = match ? parseRomanNumeral(match[1]) : null;
99
+ } else if (numberingStyle === 'lowerAlpha' || numberingStyle === 'upperAlpha') {
100
+ const match = text.match(/^([A-Za-z])[.)]?$/);
101
+ value = match ? match[1].toLowerCase().charCodeAt(0) - 96 : null;
102
+ } else {
103
+ return null;
104
+ }
105
+ if (!Number.isFinite(value) || value < 1) return null;
106
+ if (numberingStyle !== 'decimal' && value === 1) return null;
107
+ return value;
71
108
  }
72
109
 
73
110
  /**
@@ -199,7 +236,8 @@ function getDirectWordChild(element, localName) {
199
236
  * numId: string|number,
200
237
  * ilvl?: number,
201
238
  * clearParagraphPropertyChanges?: boolean,
202
- * removeListPropertyNode?: boolean
239
+ * removeListPropertyNode?: boolean,
240
+ * trackPropertyChange?: { author?: string, revisionIdAllocator?: object|null }|null
203
241
  * }} options - Binding options
204
242
  * @returns {number} Number of paragraph nodes updated
205
243
  */
@@ -209,6 +247,7 @@ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
209
247
  const ilvl = Number.isInteger(options?.ilvl) ? Math.max(0, options.ilvl) : 0;
210
248
  const clearParagraphPropertyChanges = options?.clearParagraphPropertyChanges !== false;
211
249
  const removeListPropertyNode = options?.removeListPropertyNode !== false;
250
+ const trackPropertyChange = options?.trackPropertyChange || null;
212
251
 
213
252
  const paragraphs = (Array.isArray(nodes) ? nodes : [])
214
253
  .filter(node => node && node.nodeType === 1 && node.localName === 'p');
@@ -224,6 +263,9 @@ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
224
263
  paragraph.insertBefore(pPr, paragraph.firstChild);
225
264
  }
226
265
 
266
+ // Snapshot the pre-binding pPr so Reject All restores the unnumbered paragraph.
267
+ const priorPPr = trackPropertyChange ? clonePropertiesWithoutRevisionHistory(pPr) : null;
268
+
227
269
  if (clearParagraphPropertyChanges) {
228
270
  const pPrChange = getDirectWordChild(pPr, 'pPrChange');
229
271
  if (pPrChange) pPr.removeChild(pPrChange);
@@ -253,6 +295,20 @@ export function enforceListBindingOnParagraphNodes(nodes, options = {}) {
253
295
  numPr.appendChild(numIdEl);
254
296
  }
255
297
  numIdEl.setAttribute('w:val', String(numId));
298
+
299
+ if (trackPropertyChange && !getDirectWordChild(pPr, 'pPrChange')) {
300
+ const metadata = createRevisionMetadata(
301
+ trackPropertyChange.author,
302
+ trackPropertyChange.revisionIdAllocator || ownerDoc,
303
+ 'pPrChange'
304
+ );
305
+ const pPrChange = createWordElement(ownerDoc, 'w:pPrChange');
306
+ pPrChange.setAttribute('w:id', String(metadata.id));
307
+ pPrChange.setAttribute('w:author', metadata.author);
308
+ pPrChange.setAttribute('w:date', metadata.date);
309
+ pPrChange.appendChild(priorPPr || createWordElement(ownerDoc, 'w:pPr'));
310
+ pPr.appendChild(pPrChange);
311
+ }
256
312
  updated++;
257
313
  }
258
314
 
@@ -311,7 +367,8 @@ function extractFirstParagraphNumIdFromOxml(oxml) {
311
367
  // Tracked-change output leads with a deleted-original paragraph that carries
312
368
  // no numPr, so the generated list binding is the first paragraph that has one.
313
369
  for (const paragraph of paragraphs) {
314
- const numIdNodes = Array.from(paragraph.getElementsByTagNameNS('*', 'numId'));
370
+ // Current numIds only; a pPrChange snapshot must not supply the generated list binding.
371
+ const numIdNodes = getCurrentNumIdElements(paragraph);
315
372
  for (const numIdNode of numIdNodes) {
316
373
  const numId = getElementId(numIdNode, ['w:val', 'val']);
317
374
  if (numId != null) return String(numId);
@@ -433,6 +490,7 @@ export function buildSingleLineListStructuralFallbackPlan(options = {}) {
433
490
  numberingKey: `${modifiedCandidate.type}:${modifiedCandidate.numberingStyle}:single`,
434
491
  originalText,
435
492
  wasListParagraph: !!existingListInfo,
493
+ numberingStyle: modifiedCandidate.numberingStyle,
436
494
  startAt: modifiedCandidate.startAt
437
495
  };
438
496
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,9 +41,15 @@ export function createDiffEngine(options = {}) {
41
41
  return engine;
42
42
  }
43
43
 
44
+ // Opening and closing punctuation that hugs a word. Splitting it into its own
45
+ // token lets "agents and attorneys" -> "agents, attorneys," keep both words
46
+ // unchanged instead of replacing the whole phrase.
47
+ const LEADING_PUNCTUATION = /^[("'\[{“‘]+/u;
48
+ const TRAILING_PUNCTUATION = /[)"'\]}”’,.;:!?]+$/u;
49
+
44
50
  function tokenize(text, atomicChars = null) {
45
51
  const tokens = [];
46
- const pushWord = word => {
52
+ const pushPiece = word => {
47
53
  if (!atomicChars || atomicChars.size === 0) {
48
54
  tokens.push(word);
49
55
  return;
@@ -59,6 +65,20 @@ function tokenize(text, atomicChars = null) {
59
65
  }
60
66
  if (start < word.length) tokens.push(word.slice(start));
61
67
  };
68
+ const pushWord = word => {
69
+ const leading = word.match(LEADING_PUNCTUATION)?.[0] || '';
70
+ const rest = word.slice(leading.length);
71
+ const trailing = rest.match(TRAILING_PUNCTUATION)?.[0] || '';
72
+ const core = rest.slice(0, rest.length - trailing.length);
73
+ // A token made only of punctuation stays whole.
74
+ if (!core) {
75
+ pushPiece(word);
76
+ return;
77
+ }
78
+ for (const char of leading) pushPiece(char);
79
+ pushPiece(core);
80
+ for (const char of trailing) pushPiece(char);
81
+ };
62
82
  const leading = text.match(/^\s+/);
63
83
  if (leading) tokens.push(leading[0]);
64
84
 
@@ -243,11 +263,120 @@ export function computeWordDiffs(originalText, newText, options = {}) {
243
263
  const charDiffs = dmp.diff_main(encodeTokenIdsForDmp(tokenIds1), encodeTokenIdsForDmp(tokenIds2));
244
264
  if (cleanupSemantic) {
245
265
  dmp.diff_cleanupSemantic(charDiffs);
266
+ refineReplacementsAroundAnchors(dmp, charDiffs, wordArray);
246
267
  }
247
268
 
248
269
  return decodeBmpDiffs(charDiffs, wordArray);
249
270
  }
250
271
 
272
+ // A token that is a substantive word: four or more letters or digits. Short
273
+ // function words ("a", "the", "and") and punctuation never anchor on their own.
274
+ const ANCHOR_TOKEN = /^[\p{L}\p{N}][\p{L}\p{N}'’-]{3,}$/u;
275
+
276
+ function isAnchorEquality(chars, wordArray) {
277
+ for (let index = 0; index < chars.length; index++) {
278
+ if (ANCHOR_TOKEN.test(wordArray[bmpCharToTokenId(chars.charCodeAt(index))])) return true;
279
+ }
280
+ return false;
281
+ }
282
+
283
+ const MAX_ANCHOR_REFINEMENT_CELLS = 250000;
284
+
285
+ /**
286
+ * Token LCS that weights substantive words far above whitespace, punctuation
287
+ * and function words. A plain LCS would rather keep " and " (three tokens)
288
+ * than "attorneys" (one token), which is the opposite of a useful redline.
289
+ * Returns null when the replacement is too large to refine cheaply.
290
+ *
291
+ * @param {string} deleted - BMP-encoded deleted tokens
292
+ * @param {string} inserted - BMP-encoded inserted tokens
293
+ * @param {string[]} wordArray
294
+ * @returns {Array<[number, string]>|null}
295
+ */
296
+ function anchorWeightedDiff(deleted, inserted, wordArray) {
297
+ const rows = deleted.length;
298
+ const cols = inserted.length;
299
+ if (!rows || !cols || (rows + 1) * (cols + 1) > MAX_ANCHOR_REFINEMENT_CELLS) return null;
300
+
301
+ const weight = char => {
302
+ const token = wordArray[bmpCharToTokenId(char.charCodeAt(0))];
303
+ return ANCHOR_TOKEN.test(token) ? token.length * 100 : token.length;
304
+ };
305
+ const width = cols + 1;
306
+ const scores = new Float64Array((rows + 1) * width);
307
+ for (let row = rows - 1; row >= 0; row--) {
308
+ for (let col = cols - 1; col >= 0; col--) {
309
+ const skip = Math.max(scores[(row + 1) * width + col], scores[row * width + col + 1]);
310
+ scores[row * width + col] = deleted[row] === inserted[col]
311
+ ? Math.max(skip, weight(deleted[row]) + scores[(row + 1) * width + col + 1])
312
+ : skip;
313
+ }
314
+ }
315
+
316
+ const result = [];
317
+ const push = (op, char) => {
318
+ const last = result[result.length - 1];
319
+ if (last?.[0] === op) last[1] += char;
320
+ else result.push([op, char]);
321
+ };
322
+ let row = 0;
323
+ let col = 0;
324
+ while (row < rows && col < cols) {
325
+ const here = scores[row * width + col];
326
+ if (deleted[row] === inserted[col]
327
+ && here === weight(deleted[row]) + scores[(row + 1) * width + col + 1]) {
328
+ push(0, deleted[row]);
329
+ row++;
330
+ col++;
331
+ } else if (scores[(row + 1) * width + col] >= scores[row * width + col + 1]) {
332
+ push(-1, deleted[row++]);
333
+ } else {
334
+ push(1, inserted[col++]);
335
+ }
336
+ }
337
+ while (row < rows) push(-1, deleted[row++]);
338
+ while (col < cols) push(1, inserted[col++]);
339
+ return result;
340
+ }
341
+
342
+ /**
343
+ * Semantic cleanup measures equalities in tokens, so a single unchanged word
344
+ * between two edits ("agents[ and] attorneys[, accountants ...]") is folded
345
+ * into one whole-phrase replacement. Re-diff each replacement and keep any
346
+ * unchanged run that contains a substantive word, so the redline only marks
347
+ * the text that actually changed.
348
+ */
349
+ function refineReplacementsAroundAnchors(dmp, diffs, wordArray) {
350
+ for (let index = 0; index < diffs.length; index++) {
351
+ let deleteIndex = -1;
352
+ let insertIndex = -1;
353
+ if (diffs[index][0] === -1 && diffs[index + 1]?.[0] === 1) {
354
+ deleteIndex = index;
355
+ insertIndex = index + 1;
356
+ } else if (diffs[index][0] === 1 && diffs[index + 1]?.[0] === -1) {
357
+ insertIndex = index;
358
+ deleteIndex = index + 1;
359
+ } else {
360
+ continue;
361
+ }
362
+
363
+ const inner = anchorWeightedDiff(diffs[deleteIndex][1], diffs[insertIndex][1], wordArray);
364
+ if (!inner?.some(([op, chars]) => op === 0 && isAnchorEquality(chars, wordArray))) continue;
365
+
366
+ const refined = [];
367
+ for (const [op, chars] of inner) {
368
+ if (op === 0 && !isAnchorEquality(chars, wordArray)) {
369
+ refined.push([-1, chars], [1, chars]);
370
+ } else {
371
+ refined.push([op, chars]);
372
+ }
373
+ }
374
+ dmp.diff_cleanupMerge(refined);
375
+ diffs.splice(index, 2, ...refined);
376
+ index += refined.length - 1;
377
+ }
378
+ }
379
+
251
380
  /**
252
381
  * Computes a character-local diff without semantic cleanup. This is used to
253
382
  * refine whitespace-only substitutions that a word-level token groups with