@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +28 -13
  3. package/core/list-targeting.js +12 -10
  4. package/dist/docx-redline-js.esm.js +837 -549
  5. package/dist/docx-redline-js.esm.js.map +4 -4
  6. package/dist/docx-redline-js.esm.min.js +93 -90
  7. package/dist/docx-redline-js.esm.min.js.map +4 -4
  8. package/dist/docx-redline.bundle.cjs +837 -549
  9. package/dist/docx-redline.bundle.cjs.map +4 -4
  10. package/dist/docx-redline.bundle.js +837 -549
  11. package/dist/docx-redline.bundle.js.map +4 -4
  12. package/docs/AGENT_KNOWLEDGE_BASE.md +4 -1
  13. package/docs/schemas/document-operations.schema.json +168 -168
  14. package/engine/oxml-engine.js +215 -195
  15. package/engine/reconstruction-mode.js +183 -183
  16. package/index.d.ts +4 -0
  17. package/index.js +36 -1
  18. package/node/cli.js +2 -1
  19. package/orchestration/list-structural-fallback.js +530 -518
  20. package/package.json +1 -1
  21. package/pipeline/list-generation.js +203 -168
  22. package/pipeline/serialization.js +111 -94
  23. package/services/batch-operation-orchestrator.js +6 -0
  24. package/services/document-inspection.js +10 -7
  25. package/services/document-operation-applier.js +2 -0
  26. package/services/document-operation-contract.js +553 -537
  27. package/services/document-operation-mutations.js +1140 -1011
  28. package/services/numbering-helpers.js +108 -0
  29. package/services/operation-batch-compiler.js +440 -436
  30. package/services/operation-heuristics.js +197 -173
  31. package/services/revision-comment-management.js +601 -594
  32. package/services/standalone-operation-runner.d.ts +429 -427
  33. 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 result = await applyRedlineToOxmlEngine(oxml, originalText, modifiedText, options);
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