@ansonlai/docx-redline-js 0.8.2 → 0.8.4
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 +75 -1
- package/README.md +28 -13
- package/core/list-targeting.js +12 -10
- package/dist/docx-redline-js.esm.js +837 -549
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +93 -90
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +837 -549
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +837 -549
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +4 -1
- package/docs/schemas/document-operations.schema.json +168 -168
- package/engine/oxml-engine.js +215 -195
- package/engine/reconstruction-mode.js +183 -183
- package/index.d.ts +4 -0
- package/index.js +36 -1
- package/node/cli.js +2 -1
- package/orchestration/list-structural-fallback.js +530 -518
- package/package.json +1 -1
- package/pipeline/list-generation.js +203 -168
- package/pipeline/serialization.js +111 -94
- package/services/batch-operation-orchestrator.js +6 -0
- package/services/document-inspection.js +10 -7
- package/services/document-operation-applier.js +2 -0
- package/services/document-operation-contract.js +553 -537
- package/services/document-operation-mutations.js +1140 -1011
- package/services/numbering-helpers.js +108 -0
- package/services/operation-batch-compiler.js +440 -436
- package/services/operation-heuristics.js +197 -173
- package/services/revision-comment-management.js +601 -594
- package/services/standalone-operation-runner.d.ts +429 -427
- package/services/table-reconciliation.js +48 -46
|
@@ -1,183 +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 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
|
-
}
|
|
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
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -105,6 +105,10 @@ export interface RedlineOptions {
|
|
|
105
105
|
structuredContent?: boolean;
|
|
106
106
|
pairReplacements?: boolean;
|
|
107
107
|
insertionAffinity?: InsertionAffinity;
|
|
108
|
+
/** Source numbering part; generated list definitions are remapped to IDs that do not collide with it. */
|
|
109
|
+
numberingXml?: string | null;
|
|
110
|
+
/** Shared numbering ID state (from `createDynamicNumberingIdState`) for allocation across calls. */
|
|
111
|
+
numberingIdState?: unknown;
|
|
108
112
|
[key: string]: unknown;
|
|
109
113
|
}
|
|
110
114
|
|
package/index.js
CHANGED
|
@@ -21,10 +21,44 @@ import {
|
|
|
21
21
|
executeSingleLineListStructuralFallback
|
|
22
22
|
} from './orchestration/list-structural-fallback.js';
|
|
23
23
|
import { withOoxmlSourceType } from './core/word-xml.js';
|
|
24
|
+
import { createDynamicNumberingIdState, remapNumberingPayloadForDocument } from './services/numbering-helpers.js';
|
|
24
25
|
export { containsTrackedChanges, getTrackedChangeAuthors } from './core/word-xml.js';
|
|
25
26
|
export { validateRedlineOoxml } from './core/redline-validation.js';
|
|
26
27
|
export { analyzeStructuredContent, planStructuredReplacement } from './pipeline/structured-content.js';
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* When the caller supplies the source `numberingXml` (or a numbering ID state), generated list
|
|
31
|
+
* definitions are remapped to IDs that cannot collide with any existing `w:num`/`w:abstractNum`.
|
|
32
|
+
*/
|
|
33
|
+
function remapGeneratedNumberingForSource(result, options) {
|
|
34
|
+
const sourceNumbering = typeof options?.numberingXml === 'string' && options.numberingXml.trim()
|
|
35
|
+
? options.numberingXml
|
|
36
|
+
: null;
|
|
37
|
+
const state = options?.numberingIdState && typeof options.numberingIdState === 'object'
|
|
38
|
+
? options.numberingIdState
|
|
39
|
+
: (sourceNumbering ? createDynamicNumberingIdState(sourceNumbering) : null);
|
|
40
|
+
if (!state || !result?.hasChanges || typeof result.oxml !== 'string' || !result.oxml.includes('/word/numbering.xml')) {
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
const pkgDoc = parseOoxml(result.oxml);
|
|
44
|
+
const parts = Array.from(pkgDoc.getElementsByTagNameNS('*', 'part'));
|
|
45
|
+
const payloadOf = name => {
|
|
46
|
+
const part = parts.find(candidate => (candidate.getAttribute('pkg:name') || candidate.getAttribute('name')) === name);
|
|
47
|
+
const xmlData = part?.getElementsByTagNameNS('*', 'xmlData')[0];
|
|
48
|
+
return xmlData ? Array.from(xmlData.childNodes).find(node => node.nodeType === 1) || null : null;
|
|
49
|
+
};
|
|
50
|
+
const numberingNode = payloadOf('/word/numbering.xml');
|
|
51
|
+
const body = payloadOf('/word/document.xml')?.getElementsByTagNameNS('*', 'body')[0];
|
|
52
|
+
if (!numberingNode || !body) return result;
|
|
53
|
+
|
|
54
|
+
const bodyNodes = Array.from(body.childNodes).filter(node => node.nodeType === 1 && node.localName !== 'sectPr');
|
|
55
|
+
const remapped = remapNumberingPayloadForDocument(serializeOoxml(numberingNode), bodyNodes, state);
|
|
56
|
+
const newNumbering = pkgDoc.importNode(parseOoxml(remapped.numberingXml).documentElement, true);
|
|
57
|
+
numberingNode.parentNode.replaceChild(newNumbering, numberingNode);
|
|
58
|
+
bodyNodes.forEach((node, index) => body.replaceChild(remapped.replacementNodes[index], node));
|
|
59
|
+
return { ...result, oxml: serializeOoxml(pkgDoc) };
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
/**
|
|
29
63
|
* Standalone-safe redline wrapper.
|
|
30
64
|
*
|
|
@@ -33,7 +67,8 @@ export { analyzeStructuredContent, planStructuredReplacement } from './pipeline/
|
|
|
33
67
|
* complete that native fallback path, so normalize to a no-op with warnings.
|
|
34
68
|
*/
|
|
35
69
|
export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
36
|
-
const
|
|
70
|
+
const rawResult = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
|
|
71
|
+
const result = remapGeneratedNumberingForSource(rawResult, options);
|
|
37
72
|
if (result?.useNativeApi && typeof result?.oxml !== 'string') {
|
|
38
73
|
const existingWarnings = Array.isArray(result?.warnings) ? result.warnings : [];
|
|
39
74
|
return withOoxmlSourceType({
|
package/node/cli.js
CHANGED
|
@@ -38,7 +38,8 @@ const CLI_CAPABILITIES = [
|
|
|
38
38
|
'compact-cli-json-v1',
|
|
39
39
|
'comment-resolve-operation-v1',
|
|
40
40
|
'delete-comments-by-id-v1',
|
|
41
|
-
'header-footer-parts-v1'
|
|
41
|
+
'header-footer-parts-v1',
|
|
42
|
+
'format-text-occurrence-v1'
|
|
42
43
|
];
|
|
43
44
|
const commandOptions = Object.fromEntries(CLI_COMMANDS.map(command => [command, new Set(commandOptionKeys(command))]));
|
|
44
45
|
|