@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.
- package/AGENTS.md +589 -287
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +491 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +222 -37
- package/engine/surgical-mode.js +134 -6
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +317 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +24 -3
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +494 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +323 -0
- package/services/document-operation-mutations.js +1733 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
package/adapters/config.js
CHANGED
|
@@ -1,43 +1,45 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Configurable runtime defaults for the reconciliation core.
|
|
3
|
-
* Callers can set these during bootstrap.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|
package/core/list-targeting.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
67
|
-
if (
|
|
60
|
+
const parsed = parseListItem(line, { indentSpaces: 2 });
|
|
61
|
+
if (parsed) {
|
|
68
62
|
hasListMarkers = true;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 <
|
|
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
|