@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.
@@ -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 diffs = computeWordDiffs(mapping.originalFullText, mapping.processedModifiedText, diffOptions);
43
-
44
- return withOoxmlSourceType(applyReconstructionDiffs(
45
- xmlDoc,
46
- diffs,
47
- mapping,
48
- serializer,
49
- author,
50
- formatHints,
51
- generateRedlines,
52
- options
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
+ }