@ansonlai/docx-redline-js 0.8.0 → 0.8.2

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +553 -486
  2. package/README.md +331 -309
  3. package/dist/docx-redline-js.esm.js +1209 -168
  4. package/dist/docx-redline-js.esm.js.map +4 -4
  5. package/dist/docx-redline-js.esm.min.js +107 -107
  6. package/dist/docx-redline-js.esm.min.js.map +4 -4
  7. package/dist/docx-redline.bundle.cjs +1209 -168
  8. package/dist/docx-redline.bundle.cjs.map +4 -4
  9. package/dist/docx-redline.bundle.js +1209 -168
  10. package/dist/docx-redline.bundle.js.map +4 -4
  11. package/docs/AGENT_KNOWLEDGE_BASE.md +69 -8
  12. package/docs/schemas/document-operations.schema.json +29 -0
  13. package/document/docx-document.js +416 -22
  14. package/engine/reconstruction-mode.js +183 -54
  15. package/engine/reconstruction-writer.js +18 -0
  16. package/index.d.ts +598 -575
  17. package/index.js +349 -348
  18. package/node/cli-help.js +5 -5
  19. package/node/cli.js +760 -754
  20. package/node/index.d.ts +1 -1
  21. package/package.json +2 -1
  22. package/pipeline/diff-engine.js +274 -258
  23. package/scripts/generate-word-comment-edge-case-fixture.ps1 +58 -0
  24. package/scripts/generate-word-comment-thread-fixture.ps1 +36 -0
  25. package/scripts/generate-word-header-footer-fixture.ps1 +32 -0
  26. package/scripts/generate-word-multi-section-fixture.ps1 +27 -0
  27. package/scripts/generate-word-package-integrity-cases.mjs +141 -0
  28. package/scripts/generate-word-thread-state-fixtures.ps1 +50 -0
  29. package/scripts/run-tests.mjs +14 -2
  30. package/scripts/word-com-package-integrity.ps1 +98 -0
  31. package/services/batch-operation-orchestrator.js +262 -259
  32. package/services/comment-replies.js +96 -95
  33. package/services/comment-thread-parts.js +280 -0
  34. package/services/document-inspection.js +366 -365
  35. package/services/document-operation-applier.js +230 -202
  36. package/services/document-operation-contract.js +17 -2
  37. package/services/error-recovery.js +6 -0
  38. package/services/headers-footers.js +206 -0
  39. package/services/operation-batch-compiler.js +1 -0
  40. package/services/operation-preflight.js +231 -218
  41. package/services/package-parts.js +80 -0
  42. package/services/standalone-docx-plumbing.js +631 -569
  43. package/services/standalone-operation-runner.d.ts +19 -3
@@ -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
+ }
@@ -164,6 +164,24 @@ export function applyReconstructionDiffs(xmlDoc, diffs, context, serializer, aut
164
164
  }
165
165
  }
166
166
 
167
+ // Comment markers are emitted when a diff part starts at their offset. Markers at the very end of the
168
+ // paragraph text (commentRangeEnd and the reference run of a comment on the last words) are only reached
169
+ // when the final diff part is an insert; after an unchanged or deleted tail nothing starts there, so they
170
+ // were dropped and the comment lost its end anchor. Flush whatever is left, in original order.
171
+ for (const offset of Array.from(sentinelMapByStart.keys()).sort((a, b) => a - b)) {
172
+ for (const sentinel of sentinelMapByStart.get(offset)) {
173
+ if (!sentinel.isCommentMarker || emittedCommentMarkers.has(sentinel.node)) continue;
174
+ emittedCommentMarkers.add(sentinel.node);
175
+ if (isWordElement(sentinel.node, 'commentReference')) {
176
+ const run = createWordElement(xmlDoc, 'w:r');
177
+ run.appendChild(sentinel.node.cloneNode(true));
178
+ currentParagraph.appendChild(run);
179
+ } else {
180
+ currentParagraph.appendChild(sentinel.node.cloneNode(true));
181
+ }
182
+ }
183
+ }
184
+
167
185
  if (generateRedlines) {
168
186
  containerFragments.forEach(fragment => {
169
187
  Array.from(fragment.childNodes).forEach(node => {