@ansonlai/docx-redline-js 0.4.0 → 0.5.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 (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -1,43 +1,45 @@
1
- /**
2
- * Configurable runtime defaults for the reconciliation core.
3
- * Callers can set these during bootstrap.
4
- */
5
-
6
- let _defaultAuthor = 'Author';
7
- let _platform = 'Unknown';
8
-
9
- /**
10
- * Set the default track-change author for revision metadata.
11
- *
12
- * @param {string} author
13
- */
14
- export function setDefaultAuthor(author) {
15
- _defaultAuthor = typeof author === 'string' && author.trim() ? author.trim() : 'Author';
16
- }
17
-
18
- /**
19
- * Get the current default track-change author.
20
- *
21
- * @returns {string}
22
- */
23
- export function getDefaultAuthor() {
24
- return _defaultAuthor;
25
- }
26
-
27
- /**
28
- * Set the platform identifier (e.g. 'Win32', 'Mac', 'OfficeOnline').
29
- *
30
- * @param {string} platform
31
- */
32
- export function setPlatform(platform) {
33
- _platform = typeof platform === 'string' && platform.trim() ? platform.trim() : 'Unknown';
34
- }
35
-
36
- /**
37
- * Get the current platform identifier.
38
- *
39
- * @returns {string}
40
- */
41
- export function getPlatform() {
42
- return _platform;
43
- }
1
+ /**
2
+ * Configurable runtime defaults for the reconciliation core.
3
+ * Callers can set these during bootstrap.
4
+ */
5
+
6
+ const fallbackAuthor = () => (typeof process !== 'undefined' && process.env?.DOCX_REDLINE_AUTHOR) || 'AI Redliner';
7
+
8
+ let _defaultAuthor = fallbackAuthor();
9
+ let _platform = 'Unknown';
10
+
11
+ /**
12
+ * Set the default track-change author for revision metadata.
13
+ *
14
+ * @param {string} author
15
+ */
16
+ export function setDefaultAuthor(author) {
17
+ _defaultAuthor = typeof author === 'string' && author.trim() ? author.trim() : fallbackAuthor();
18
+ }
19
+
20
+ /**
21
+ * Get the current default track-change author.
22
+ *
23
+ * @returns {string}
24
+ */
25
+ export function getDefaultAuthor() {
26
+ return _defaultAuthor;
27
+ }
28
+
29
+ /**
30
+ * Set the platform identifier (e.g. 'Win32', 'Mac', 'OfficeOnline').
31
+ *
32
+ * @param {string} platform
33
+ */
34
+ export function setPlatform(platform) {
35
+ _platform = typeof platform === 'string' && platform.trim() ? platform.trim() : 'Unknown';
36
+ }
37
+
38
+ /**
39
+ * Get the current platform identifier.
40
+ *
41
+ * @returns {string}
42
+ */
43
+ export function getPlatform() {
44
+ return _platform;
45
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../node/cli.js';
3
+ process.exitCode = await runCli();
@@ -7,6 +7,7 @@ import {
7
7
  getParagraphText,
8
8
  normalizeWhitespaceForTargeting
9
9
  } from './paragraph-targeting.js';
10
+ import { parseListItem, stripListMarker } from '../pipeline/list-markers.js';
10
11
 
11
12
  function getFirstDescendantByLocalName(node, localName) {
12
13
  if (!node || typeof node.getElementsByTagNameNS !== 'function') return null;
@@ -25,60 +26,50 @@ function readValAttribute(element) {
25
26
  return element.getAttribute('w:val') || element.getAttribute('val') || null;
26
27
  }
27
28
 
28
- function parseOutlineLevelFromMarker(marker) {
29
- const normalized = String(marker || '').trim();
30
- if (!/^\d+(?:\.\d+)+\.?$/.test(normalized)) return null;
31
- const parts = normalized.replace(/\.$/, '').split('.');
32
- return Math.max(0, parts.length - 1);
33
- }
34
-
35
- const REDUNDANT_LIST_PREFIX_REGEX = /^(?:(?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+/;
36
-
37
- /**
38
- * Strips redundant manual list markers from the start of list item text.
39
- *
40
- * Useful when model output contains doubled markers like:
41
- * - "2.1. - Item text"
42
- * - "- 2.1. Item text"
43
- *
44
- * @param {string} text - Candidate list item text
45
- * @returns {string}
46
- */
47
- export function stripRedundantLeadingListMarkers(text) {
48
- let value = String(text || '').trim();
49
- let passes = 0;
50
- while (passes < 4 && REDUNDANT_LIST_PREFIX_REGEX.test(value)) {
51
- value = value.replace(REDUNDANT_LIST_PREFIX_REGEX, '').trimStart();
52
- passes++;
53
- }
54
- return value.trim();
55
- }
56
-
57
- function parseModifiedListItems(modifiedText) {
58
- const rawLines = String(modifiedText || '').split(/\r?\n/g);
59
- const items = [];
60
- let hasListMarkers = false;
29
+ /**
30
+ * Strips redundant manual list markers from the start of list item text.
31
+ *
32
+ * Useful when model output contains doubled markers like:
33
+ * - "2.1. - Item text"
34
+ * - "- 2.1. Item text"
35
+ *
36
+ * @param {string} text - Candidate list item text
37
+ * @returns {string}
38
+ */
39
+ export function stripRedundantLeadingListMarkers(text) {
40
+ let value = String(text || '').trim();
41
+ let passes = 0;
42
+ while (passes < 4) {
43
+ const stripped = stripListMarker(value);
44
+ if (stripped === value) break;
45
+ value = stripped.trimStart();
46
+ passes++;
47
+ }
48
+ return value.trim();
49
+ }
50
+
51
+ function parseModifiedListItems(modifiedText) {
52
+ const rawLines = String(modifiedText || '').split(/\r?\n/g);
53
+ const items = [];
54
+ let hasListMarkers = false;
61
55
 
62
56
  for (const rawLine of rawLines) {
63
57
  const line = rawLine.trimEnd();
64
58
  if (!line.trim()) continue;
65
59
 
66
- const markerMatch = line.match(/^(\s*)((?:\d+(?:\.\d+)*\.?|\((?:\d+|[a-zA-Z]|[ivxlcIVXLC]+)\)|[a-zA-Z]\.|[ivxlcIVXLC]+\.|[-*+\u2022]))\s+(.*)$/);
67
- if (markerMatch) {
60
+ const parsed = parseListItem(line, { indentSpaces: 2 });
61
+ if (parsed) {
68
62
  hasListMarkers = true;
69
- const marker = markerMatch[2];
70
- const markerType = /^[-*+\u2022]$/.test(marker) ? 'bullet' : 'numbered';
71
- const level = Math.floor((markerMatch[1] || '').length / 2);
72
- items.push({
73
- kind: 'list',
74
- markerType,
75
- level,
76
- marker,
77
- outlineLevel: markerType === 'numbered' ? parseOutlineLevelFromMarker(marker) : null,
78
- text: stripRedundantLeadingListMarkers(markerMatch[3])
79
- });
80
- continue;
81
- }
63
+ items.push({
64
+ kind: 'list',
65
+ markerType: parsed.markerType,
66
+ level: parsed.level,
67
+ marker: parsed.marker,
68
+ outlineLevel: parsed.outlineLevel,
69
+ text: stripRedundantLeadingListMarkers(parsed.text)
70
+ });
71
+ continue;
72
+ }
82
73
 
83
74
  items.push({
84
75
  kind: 'text',
@@ -99,46 +90,46 @@ function isNormalizedTextEqual(a, b) {
99
90
  return normalizeWhitespaceForTargeting(a) === normalizeWhitespaceForTargeting(b);
100
91
  }
101
92
 
102
- function resolveInsertionLevel(item, anchorLevel, baselineLevel) {
103
- if (Number.isInteger(item?.outlineLevel)) {
104
- return Math.max(0, item.outlineLevel);
105
- }
106
- return Math.max(0, anchorLevel + ((item?.level || 0) - baselineLevel));
107
- }
108
-
109
- function shouldPromoteBulletInsertionsToChildDepth(parsedItems, normalizedTargetText, anchorLevel) {
110
- if (!Array.isArray(parsedItems) || parsedItems.length < 2) return false;
111
- if (!Number.isInteger(anchorLevel) || anchorLevel < 1) return false;
112
-
113
- const firstItem = parsedItems[0];
114
- const trailingListItems = parsedItems.slice(1).filter(item => item.kind === 'list');
115
- if (trailingListItems.length === 0) return false;
116
- if (trailingListItems.some(item => item.markerType !== 'bullet')) return false;
117
- if (trailingListItems.some(item => Number.isInteger(item.outlineLevel))) return false;
118
-
119
- if (firstItem?.kind === 'text') {
120
- return isNormalizedTextEqual(firstItem.text, normalizedTargetText);
121
- }
122
-
123
- if (firstItem?.kind === 'list' && firstItem.markerType === 'numbered') {
124
- const firstLevel = firstItem.level || 0;
125
- const alreadyIndented = trailingListItems.some(item => (item.level || 0) > firstLevel);
126
- if (alreadyIndented) return false;
127
- return isNormalizedTextEqual(firstItem.text, normalizedTargetText);
128
- }
129
-
130
- return false;
131
- }
132
-
133
- function promoteBulletInsertionsToChildDepth(entries, anchorLevel) {
134
- return entries.map(entry => {
135
- const relativeDepth = Math.max(0, (entry.ilvl || 0) - anchorLevel);
136
- return {
137
- ...entry,
138
- ilvl: Math.min(8, anchorLevel + 1 + relativeDepth)
139
- };
140
- });
141
- }
93
+ function resolveInsertionLevel(item, anchorLevel, baselineLevel) {
94
+ if (Number.isInteger(item?.outlineLevel)) {
95
+ return Math.max(0, item.outlineLevel);
96
+ }
97
+ return Math.max(0, anchorLevel + ((item?.level || 0) - baselineLevel));
98
+ }
99
+
100
+ function shouldPromoteBulletInsertionsToChildDepth(parsedItems, normalizedTargetText, anchorLevel) {
101
+ if (!Array.isArray(parsedItems) || parsedItems.length < 2) return false;
102
+ if (!Number.isInteger(anchorLevel) || anchorLevel < 0) return false;
103
+
104
+ const firstItem = parsedItems[0];
105
+ const trailingListItems = parsedItems.slice(1).filter(item => item.kind === 'list');
106
+ if (trailingListItems.length === 0) return false;
107
+ if (trailingListItems.some(item => item.markerType !== 'bullet')) return false;
108
+ if (trailingListItems.some(item => Number.isInteger(item.outlineLevel))) return false;
109
+
110
+ if (firstItem?.kind === 'text') {
111
+ return isNormalizedTextEqual(firstItem.text, normalizedTargetText);
112
+ }
113
+
114
+ if (firstItem?.kind === 'list' && firstItem.markerType === 'numbered') {
115
+ const firstLevel = firstItem.level || 0;
116
+ const alreadyIndented = trailingListItems.some(item => (item.level || 0) > firstLevel);
117
+ if (alreadyIndented) return false;
118
+ return isNormalizedTextEqual(firstItem.text, normalizedTargetText);
119
+ }
120
+
121
+ return false;
122
+ }
123
+
124
+ function promoteBulletInsertionsToChildDepth(entries, anchorLevel) {
125
+ return entries.map(entry => {
126
+ const relativeDepth = Math.max(0, (entry.ilvl || 0) - anchorLevel);
127
+ return {
128
+ ...entry,
129
+ ilvl: Math.min(8, anchorLevel + 1 + relativeDepth)
130
+ };
131
+ });
132
+ }
142
133
 
143
134
  function buildListEntriesForInsertion(parsedItems, normalizedTargetText, anchorLevel, defaultMarkerType) {
144
135
  const firstItem = parsedItems[0];
@@ -195,7 +186,7 @@ export function getParagraphListInfo(paragraph) {
195
186
  if (!numIdEl) return null;
196
187
 
197
188
  const numId = readValAttribute(numIdEl);
198
- if (!numId) return null;
189
+ if (!numId || numId === '0') return null;
199
190
 
200
191
  const ilvlEl = getFirstDescendantByLocalName(numPr, 'ilvl');
201
192
  const ilvlRaw = readValAttribute(ilvlEl);
@@ -361,7 +352,7 @@ export function synthesizeExpandedListScopeEdit(targetParagraph, modifiedText, o
361
352
  * }} [options] - Optional context/log callbacks
362
353
  * @returns {{ targetParagraph: Element, numId: string, entries: Array<{ ilvl: number, text: string, markerType: 'bullet'|'numbered' }> }|null}
363
354
  */
364
- export function planListInsertionOnlyEdit(targetParagraph, modifiedText, options = {}) {
355
+ export function planListInsertionOnlyEdit(targetParagraph, modifiedText, options = {}) {
365
356
  const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
366
357
  const onWarn = typeof options.onWarn === 'function' ? options.onWarn : () => {};
367
358
 
@@ -374,26 +365,26 @@ export function planListInsertionOnlyEdit(targetParagraph, modifiedText, options
374
365
  const parsed = parseModifiedListItems(rawModified);
375
366
  if (!parsed.hasListMarkers || parsed.items.length < 2) return null;
376
367
 
377
- const normalizedTargetText = normalizeWhitespaceForTargeting(
378
- options.currentParagraphText || getParagraphText(targetParagraph)
379
- );
380
- const listItemsOnly = parsed.items.filter(item => item.kind === 'list');
381
- const defaultMarkerType = listItemsOnly[0]?.markerType || 'bullet';
382
- const anchorLevel = Math.max(0, targetListInfo.ilvl);
383
- let entries = buildListEntriesForInsertion(parsed.items, normalizedTargetText, anchorLevel, defaultMarkerType);
384
-
385
- if (!entries || entries.length === 0) {
386
- onWarn('[List] Could not derive insertion-only entries from multiline list edit.');
387
- return null;
388
- }
389
-
390
- if (shouldPromoteBulletInsertionsToChildDepth(parsed.items, normalizedTargetText, anchorLevel)) {
391
- entries = promoteBulletInsertionsToChildDepth(entries, anchorLevel);
392
- onInfo('[List] Promoted bullet insertion to child depth for nested numbered-list intent.');
393
- }
394
-
395
- onInfo('[List] Planned insertion-only list redline entries (no block rewrite).');
396
- return {
368
+ const normalizedTargetText = normalizeWhitespaceForTargeting(
369
+ options.currentParagraphText || getParagraphText(targetParagraph)
370
+ );
371
+ const listItemsOnly = parsed.items.filter(item => item.kind === 'list');
372
+ const defaultMarkerType = listItemsOnly[0]?.markerType || 'bullet';
373
+ const anchorLevel = Math.max(0, targetListInfo.ilvl);
374
+ let entries = buildListEntriesForInsertion(parsed.items, normalizedTargetText, anchorLevel, defaultMarkerType);
375
+
376
+ if (!entries || entries.length === 0) {
377
+ onWarn('[List] Could not derive insertion-only entries from multiline list edit.');
378
+ return null;
379
+ }
380
+
381
+ if (shouldPromoteBulletInsertionsToChildDepth(parsed.items, normalizedTargetText, anchorLevel)) {
382
+ entries = promoteBulletInsertionsToChildDepth(entries, anchorLevel);
383
+ onInfo('[List] Promoted bullet insertion to child depth for nested numbered-list intent.');
384
+ }
385
+
386
+ onInfo('[List] Planned insertion-only list redline entries (no block rewrite).');
387
+ return {
397
388
  targetParagraph,
398
389
  numId: targetListInfo.numId,
399
390
  entries