@ansonlai/docx-redline-js 0.4.0 → 0.5.1
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 +646 -288
- 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/redline-validation.js +11 -5
- 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 +3145 -505
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +88 -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/2026-09-08-cross-author-revision-slicing.md +505 -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 +508 -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 +407 -50
- package/engine/surgical-mode.js +142 -6
- package/engine/surgical-run-splitting.js +103 -0
- 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 +322 -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 +123 -102
- 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-cross-author-slicing-fixtures.ps1 +256 -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 +513 -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 +345 -0
- package/services/document-operation-mutations.js +1749 -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 +390 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +77 -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
|
@@ -6,21 +6,21 @@ import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
|
6
6
|
import { buildReconstructionMapping, findReconstructionParagraphRange } from './reconstruction-mapper.js';
|
|
7
7
|
import { applyReconstructionDiffs } from './reconstruction-writer.js';
|
|
8
8
|
import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Applies reconstruction mode reconciliation.
|
|
12
|
-
*
|
|
13
|
-
* @param {Document} xmlDoc - XML document
|
|
14
|
-
* @param {string} originalText - Original text (kept for signature compatibility)
|
|
15
|
-
* @param {string} modifiedText - Modified text
|
|
16
|
-
* @param {XMLSerializer} serializer - Serializer instance
|
|
17
|
-
* @param {string} author - Author name
|
|
18
|
-
* @param {Array} formatHints - Format hints
|
|
19
|
-
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Applies reconstruction mode reconciliation.
|
|
12
|
+
*
|
|
13
|
+
* @param {Document} xmlDoc - XML document
|
|
14
|
+
* @param {string} originalText - Original text (kept for signature compatibility)
|
|
15
|
+
* @param {string} modifiedText - Modified text
|
|
16
|
+
* @param {XMLSerializer} serializer - Serializer instance
|
|
17
|
+
* @param {string} author - Author name
|
|
18
|
+
* @param {Array} formatHints - Format hints
|
|
19
|
+
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
20
20
|
* @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
|
|
21
21
|
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
22
22
|
*/
|
|
23
|
-
export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, diffOptions = {}) {
|
|
23
|
+
export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, diffOptions = {}, options = {}) {
|
|
24
24
|
const selectedParagraphs = findReconstructionParagraphRange(xmlDoc, originalText);
|
|
25
25
|
if (selectedParagraphs === null) {
|
|
26
26
|
return withOoxmlSourceType({
|
|
@@ -38,7 +38,7 @@ export function applyReconstructionMode(xmlDoc, originalText, modifiedText, seri
|
|
|
38
38
|
if (mapping.paragraphs.length === 0) {
|
|
39
39
|
return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
|
|
42
42
|
const diffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText, diffOptions);
|
|
43
43
|
|
|
44
44
|
return withOoxmlSourceType(applyReconstructionDiffs(
|
|
@@ -48,6 +48,7 @@ export function applyReconstructionMode(xmlDoc, originalText, modifiedText, seri
|
|
|
48
48
|
serializer,
|
|
49
49
|
author,
|
|
50
50
|
formatHints,
|
|
51
|
-
generateRedlines
|
|
51
|
+
generateRedlines,
|
|
52
|
+
options
|
|
52
53
|
));
|
|
53
54
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reconstruction writer.
|
|
3
|
-
*
|
|
4
|
-
* Applies diff segments to mapped reconstruction context and writes updated DOM content.
|
|
5
|
-
*/
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Reconstruction writer.
|
|
3
|
+
*
|
|
4
|
+
* Applies diff segments to mapped reconstruction context and writes updated DOM content.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
7
|
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
8
8
|
import {
|
|
9
9
|
createTrackChange,
|
|
@@ -12,41 +12,49 @@ import {
|
|
|
12
12
|
markParagraphMarkInserted
|
|
13
13
|
} from './run-builders.js';
|
|
14
14
|
import { getFirstElementByTagNSOrTag } from '../core/xml-query.js';
|
|
15
|
-
import { NS_W } from '../core/types.js';
|
|
15
|
+
import { NS_W, createReplacementRevisionEvent, createRevisionMetadata } from '../core/types.js';
|
|
16
16
|
import { createWordElement, isWordElement } from '../core/word-xml.js';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Applies diffs to reconstruction context and writes updated XML.
|
|
20
|
-
*
|
|
21
|
-
* @param {Document} xmlDoc - XML document
|
|
22
|
-
* @param {Array<[number, string]>} diffs - Diff tuples from diff-match-patch
|
|
23
|
-
* @param {ReturnType<import('./reconstruction-mapper.js').buildReconstructionMapping>} context - Reconstruction mapping
|
|
24
|
-
* @param {XMLSerializer} serializer - Serializer instance
|
|
25
|
-
* @param {string} author - Author name
|
|
26
|
-
* @param {Array} formatHints - Format hints
|
|
27
|
-
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
28
|
-
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
29
|
-
*/
|
|
30
|
-
export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, author, formatHints, generateRedlines = true) {
|
|
31
|
-
const {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Applies diffs to reconstruction context and writes updated XML.
|
|
20
|
+
*
|
|
21
|
+
* @param {Document} xmlDoc - XML document
|
|
22
|
+
* @param {Array<[number, string]>} diffs - Diff tuples from diff-match-patch
|
|
23
|
+
* @param {ReturnType<import('./reconstruction-mapper.js').buildReconstructionMapping>} context - Reconstruction mapping
|
|
24
|
+
* @param {XMLSerializer} serializer - Serializer instance
|
|
25
|
+
* @param {string} author - Author name
|
|
26
|
+
* @param {Array} formatHints - Format hints
|
|
27
|
+
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
28
|
+
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
29
|
+
*/
|
|
30
|
+
export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, author, formatHints, generateRedlines = true, options = {}) {
|
|
31
|
+
const {
|
|
32
32
|
paragraphs,
|
|
33
33
|
containerFragments,
|
|
34
|
-
sentinelMapByStart,
|
|
35
|
-
referenceMap,
|
|
36
|
-
replacementContainers,
|
|
37
|
-
getParagraphInfo,
|
|
38
|
-
getRunProperties,
|
|
39
|
-
getPropertySpanLength,
|
|
40
|
-
isParagraphStart
|
|
34
|
+
sentinelMapByStart,
|
|
35
|
+
referenceMap,
|
|
36
|
+
replacementContainers,
|
|
37
|
+
getParagraphInfo,
|
|
38
|
+
getRunProperties,
|
|
39
|
+
getPropertySpanLength,
|
|
40
|
+
isParagraphStart
|
|
41
41
|
} = context;
|
|
42
42
|
|
|
43
43
|
const createNewParagraph = (pPr) => {
|
|
44
44
|
const newParagraph = createWordElement(xmlDoc, 'w:p');
|
|
45
|
-
if (pPr)
|
|
45
|
+
if (pPr) {
|
|
46
|
+
const clonedPPr = pPr.cloneNode(true);
|
|
47
|
+
// Safeguard: Never place w:sectPr on both paragraphs during split
|
|
48
|
+
const sectPr = getFirstElementByTagNSOrTag(clonedPPr, NS_W, 'sectPr');
|
|
49
|
+
if (sectPr) {
|
|
50
|
+
clonedPPr.removeChild(sectPr);
|
|
51
|
+
}
|
|
52
|
+
newParagraph.appendChild(clonedPPr);
|
|
53
|
+
}
|
|
46
54
|
return newParagraph;
|
|
47
55
|
};
|
|
48
|
-
|
|
49
|
-
const startInfo = getParagraphInfo(0);
|
|
56
|
+
|
|
57
|
+
const startInfo = getParagraphInfo(0);
|
|
50
58
|
let currentParagraph = createNewParagraph(startInfo.pPr);
|
|
51
59
|
const initialFragment = containerFragments.get(startInfo.container);
|
|
52
60
|
if (initialFragment) {
|
|
@@ -55,61 +63,69 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
55
63
|
|
|
56
64
|
let currentOriginalIndex = 0;
|
|
57
65
|
let currentInsertOffset = 0;
|
|
58
|
-
|
|
59
|
-
// insertion. Remember where that deleted range began so replacement text
|
|
60
|
-
// inherits the formatting at the start of the range, rather than the last
|
|
61
|
-
// deleted character (which may be a superscript ordinal, footnote-style
|
|
62
|
-
// run, or another narrow formatting boundary).
|
|
66
|
+
const pairReplacements = options?.pairReplacements === true;
|
|
63
67
|
let pendingReplacementStart = null;
|
|
68
|
+
let pendingReplacementEvent = null;
|
|
64
69
|
const emittedCommentMarkers = new WeakSet();
|
|
65
70
|
|
|
66
|
-
for (
|
|
71
|
+
for (let diffIndex = 0; diffIndex < diffs.length; diffIndex++) {
|
|
72
|
+
const [op, text] = diffs[diffIndex];
|
|
67
73
|
if (op === 0 || op === -1) {
|
|
68
74
|
const type = op === 0 ? 'equal' : 'delete';
|
|
69
75
|
if (op === 0) {
|
|
70
76
|
pendingReplacementStart = null;
|
|
77
|
+
pendingReplacementEvent = null;
|
|
71
78
|
} else if (pendingReplacementStart === null) {
|
|
72
79
|
pendingReplacementStart = currentOriginalIndex;
|
|
80
|
+
let hasInsert = false;
|
|
81
|
+
for (let k = diffIndex + 1; k < diffs.length; k++) {
|
|
82
|
+
if (diffs[k][0] === 1) { hasInsert = true; break; }
|
|
83
|
+
if (diffs[k][0] === 0) break;
|
|
84
|
+
}
|
|
85
|
+
if (pairReplacements && generateRedlines && hasInsert) {
|
|
86
|
+
pendingReplacementEvent = createReplacementRevisionEvent(author, xmlDoc);
|
|
87
|
+
}
|
|
73
88
|
}
|
|
74
89
|
let offset = 0;
|
|
75
|
-
|
|
76
|
-
while (offset < text.length) {
|
|
77
|
-
const chunkStart = currentOriginalIndex + offset;
|
|
78
|
-
const properties = getRunProperties(chunkStart);
|
|
79
|
-
const chunkLength = getPropertySpanLength(chunkStart, text.length - offset);
|
|
80
|
-
const chunk = text.substring(offset, offset + chunkLength);
|
|
81
|
-
|
|
90
|
+
|
|
91
|
+
while (offset < text.length) {
|
|
92
|
+
const chunkStart = currentOriginalIndex + offset;
|
|
93
|
+
const properties = getRunProperties(chunkStart);
|
|
94
|
+
const chunkLength = getPropertySpanLength(chunkStart, text.length - offset);
|
|
95
|
+
const chunk = text.substring(offset, offset + chunkLength);
|
|
96
|
+
|
|
82
97
|
const appendResult = appendTextToCurrent(
|
|
83
98
|
xmlDoc,
|
|
84
99
|
chunk,
|
|
85
|
-
type,
|
|
86
|
-
properties.rPr,
|
|
87
|
-
properties.wrapper,
|
|
88
|
-
chunkStart,
|
|
89
|
-
currentParagraph,
|
|
90
|
-
containerFragments,
|
|
91
|
-
sentinelMapByStart,
|
|
92
|
-
referenceMap,
|
|
93
|
-
replacementContainers,
|
|
94
|
-
getParagraphInfo,
|
|
95
|
-
createNewParagraph,
|
|
96
|
-
author,
|
|
100
|
+
type,
|
|
101
|
+
properties.rPr,
|
|
102
|
+
properties.wrapper,
|
|
103
|
+
chunkStart,
|
|
104
|
+
currentParagraph,
|
|
105
|
+
containerFragments,
|
|
106
|
+
sentinelMapByStart,
|
|
107
|
+
referenceMap,
|
|
108
|
+
replacementContainers,
|
|
109
|
+
getParagraphInfo,
|
|
110
|
+
createNewParagraph,
|
|
111
|
+
author,
|
|
97
112
|
formatHints,
|
|
98
113
|
currentInsertOffset,
|
|
99
114
|
generateRedlines,
|
|
100
|
-
emittedCommentMarkers
|
|
115
|
+
emittedCommentMarkers,
|
|
116
|
+
pendingReplacementEvent
|
|
101
117
|
);
|
|
102
118
|
currentParagraph = appendResult.currentParagraph;
|
|
103
119
|
|
|
104
120
|
if (op === 0) {
|
|
105
121
|
currentInsertOffset += chunkLength;
|
|
106
|
-
}
|
|
107
|
-
offset += chunkLength;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
currentOriginalIndex += text.length;
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
122
|
+
}
|
|
123
|
+
offset += chunkLength;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
currentOriginalIndex += text.length;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
113
129
|
|
|
114
130
|
if (op === 1) {
|
|
115
131
|
const propertyIndex = pendingReplacementStart !== null
|
|
@@ -118,33 +134,69 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
|
|
|
118
134
|
? currentOriginalIndex - 1
|
|
119
135
|
: currentOriginalIndex);
|
|
120
136
|
const properties = getRunProperties(propertyIndex);
|
|
121
|
-
|
|
137
|
+
|
|
122
138
|
const appendResult = appendTextToCurrent(
|
|
123
139
|
xmlDoc,
|
|
124
140
|
text,
|
|
125
|
-
'insert',
|
|
126
|
-
properties.rPr,
|
|
127
|
-
properties.wrapper,
|
|
128
|
-
currentOriginalIndex,
|
|
129
|
-
currentParagraph,
|
|
130
|
-
containerFragments,
|
|
131
|
-
sentinelMapByStart,
|
|
132
|
-
referenceMap,
|
|
133
|
-
replacementContainers,
|
|
134
|
-
getParagraphInfo,
|
|
135
|
-
createNewParagraph,
|
|
136
|
-
author,
|
|
141
|
+
'insert',
|
|
142
|
+
properties.rPr,
|
|
143
|
+
properties.wrapper,
|
|
144
|
+
currentOriginalIndex,
|
|
145
|
+
currentParagraph,
|
|
146
|
+
containerFragments,
|
|
147
|
+
sentinelMapByStart,
|
|
148
|
+
referenceMap,
|
|
149
|
+
replacementContainers,
|
|
150
|
+
getParagraphInfo,
|
|
151
|
+
createNewParagraph,
|
|
152
|
+
author,
|
|
137
153
|
formatHints,
|
|
138
154
|
currentInsertOffset,
|
|
139
155
|
generateRedlines,
|
|
140
|
-
emittedCommentMarkers
|
|
156
|
+
emittedCommentMarkers,
|
|
157
|
+
pendingReplacementEvent,
|
|
158
|
+
pendingReplacementStart !== null
|
|
141
159
|
);
|
|
142
160
|
currentParagraph = appendResult.currentParagraph;
|
|
143
161
|
currentInsertOffset += text.length;
|
|
144
162
|
pendingReplacementStart = null;
|
|
163
|
+
pendingReplacementEvent = null;
|
|
145
164
|
}
|
|
146
165
|
}
|
|
147
|
-
|
|
166
|
+
|
|
167
|
+
if (generateRedlines) {
|
|
168
|
+
containerFragments.forEach(fragment => {
|
|
169
|
+
Array.from(fragment.childNodes).forEach(node => {
|
|
170
|
+
if (isWordElement(node, 'p')) {
|
|
171
|
+
const hasVisibleText = Array.from(node.getElementsByTagNameNS(NS_W, 't')).length > 0;
|
|
172
|
+
const hasDeletedText = Array.from(node.getElementsByTagNameNS(NS_W, 'delText')).length > 0;
|
|
173
|
+
if (paragraphs.length === 1 && !hasVisibleText && (hasDeletedText || context.originalFullText.trim() !== '')) {
|
|
174
|
+
markParagraphMarkDeleted(xmlDoc, node, author);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
containerFragments.forEach(fragment => {
|
|
182
|
+
const createdParagraphs = Array.from(fragment.childNodes).filter(node => isWordElement(node, 'p'));
|
|
183
|
+
if (createdParagraphs.length > 1) {
|
|
184
|
+
const firstP = createdParagraphs[0];
|
|
185
|
+
const lastP = createdParagraphs[createdParagraphs.length - 1];
|
|
186
|
+
const firstPPr = getFirstElementByTagNSOrTag(firstP, NS_W, 'pPr');
|
|
187
|
+
const sectPr = firstPPr ? getFirstElementByTagNSOrTag(firstPPr, NS_W, 'sectPr') : null;
|
|
188
|
+
if (sectPr) {
|
|
189
|
+
firstPPr.removeChild(sectPr);
|
|
190
|
+
let lastPPr = getFirstElementByTagNSOrTag(lastP, NS_W, 'pPr');
|
|
191
|
+
if (!lastPPr) {
|
|
192
|
+
lastPPr = createWordElement(xmlDoc, 'w:pPr');
|
|
193
|
+
lastP.insertBefore(lastPPr, lastP.firstChild || null);
|
|
194
|
+
}
|
|
195
|
+
lastPPr.appendChild(sectPr);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
148
200
|
const paragraphSet = new Set(paragraphs);
|
|
149
201
|
const insertionAnchors = new Map();
|
|
150
202
|
paragraphs.forEach(paragraph => {
|
|
@@ -213,29 +265,48 @@ function appendTextToCurrent(
|
|
|
213
265
|
formatHints = [],
|
|
214
266
|
insertOffset = 0,
|
|
215
267
|
generateRedlines = true,
|
|
216
|
-
emittedCommentMarkers = new WeakSet()
|
|
268
|
+
emittedCommentMarkers = new WeakSet(),
|
|
269
|
+
replacementEvent = null,
|
|
270
|
+
deferClosingCommentMarkers = false
|
|
217
271
|
) {
|
|
218
272
|
let localBaseIndex = baseIndex;
|
|
219
273
|
let localInsertOffset = insertOffset;
|
|
220
274
|
let localParagraph = currentParagraphRef;
|
|
275
|
+
const deferredCommentMarkers = [];
|
|
221
276
|
|
|
222
277
|
const parts = text.split(/([\n\uFFFC]|[\uE000-\uF8FF])/);
|
|
223
278
|
|
|
224
279
|
parts.forEach(part => {
|
|
225
280
|
const sentinelsAtOffset = sentinelMapByStart.get(localBaseIndex) || [];
|
|
226
281
|
const commentMarkers = sentinelsAtOffset.filter(sentinel => sentinel.isCommentMarker && !emittedCommentMarkers.has(sentinel.node));
|
|
282
|
+
const closingCommentIds = deferClosingCommentMarkers
|
|
283
|
+
? new Set(commentMarkers
|
|
284
|
+
.filter(marker => isWordElement(marker.node, 'commentRangeEnd'))
|
|
285
|
+
.map(marker => marker.node.getAttributeNS?.(NS_W, 'id') || marker.node.getAttribute?.('w:id') || marker.node.getAttribute?.('id')))
|
|
286
|
+
: new Set();
|
|
227
287
|
|
|
228
288
|
commentMarkers.forEach(marker => {
|
|
229
289
|
emittedCommentMarkers.add(marker.node);
|
|
290
|
+
const markerId = marker.node.getAttributeNS?.(NS_W, 'id')
|
|
291
|
+
|| marker.node.getAttribute?.('w:id')
|
|
292
|
+
|| marker.node.getAttribute?.('id');
|
|
293
|
+
if (
|
|
294
|
+
deferClosingCommentMarkers
|
|
295
|
+
&& closingCommentIds.has(markerId)
|
|
296
|
+
&& (isWordElement(marker.node, 'commentRangeEnd') || isWordElement(marker.node, 'commentReference'))
|
|
297
|
+
) {
|
|
298
|
+
deferredCommentMarkers.push(marker.node);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
230
301
|
if (isWordElement(marker.node, 'commentReference')) {
|
|
231
302
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
232
303
|
run.appendChild(marker.node.cloneNode(true));
|
|
233
304
|
localParagraph.appendChild(run);
|
|
234
305
|
} else {
|
|
235
|
-
localParagraph.appendChild(marker.node.cloneNode(true));
|
|
236
|
-
}
|
|
237
|
-
});
|
|
238
|
-
|
|
306
|
+
localParagraph.appendChild(marker.node.cloneNode(true));
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
239
310
|
if (part === '\n') {
|
|
240
311
|
const info = getParagraphInfo(localBaseIndex + 1);
|
|
241
312
|
const nextParagraph = createNewParagraph(info.pPr);
|
|
@@ -252,20 +323,20 @@ function appendTextToCurrent(
|
|
|
252
323
|
}
|
|
253
324
|
localBaseIndex++;
|
|
254
325
|
if (type !== 'delete') localInsertOffset++;
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (part === '\uFFFC') {
|
|
259
|
-
const sentinel = sentinelsAtOffset.find(entry => !entry.isCommentMarker) || sentinelsAtOffset[0];
|
|
260
|
-
if (sentinel) {
|
|
261
|
-
const clone = sentinel.node.cloneNode(true);
|
|
262
|
-
if (sentinel.isTextBox && sentinel.originalContainer) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (part === '\uFFFC') {
|
|
330
|
+
const sentinel = sentinelsAtOffset.find(entry => !entry.isCommentMarker) || sentinelsAtOffset[0];
|
|
331
|
+
if (sentinel) {
|
|
332
|
+
const clone = sentinel.node.cloneNode(true);
|
|
333
|
+
if (sentinel.isTextBox && sentinel.originalContainer) {
|
|
263
334
|
const newContainer = getFirstElementByTagNSOrTag(clone, NS_W, 'txbxContent');
|
|
264
|
-
if (newContainer) {
|
|
265
|
-
while (newContainer.firstChild) newContainer.removeChild(newContainer.firstChild);
|
|
266
|
-
replacementContainers.set(sentinel.originalContainer, newContainer);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
335
|
+
if (newContainer) {
|
|
336
|
+
while (newContainer.firstChild) newContainer.removeChild(newContainer.firstChild);
|
|
337
|
+
replacementContainers.set(sentinel.originalContainer, newContainer);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
269
340
|
if (sentinel.wrapInRun) {
|
|
270
341
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
271
342
|
if (sentinel.rPr) run.appendChild(sentinel.rPr.cloneNode(true));
|
|
@@ -274,14 +345,14 @@ function appendTextToCurrent(
|
|
|
274
345
|
} else {
|
|
275
346
|
localParagraph.appendChild(clone);
|
|
276
347
|
}
|
|
277
|
-
}
|
|
278
|
-
localBaseIndex++;
|
|
279
|
-
if (type !== 'delete') localInsertOffset++;
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (referenceMap.has(part)) {
|
|
284
|
-
if (type !== 'delete') {
|
|
348
|
+
}
|
|
349
|
+
localBaseIndex++;
|
|
350
|
+
if (type !== 'delete') localInsertOffset++;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (referenceMap.has(part)) {
|
|
355
|
+
if (type !== 'delete') {
|
|
285
356
|
const refNode = referenceMap.get(part);
|
|
286
357
|
if (refNode) {
|
|
287
358
|
const clone = refNode.cloneNode(true);
|
|
@@ -289,21 +360,21 @@ function appendTextToCurrent(
|
|
|
289
360
|
if (rPr) run.appendChild(rPr.cloneNode(true));
|
|
290
361
|
run.appendChild(clone);
|
|
291
362
|
localParagraph.appendChild(run);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
localBaseIndex++;
|
|
295
|
-
if (type !== 'delete') localInsertOffset++;
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (part.length === 0) return;
|
|
300
|
-
|
|
301
|
-
let parent = localParagraph;
|
|
302
|
-
if (wrapper) {
|
|
303
|
-
const wrapperClone = wrapper.cloneNode(false);
|
|
304
|
-
parent = wrapperClone;
|
|
305
|
-
localParagraph.appendChild(wrapperClone);
|
|
306
|
-
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
localBaseIndex++;
|
|
366
|
+
if (type !== 'delete') localInsertOffset++;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (part.length === 0) return;
|
|
371
|
+
|
|
372
|
+
let parent = localParagraph;
|
|
373
|
+
if (wrapper) {
|
|
374
|
+
const wrapperClone = wrapper.cloneNode(false);
|
|
375
|
+
parent = wrapperClone;
|
|
376
|
+
localParagraph.appendChild(wrapperClone);
|
|
377
|
+
}
|
|
307
378
|
|
|
308
379
|
if (type === 'delete') {
|
|
309
380
|
const run = createWordElement(xmlDoc, 'w:r');
|
|
@@ -312,29 +383,63 @@ function appendTextToCurrent(
|
|
|
312
383
|
delText.setAttribute('xml:space', 'preserve');
|
|
313
384
|
delText.textContent = part;
|
|
314
385
|
run.appendChild(delText);
|
|
315
|
-
|
|
316
|
-
if (generateRedlines) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
localInsertOffset
|
|
335
|
-
|
|
386
|
+
|
|
387
|
+
if (generateRedlines) {
|
|
388
|
+
let metadata = null;
|
|
389
|
+
if (replacementEvent) {
|
|
390
|
+
const id = replacementEvent.usedDeletionId
|
|
391
|
+
? createRevisionMetadata(author, xmlDoc, 'del').id
|
|
392
|
+
: replacementEvent.deletionId;
|
|
393
|
+
replacementEvent.usedDeletionId = true;
|
|
394
|
+
metadata = {
|
|
395
|
+
id,
|
|
396
|
+
author: replacementEvent.author,
|
|
397
|
+
date: replacementEvent.date
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const del = createTrackChange(xmlDoc, 'del', run, author, metadata);
|
|
401
|
+
parent.appendChild(del);
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
const applicableHints = getApplicableFormatHints(formatHints, localInsertOffset, localInsertOffset + part.length);
|
|
405
|
+
const runs = createFormattedRuns(xmlDoc, part, rPr, applicableHints, localInsertOffset, author, generateRedlines);
|
|
406
|
+
|
|
407
|
+
if (type === 'insert' && generateRedlines) {
|
|
408
|
+
let metadata = null;
|
|
409
|
+
if (replacementEvent) {
|
|
410
|
+
const id = replacementEvent.usedInsertionId
|
|
411
|
+
? createRevisionMetadata(author, xmlDoc, 'ins').id
|
|
412
|
+
: replacementEvent.insertionId;
|
|
413
|
+
replacementEvent.usedInsertionId = true;
|
|
414
|
+
metadata = {
|
|
415
|
+
id,
|
|
416
|
+
author: replacementEvent.author,
|
|
417
|
+
date: replacementEvent.date
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const ins = createTrackChange(xmlDoc, 'ins', null, author, metadata);
|
|
421
|
+
runs.forEach(run => ins.appendChild(run));
|
|
422
|
+
parent.appendChild(ins);
|
|
423
|
+
} else {
|
|
424
|
+
runs.forEach(run => parent.appendChild(run));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (type !== 'delete') {
|
|
429
|
+
localInsertOffset += part.length;
|
|
430
|
+
}
|
|
336
431
|
localBaseIndex += part.length;
|
|
337
432
|
});
|
|
338
433
|
|
|
434
|
+
deferredCommentMarkers.forEach(marker => {
|
|
435
|
+
if (isWordElement(marker, 'commentReference')) {
|
|
436
|
+
const run = createWordElement(xmlDoc, 'w:r');
|
|
437
|
+
run.appendChild(marker.cloneNode(true));
|
|
438
|
+
localParagraph.appendChild(run);
|
|
439
|
+
} else {
|
|
440
|
+
localParagraph.appendChild(marker.cloneNode(true));
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
339
444
|
return { currentParagraph: localParagraph };
|
|
340
445
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accuracy-oriented capability record for the current reconciliation routes.
|
|
3
|
+
* This is internal instrumentation, not a routing policy API.
|
|
4
|
+
*/
|
|
5
|
+
export const RECONCILIATION_CAPABILITY_MATRIX = Object.freeze({
|
|
6
|
+
formatOnly: Object.freeze({ paragraphs: true, formatting: true, tables: 'scoped', hyperlinks: 'preserved', fields: 'preserved', comments: 'preserved' }),
|
|
7
|
+
surgical: Object.freeze({ paragraphs: true, tables: 'cell-scoped', hyperlinks: 'preserved', fields: 'preserved', comments: 'preserved', notes: 'preserved' }),
|
|
8
|
+
reconstruction: Object.freeze({ paragraphs: true, hyperlinks: 'sentinel-preserved', fields: 'sentinel-preserved', comments: 'marker-preserved', notes: 'reference-preserved' }),
|
|
9
|
+
table: Object.freeze({ tables: true, paragraphs: true, formatting: 'cell-dependent', numbering: false }),
|
|
10
|
+
listDirect: Object.freeze({ lists: true, numbering: true, paragraphs: 'single-source expansion', tables: 'embedded-markdown blocks', formatting: 'markdown hints' }),
|
|
11
|
+
listCompatibilityPipeline: Object.freeze({ lists: true, numbering: true, paragraphs: 'multi-source patching', compatibility: true })
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function recordRouteSelection(options, route, context = {}) {
|
|
15
|
+
const callback = options?._routeInstrumentation?.onRoute;
|
|
16
|
+
if (typeof callback !== 'function') return;
|
|
17
|
+
callback(Object.freeze({
|
|
18
|
+
route,
|
|
19
|
+
capabilities: RECONCILIATION_CAPABILITY_MATRIX[route] || null,
|
|
20
|
+
...context
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createRouteFrequencyCollector() {
|
|
25
|
+
const counts = new Map();
|
|
26
|
+
return {
|
|
27
|
+
onRoute(event) {
|
|
28
|
+
const route = event?.route || 'unknown';
|
|
29
|
+
counts.set(route, (counts.get(route) || 0) + 1);
|
|
30
|
+
},
|
|
31
|
+
snapshot() {
|
|
32
|
+
return Object.fromEntries(Array.from(counts).sort(([a], [b]) => a.localeCompare(b)));
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|