@ansonlai/docx-redline-js 0.8.1 → 0.8.3
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/CHANGELOG.md +586 -539
- package/README.md +901 -890
- package/core/list-targeting.js +12 -10
- package/dist/docx-redline-js.esm.js +280 -33
- package/dist/docx-redline-js.esm.js.map +3 -3
- package/dist/docx-redline-js.esm.min.js +86 -86
- package/dist/docx-redline-js.esm.min.js.map +3 -3
- package/dist/docx-redline.bundle.cjs +280 -33
- package/dist/docx-redline.bundle.cjs.map +3 -3
- package/dist/docx-redline.bundle.js +280 -33
- package/dist/docx-redline.bundle.js.map +3 -3
- package/engine/reconstruction-mode.js +183 -54
- package/orchestration/list-structural-fallback.js +530 -518
- package/package.json +1 -1
- package/pipeline/diff-engine.js +274 -258
- package/pipeline/list-generation.js +203 -168
- package/scripts/run-tests.mjs +14 -2
- package/services/document-inspection.js +10 -7
- package/services/document-operation-mutations.js +1597 -1586
- package/services/numbering-helpers.js +108 -0
|
@@ -1,54 +1,183 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reconstruction reconciliation mode orchestration.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
6
|
-
import { buildReconstructionMapping, findReconstructionParagraphRange } from './reconstruction-mapper.js';
|
|
7
|
-
import { applyReconstructionDiffs } from './reconstruction-writer.js';
|
|
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
|
|
20
|
-
* @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
|
|
21
|
-
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
22
|
-
*/
|
|
23
|
-
export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, diffOptions = {}, options = {}) {
|
|
24
|
-
const selectedParagraphs = findReconstructionParagraphRange(xmlDoc, originalText);
|
|
25
|
-
if (selectedParagraphs === null) {
|
|
26
|
-
return withOoxmlSourceType({
|
|
27
|
-
oxml: serializer.serializeToString(xmlDoc),
|
|
28
|
-
hasChanges: false,
|
|
29
|
-
status: 'error',
|
|
30
|
-
error: {
|
|
31
|
-
code: 'PARTIAL_TARGET',
|
|
32
|
-
message: 'Original text did not identify a complete contiguous paragraph range for reconstruction.'
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const mapping = buildReconstructionMapping(xmlDoc, modifiedText, selectedParagraphs);
|
|
38
|
-
if (mapping.paragraphs.length === 0) {
|
|
39
|
-
return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Reconstruction reconciliation mode orchestration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
6
|
+
import { buildReconstructionMapping, findReconstructionParagraphRange } from './reconstruction-mapper.js';
|
|
7
|
+
import { applyReconstructionDiffs } from './reconstruction-writer.js';
|
|
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
|
|
20
|
+
* @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
|
|
21
|
+
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
22
|
+
*/
|
|
23
|
+
export function applyReconstructionMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, diffOptions = {}, options = {}) {
|
|
24
|
+
const selectedParagraphs = findReconstructionParagraphRange(xmlDoc, originalText);
|
|
25
|
+
if (selectedParagraphs === null) {
|
|
26
|
+
return withOoxmlSourceType({
|
|
27
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
28
|
+
hasChanges: false,
|
|
29
|
+
status: 'error',
|
|
30
|
+
error: {
|
|
31
|
+
code: 'PARTIAL_TARGET',
|
|
32
|
+
message: 'Original text did not identify a complete contiguous paragraph range for reconstruction.'
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const mapping = buildReconstructionMapping(xmlDoc, modifiedText, selectedParagraphs);
|
|
38
|
+
if (mapping.paragraphs.length === 0) {
|
|
39
|
+
return withOoxmlSourceType({ oxml: serializer.serializeToString(xmlDoc), hasChanges: false });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const wordDiffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText, {
|
|
43
|
+
...diffOptions,
|
|
44
|
+
atomicChars: new Set(mapping.referenceMap.keys())
|
|
45
|
+
});
|
|
46
|
+
const diffs = keepUnchangedTextOutsideWrappers(anchorSharedReferences(wordDiffs, mapping.referenceMap), mapping);
|
|
47
|
+
|
|
48
|
+
return withOoxmlSourceType(applyReconstructionDiffs(
|
|
49
|
+
xmlDoc,
|
|
50
|
+
diffs,
|
|
51
|
+
mapping,
|
|
52
|
+
serializer,
|
|
53
|
+
author,
|
|
54
|
+
formatHints,
|
|
55
|
+
generateRedlines,
|
|
56
|
+
options
|
|
57
|
+
));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Splits diffs into runs of equal parts and change groups (deleted + inserted text). */
|
|
61
|
+
function forEachChangeGroup(diffs, onEqual, onGroup) {
|
|
62
|
+
let index = 0;
|
|
63
|
+
while (index < diffs.length) {
|
|
64
|
+
if (diffs[index][0] === 0) {
|
|
65
|
+
onEqual(diffs[index]);
|
|
66
|
+
index++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const group = [];
|
|
70
|
+
while (index < diffs.length && diffs[index][0] !== 0) group.push(diffs[index++]);
|
|
71
|
+
onGroup(
|
|
72
|
+
group,
|
|
73
|
+
group.filter(([op]) => op === -1).map(([, text]) => text).join(''),
|
|
74
|
+
group.filter(([op]) => op === 1).map(([, text]) => text).join('')
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pushChange(result, deleted, inserted) {
|
|
80
|
+
if (deleted === inserted) {
|
|
81
|
+
if (deleted) result.push([0, deleted]);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (deleted) result.push([-1, deleted]);
|
|
85
|
+
if (inserted) result.push([1, inserted]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The writer keeps a reference placeholder (manual break, note reference) that
|
|
90
|
+
* appears in both the deleted and inserted text of one change group, but only
|
|
91
|
+
* emits it with the insertion, after all deleted text. Semantic cleanup can
|
|
92
|
+
* merge edits on both sides of a break into such a group, so Reject All would
|
|
93
|
+
* move deleted text across the break. Split the group at shared references so
|
|
94
|
+
* each one stays an unchanged anchor between its own before/after edits.
|
|
95
|
+
*/
|
|
96
|
+
function anchorSharedReferences(diffs, referenceMap) {
|
|
97
|
+
if (referenceMap.size === 0) return diffs;
|
|
98
|
+
const result = [];
|
|
99
|
+
forEachChangeGroup(diffs, part => result.push(part), (group, deleted, inserted) => {
|
|
100
|
+
const deletedRefs = [...deleted].filter(char => referenceMap.has(char) && inserted.includes(char));
|
|
101
|
+
const insertedRefs = [...inserted].filter(char => deletedRefs.includes(char));
|
|
102
|
+
if (deletedRefs.length === 0 || deletedRefs.join('') !== insertedRefs.join('')) {
|
|
103
|
+
result.push(...group);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
let deletedStart = 0;
|
|
107
|
+
let insertedStart = 0;
|
|
108
|
+
for (const ref of deletedRefs) {
|
|
109
|
+
const deletedAt = deleted.indexOf(ref, deletedStart);
|
|
110
|
+
const insertedAt = inserted.indexOf(ref, insertedStart);
|
|
111
|
+
pushChange(result, deleted.slice(deletedStart, deletedAt), inserted.slice(insertedStart, insertedAt));
|
|
112
|
+
result.push([0, ref]);
|
|
113
|
+
deletedStart = deletedAt + 1;
|
|
114
|
+
insertedStart = insertedAt + 1;
|
|
115
|
+
}
|
|
116
|
+
pushChange(result, deleted.slice(deletedStart), inserted.slice(insertedStart));
|
|
117
|
+
});
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function commonPrefixLength(left, right) {
|
|
122
|
+
const limit = Math.min(left.length, right.length);
|
|
123
|
+
let length = 0;
|
|
124
|
+
while (length < limit && left[length] === right[length]) length++;
|
|
125
|
+
return length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function commonSuffixLength(left, right, limit) {
|
|
129
|
+
let length = 0;
|
|
130
|
+
while (length < limit && left[left.length - 1 - length] === right[right.length - 1 - length]) length++;
|
|
131
|
+
return length;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Word tokens can span a hyperlink boundary (`example.org` + plain `.`), so a
|
|
136
|
+
* replaced token would delete and reinsert unchanged text across it and move
|
|
137
|
+
* that text into or out of the link. Where the replacement's shared prefix or
|
|
138
|
+
* suffix reaches a wrapper boundary in the original, keep that part unchanged.
|
|
139
|
+
*/
|
|
140
|
+
function keepUnchangedTextOutsideWrappers(diffs, mapping) {
|
|
141
|
+
const wrapperAt = index => mapping.getRunProperties(index).wrapper || null;
|
|
142
|
+
const result = [];
|
|
143
|
+
let originalOffset = 0;
|
|
144
|
+
|
|
145
|
+
forEachChangeGroup(diffs, part => {
|
|
146
|
+
result.push(part);
|
|
147
|
+
originalOffset += part[1].length;
|
|
148
|
+
}, (group, deleted, inserted) => {
|
|
149
|
+
const start = originalOffset;
|
|
150
|
+
const end = start + deleted.length;
|
|
151
|
+
originalOffset = end;
|
|
152
|
+
// Only a boundary strictly inside the deleted text splits it; an edge
|
|
153
|
+
// of the whole change (`link` -> `hyperlink` inside a link) does not.
|
|
154
|
+
const isWrapperBoundary = index => index > start && index < end
|
|
155
|
+
&& wrapperAt(index - 1) !== wrapperAt(index);
|
|
156
|
+
|
|
157
|
+
let prefix = 0;
|
|
158
|
+
let suffix = 0;
|
|
159
|
+
if (deleted && inserted) {
|
|
160
|
+
const sharedSuffix = commonSuffixLength(deleted, inserted, Math.min(deleted.length, inserted.length));
|
|
161
|
+
for (let length = sharedSuffix; length > 0; length--) {
|
|
162
|
+
if (isWrapperBoundary(end - length)) { suffix = length; break; }
|
|
163
|
+
}
|
|
164
|
+
const sharedPrefix = Math.min(
|
|
165
|
+
commonPrefixLength(deleted, inserted),
|
|
166
|
+
Math.min(deleted.length, inserted.length) - suffix
|
|
167
|
+
);
|
|
168
|
+
for (let length = sharedPrefix; length > 0; length--) {
|
|
169
|
+
if (isWrapperBoundary(start + length)) { prefix = length; break; }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (prefix === 0 && suffix === 0) {
|
|
173
|
+
result.push(...group);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (prefix > 0) result.push([0, deleted.slice(0, prefix)]);
|
|
178
|
+
pushChange(result, deleted.slice(prefix, deleted.length - suffix), inserted.slice(prefix, inserted.length - suffix));
|
|
179
|
+
if (suffix > 0) result.push([0, deleted.slice(deleted.length - suffix)]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return result;
|
|
183
|
+
}
|