@ansonlai/docx-redline-js 0.8.4 → 0.9.0

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 (68) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +176 -0
  3. package/README.md +125 -12
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4189 -767
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +111 -108
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4292 -870
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4292 -870
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +19 -3
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +54 -1
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +736 -749
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-writer.js +67 -10
  26. package/engine/rpr-helpers.js +42 -0
  27. package/engine/run-builders.js +0 -73
  28. package/engine/surgical-diff-application.js +604 -605
  29. package/engine/table-mode.js +63 -63
  30. package/index.d.ts +47 -1
  31. package/index.js +11 -2
  32. package/node/cli-help.js +1 -0
  33. package/node/cli.js +992 -975
  34. package/orchestration/list-structural-fallback.js +67 -9
  35. package/package.json +1 -1
  36. package/pipeline/diff-engine.js +130 -1
  37. package/pipeline/ingestion-export.js +227 -243
  38. package/pipeline/ingestion-paragraph.js +93 -98
  39. package/pipeline/list-generation.js +415 -419
  40. package/pipeline/pipeline.js +206 -204
  41. package/scripts/export-validation-fixtures.mjs +19 -6
  42. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  43. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  44. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  45. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  46. package/scripts/word-com-differential.ps1 +443 -335
  47. package/scripts/word-com-visual-suite.ps1 +2 -2
  48. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  49. package/services/batch-operation-orchestrator.js +23 -3
  50. package/services/capture-engine.js +228 -226
  51. package/services/document-operation-applier.js +213 -0
  52. package/services/document-operation-contract.js +75 -0
  53. package/services/document-operation-mutations.js +3346 -2721
  54. package/services/error-recovery.js +17 -0
  55. package/services/list-conversion-diagnostics.js +296 -0
  56. package/services/list-format-change.js +586 -0
  57. package/services/list-format-mutation.js +416 -0
  58. package/services/list-format.js +305 -0
  59. package/services/numbering-allocation.js +642 -0
  60. package/services/numbering-helpers.js +354 -524
  61. package/services/operation-batch-compiler.js +183 -11
  62. package/services/operation-preflight.js +1 -0
  63. package/services/receipt-collector.js +288 -288
  64. package/services/revision-comment-management.js +38 -5
  65. package/services/revision-fidelity-verifier.js +498 -0
  66. package/services/standalone-docx-plumbing.js +627 -631
  67. package/services/standalone-operation-runner.d.ts +182 -5
  68. package/services/table-reconciliation.js +59 -7
@@ -1,209 +1,211 @@
1
- import { NS_W } from './types.js';
2
-
3
- function localName(node) {
4
- return String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
5
- }
6
-
7
- /** Returns whether a node contributes to the selected revision view. */
8
- export function isNodeVisibleInRevisionView(node, boundary = null, revisionView = 'accepted') {
9
- const view = revisionView === 'current' ? 'accepted' : revisionView;
10
- let cursor = node;
11
- while (cursor && cursor !== boundary) {
12
- const name = localName(cursor);
13
- if (view === 'accepted' && (name === 'del' || name === 'moveFrom')) return false;
14
- if (view === 'rejected' && (name === 'ins' || name === 'moveTo')) return false;
15
- cursor = cursor.parentNode;
16
- }
17
- return true;
18
- }
19
-
20
- /** Reads exact visible text from a run, including tabs, breaks and non-breaking hyphens. */
21
- export function readCanonicalRunText(run, options = {}) {
22
- const revisionView = options.revisionView === 'current' ? 'accepted' : (options.revisionView || 'accepted');
23
- const boundary = options.boundary || null;
24
- if (!isNodeVisibleInRevisionView(run, boundary, revisionView)) return '';
25
- let text = '';
26
- const visit = node => {
27
- for (const child of Array.from(node?.childNodes || [])) {
28
- if (child?.nodeType !== 1 || (child.namespaceURI && child.namespaceURI !== NS_W)) continue;
29
- if (!isNodeVisibleInRevisionView(child, boundary, revisionView)) continue;
30
- const name = localName(child);
31
- if (name === 't' || (name === 'delText' && revisionView === 'rejected')) text += child.textContent || '';
32
- else if (name === 'tab') text += '\t';
33
- else if (name === 'br' || name === 'cr') text += '\n';
34
- else if (name === 'noBreakHyphen') text += '\u2011';
35
- else if (name === 'softHyphen') text += '\u00ad';
36
- else visit(child);
37
- }
38
- };
39
- visit(run);
40
- return text;
41
- }
42
-
43
- const attr = (node, name) => node?.getAttribute?.(`w:${name}`) || node?.getAttribute?.(name) || '';
44
-
45
- /**
46
- * Extracts revision-view text segments from a paragraph or container node.
47
- *
48
- * Emits structural text pieces tracking revision ancestry (ins, del, moveFrom, moveTo),
49
- * computes independent accepted and rejected UTF-16 cursor offsets, and merges
50
- * adjacent pieces with compatible carrier containers and revision metadata.
51
- *
52
- * @param {Element | null | undefined} paragraph - Paragraph or container element
53
- * @param {Object} [options={}] - Extraction options
54
- * @param {boolean} [options.mergeRuns=true] - Merge adjacent runs sharing compatible container and revision metadata
55
- * @returns {Array<{ text: string, kind: 'baseline' | 'insertion' | 'deletion' | 'move_from' | 'move_to', author?: string, revisionId?: string, acceptedStart: number | null, rejectedStart: number | null }>}
56
- */
57
- export function extractParagraphRevisionSegments(paragraph, options = {}) {
58
- if (!paragraph) return [];
59
-
60
- const rawPieces = [];
61
-
62
- function walk(node, currentRevision, currentCarrier) {
63
- for (const child of Array.from(node?.childNodes || [])) {
64
- if (child?.nodeType !== 1) continue;
65
- if (child.namespaceURI && child.namespaceURI !== NS_W) continue;
66
-
67
- const name = localName(child);
68
- if (name === 'pPr' || name === 'rPr') continue;
69
-
70
- let nextRevision = currentRevision;
71
- if (name === 'ins') {
72
- nextRevision = {
73
- kind: 'insertion',
74
- author: attr(child, 'author') || undefined,
75
- revisionId: attr(child, 'id') || undefined
76
- };
77
- } else if (name === 'del') {
78
- nextRevision = {
79
- kind: 'deletion',
80
- author: attr(child, 'author') || undefined,
81
- revisionId: attr(child, 'id') || undefined
82
- };
83
- } else if (name === 'moveFrom') {
84
- nextRevision = {
85
- kind: 'move_from',
86
- author: attr(child, 'author') || undefined,
87
- revisionId: attr(child, 'id') || undefined
88
- };
89
- } else if (name === 'moveTo') {
90
- nextRevision = {
91
- kind: 'move_to',
92
- author: attr(child, 'author') || undefined,
93
- revisionId: attr(child, 'id') || undefined
94
- };
95
- }
96
-
97
- let nextCarrier = currentCarrier;
98
- if (name === 'r') {
99
- nextCarrier = child;
100
- }
101
-
102
- let text = null;
103
- let kind = nextRevision ? nextRevision.kind : 'baseline';
104
- let author = nextRevision?.author;
105
- let revisionId = nextRevision?.revisionId;
106
-
107
- if (name === 't') {
108
- text = child.textContent || '';
109
- } else if (name === 'delText') {
110
- text = child.textContent || '';
111
- if (!nextRevision) {
112
- kind = 'deletion';
113
- }
114
- } else if (name === 'tab') {
115
- text = '\t';
116
- } else if (name === 'br' || name === 'cr') {
117
- text = '\n';
118
- } else if (name === 'noBreakHyphen') {
119
- text = '\u2011';
120
- } else if (name === 'softHyphen') {
121
- text = '\u00ad';
122
- }
123
-
124
- if (text !== null) {
125
- if (text.length > 0) {
126
- rawPieces.push({
127
- text,
128
- kind,
129
- author,
130
- revisionId,
131
- carrier: nextCarrier || child,
132
- carrierContainer: (nextCarrier || child)?.parentNode || null
133
- });
134
- }
135
- } else {
136
- walk(child, nextRevision, nextCarrier);
137
- }
138
- }
139
- }
140
-
141
- const initialCarrier = localName(paragraph) === 'r' ? paragraph : null;
142
- walk(paragraph, null, initialCarrier);
143
-
144
- if (rawPieces.length === 0) return [];
145
-
146
- const mergeRuns = options.mergeRuns !== false;
147
- const merged = [];
148
- let current = null;
149
-
150
- for (const piece of rawPieces) {
151
- if (!current) {
152
- current = { ...piece };
153
- continue;
154
- }
155
-
156
- const sameKind = current.kind === piece.kind;
157
- const sameAuthor = current.author === piece.author;
158
- const sameRevisionId = current.revisionId === piece.revisionId;
159
- const sameCarrier = current.carrier === piece.carrier;
160
- const compatibleContainer = mergeRuns && current.carrierContainer === piece.carrierContainer;
161
-
162
- if (sameKind && sameAuthor && sameRevisionId && (sameCarrier || compatibleContainer)) {
163
- current.text += piece.text;
164
- } else {
165
- merged.push(current);
166
- current = { ...piece };
167
- }
168
- }
169
- if (current) merged.push(current);
170
-
171
- let acceptedCursor = 0;
172
- let rejectedCursor = 0;
173
- const segments = [];
174
-
175
- for (const item of merged) {
176
- const isAccepted = item.kind !== 'deletion' && item.kind !== 'move_from';
177
- const isRejected = item.kind !== 'insertion' && item.kind !== 'move_to';
178
-
179
- const acceptedStart = isAccepted ? acceptedCursor : null;
180
- const rejectedStart = isRejected ? rejectedCursor : null;
181
-
182
- if (isAccepted) acceptedCursor += item.text.length;
183
- if (isRejected) rejectedCursor += item.text.length;
184
-
185
- const segment = {
186
- text: item.text,
187
- kind: item.kind,
188
- acceptedStart,
189
- rejectedStart
190
- };
191
- if (item.author !== undefined) segment.author = item.author;
192
- if (item.revisionId !== undefined) segment.revisionId = item.revisionId;
193
-
194
- segments.push(segment);
195
- }
196
-
197
- return segments;
198
- }
199
-
200
- /** Canonical exact paragraph text used by inspection, targeting and ingestion. */
201
- export function extractCanonicalParagraphText(paragraph, options = {}) {
202
- if (!paragraph) return '';
203
- const view = options.revisionView === 'current' ? 'accepted' : (options.revisionView || 'accepted');
204
- const segments = extractParagraphRevisionSegments(paragraph, options);
205
- if (view === 'rejected') {
206
- return segments.filter(s => s.rejectedStart !== null).map(s => s.text).join('');
207
- }
208
- return segments.filter(s => s.acceptedStart !== null).map(s => s.text).join('');
209
- }
1
+ import { NS_W } from './types.js';
2
+
3
+ function localName(node) {
4
+ const local = node?.localName;
5
+ if (local) return local.indexOf(':') < 0 ? local : local.replace(/^.*:/, '');
6
+ return String(node?.nodeName || '').replace(/^.*:/, '');
7
+ }
8
+
9
+ /** Returns whether a node contributes to the selected revision view. */
10
+ export function isNodeVisibleInRevisionView(node, boundary = null, revisionView = 'accepted') {
11
+ const view = revisionView === 'current' ? 'accepted' : revisionView;
12
+ let cursor = node;
13
+ while (cursor && cursor !== boundary) {
14
+ const name = localName(cursor);
15
+ if (view === 'accepted' && (name === 'del' || name === 'moveFrom')) return false;
16
+ if (view === 'rejected' && (name === 'ins' || name === 'moveTo')) return false;
17
+ cursor = cursor.parentNode;
18
+ }
19
+ return true;
20
+ }
21
+
22
+ /** Reads exact visible text from a run, including tabs, breaks and non-breaking hyphens. */
23
+ export function readCanonicalRunText(run, options = {}) {
24
+ const revisionView = options.revisionView === 'current' ? 'accepted' : (options.revisionView || 'accepted');
25
+ const boundary = options.boundary || null;
26
+ if (!isNodeVisibleInRevisionView(run, boundary, revisionView)) return '';
27
+ let text = '';
28
+ const visit = node => {
29
+ for (let child = node?.firstChild; child; child = child.nextSibling) {
30
+ if (child?.nodeType !== 1 || (child.namespaceURI && child.namespaceURI !== NS_W)) continue;
31
+ if (!isNodeVisibleInRevisionView(child, boundary, revisionView)) continue;
32
+ const name = localName(child);
33
+ if (name === 't' || (name === 'delText' && revisionView === 'rejected')) text += child.textContent || '';
34
+ else if (name === 'tab') text += '\t';
35
+ else if (name === 'br' || name === 'cr') text += '\n';
36
+ else if (name === 'noBreakHyphen') text += '\u2011';
37
+ else if (name === 'softHyphen') text += '\u00ad';
38
+ else visit(child);
39
+ }
40
+ };
41
+ visit(run);
42
+ return text;
43
+ }
44
+
45
+ const attr = (node, name) => node?.getAttribute?.(`w:${name}`) || node?.getAttribute?.(name) || '';
46
+
47
+ /**
48
+ * Extracts revision-view text segments from a paragraph or container node.
49
+ *
50
+ * Emits structural text pieces tracking revision ancestry (ins, del, moveFrom, moveTo),
51
+ * computes independent accepted and rejected UTF-16 cursor offsets, and merges
52
+ * adjacent pieces with compatible carrier containers and revision metadata.
53
+ *
54
+ * @param {Element | null | undefined} paragraph - Paragraph or container element
55
+ * @param {Object} [options={}] - Extraction options
56
+ * @param {boolean} [options.mergeRuns=true] - Merge adjacent runs sharing compatible container and revision metadata
57
+ * @returns {Array<{ text: string, kind: 'baseline' | 'insertion' | 'deletion' | 'move_from' | 'move_to', author?: string, revisionId?: string, acceptedStart: number | null, rejectedStart: number | null }>}
58
+ */
59
+ export function extractParagraphRevisionSegments(paragraph, options = {}) {
60
+ if (!paragraph) return [];
61
+
62
+ const rawPieces = [];
63
+
64
+ function walk(node, currentRevision, currentCarrier) {
65
+ for (const child of Array.from(node?.childNodes || [])) {
66
+ if (child?.nodeType !== 1) continue;
67
+ if (child.namespaceURI && child.namespaceURI !== NS_W) continue;
68
+
69
+ const name = localName(child);
70
+ if (name === 'pPr' || name === 'rPr') continue;
71
+
72
+ let nextRevision = currentRevision;
73
+ if (name === 'ins') {
74
+ nextRevision = {
75
+ kind: 'insertion',
76
+ author: attr(child, 'author') || undefined,
77
+ revisionId: attr(child, 'id') || undefined
78
+ };
79
+ } else if (name === 'del') {
80
+ nextRevision = {
81
+ kind: 'deletion',
82
+ author: attr(child, 'author') || undefined,
83
+ revisionId: attr(child, 'id') || undefined
84
+ };
85
+ } else if (name === 'moveFrom') {
86
+ nextRevision = {
87
+ kind: 'move_from',
88
+ author: attr(child, 'author') || undefined,
89
+ revisionId: attr(child, 'id') || undefined
90
+ };
91
+ } else if (name === 'moveTo') {
92
+ nextRevision = {
93
+ kind: 'move_to',
94
+ author: attr(child, 'author') || undefined,
95
+ revisionId: attr(child, 'id') || undefined
96
+ };
97
+ }
98
+
99
+ let nextCarrier = currentCarrier;
100
+ if (name === 'r') {
101
+ nextCarrier = child;
102
+ }
103
+
104
+ let text = null;
105
+ let kind = nextRevision ? nextRevision.kind : 'baseline';
106
+ let author = nextRevision?.author;
107
+ let revisionId = nextRevision?.revisionId;
108
+
109
+ if (name === 't') {
110
+ text = child.textContent || '';
111
+ } else if (name === 'delText') {
112
+ text = child.textContent || '';
113
+ if (!nextRevision) {
114
+ kind = 'deletion';
115
+ }
116
+ } else if (name === 'tab') {
117
+ text = '\t';
118
+ } else if (name === 'br' || name === 'cr') {
119
+ text = '\n';
120
+ } else if (name === 'noBreakHyphen') {
121
+ text = '\u2011';
122
+ } else if (name === 'softHyphen') {
123
+ text = '\u00ad';
124
+ }
125
+
126
+ if (text !== null) {
127
+ if (text.length > 0) {
128
+ rawPieces.push({
129
+ text,
130
+ kind,
131
+ author,
132
+ revisionId,
133
+ carrier: nextCarrier || child,
134
+ carrierContainer: (nextCarrier || child)?.parentNode || null
135
+ });
136
+ }
137
+ } else {
138
+ walk(child, nextRevision, nextCarrier);
139
+ }
140
+ }
141
+ }
142
+
143
+ const initialCarrier = localName(paragraph) === 'r' ? paragraph : null;
144
+ walk(paragraph, null, initialCarrier);
145
+
146
+ if (rawPieces.length === 0) return [];
147
+
148
+ const mergeRuns = options.mergeRuns !== false;
149
+ const merged = [];
150
+ let current = null;
151
+
152
+ for (const piece of rawPieces) {
153
+ if (!current) {
154
+ current = { ...piece };
155
+ continue;
156
+ }
157
+
158
+ const sameKind = current.kind === piece.kind;
159
+ const sameAuthor = current.author === piece.author;
160
+ const sameRevisionId = current.revisionId === piece.revisionId;
161
+ const sameCarrier = current.carrier === piece.carrier;
162
+ const compatibleContainer = mergeRuns && current.carrierContainer === piece.carrierContainer;
163
+
164
+ if (sameKind && sameAuthor && sameRevisionId && (sameCarrier || compatibleContainer)) {
165
+ current.text += piece.text;
166
+ } else {
167
+ merged.push(current);
168
+ current = { ...piece };
169
+ }
170
+ }
171
+ if (current) merged.push(current);
172
+
173
+ let acceptedCursor = 0;
174
+ let rejectedCursor = 0;
175
+ const segments = [];
176
+
177
+ for (const item of merged) {
178
+ const isAccepted = item.kind !== 'deletion' && item.kind !== 'move_from';
179
+ const isRejected = item.kind !== 'insertion' && item.kind !== 'move_to';
180
+
181
+ const acceptedStart = isAccepted ? acceptedCursor : null;
182
+ const rejectedStart = isRejected ? rejectedCursor : null;
183
+
184
+ if (isAccepted) acceptedCursor += item.text.length;
185
+ if (isRejected) rejectedCursor += item.text.length;
186
+
187
+ const segment = {
188
+ text: item.text,
189
+ kind: item.kind,
190
+ acceptedStart,
191
+ rejectedStart
192
+ };
193
+ if (item.author !== undefined) segment.author = item.author;
194
+ if (item.revisionId !== undefined) segment.revisionId = item.revisionId;
195
+
196
+ segments.push(segment);
197
+ }
198
+
199
+ return segments;
200
+ }
201
+
202
+ /** Canonical exact paragraph text used by inspection, targeting and ingestion. */
203
+ export function extractCanonicalParagraphText(paragraph, options = {}) {
204
+ if (!paragraph) return '';
205
+ const view = options.revisionView === 'current' ? 'accepted' : (options.revisionView || 'accepted');
206
+ const segments = extractParagraphRevisionSegments(paragraph, options);
207
+ if (view === 'rejected') {
208
+ return segments.filter(s => s.rejectedStart !== null).map(s => s.text).join('');
209
+ }
210
+ return segments.filter(s => s.acceptedStart !== null).map(s => s.text).join('');
211
+ }
package/core/types.js CHANGED
@@ -329,9 +329,10 @@ export function createRevisionMetadata(author, allocatorOrNode = null, kind = nu
329
329
  *
330
330
  * @param {string} [author]
331
331
  * @param {RevisionIdAllocator|Document|Element|null} [allocatorOrNode=null]
332
+ * @param {{ deferReceipt?: boolean }} [options] - Defer receipt recording to `event.recordUsed(kind)`.
332
333
  * @returns {ReplacementRevisionEvent}
333
334
  */
334
- export function createReplacementRevisionEvent(author, allocatorOrNode = null) {
335
+ export function createReplacementRevisionEvent(author, allocatorOrNode = null, { deferReceipt = false } = {}) {
335
336
  const resolvedAuthor = typeof author === 'string' && author.trim()
336
337
  ? author.trim()
337
338
  : getDefaultAuthor();
@@ -342,7 +343,7 @@ export function createReplacementRevisionEvent(author, allocatorOrNode = null) {
342
343
  const deletionId = allocator.next();
343
344
  const insertionId = allocator.next();
344
345
 
345
- if (allocator._receiptCollector) {
346
+ if (allocator._receiptCollector && !deferReceipt) {
346
347
  allocator._receiptCollector.recordRevision(deletionId, 'del');
347
348
  allocator._receiptCollector.recordRevision(insertionId, 'ins');
348
349
  }
@@ -351,7 +352,12 @@ export function createReplacementRevisionEvent(author, allocatorOrNode = null) {
351
352
  deletionId,
352
353
  insertionId,
353
354
  author: resolvedAuthor,
354
- date
355
+ date,
356
+ // With deferReceipt the caller records an id only once it is actually written to the document, so an id
357
+ // the diff never emits (a deletion of paragraph breaks only has no run to carry it) never reaches a receipt.
358
+ recordUsed: deferReceipt
359
+ ? kind => allocator._receiptCollector?.recordRevision(kind === 'del' ? deletionId : insertionId, kind)
360
+ : null
355
361
  };
356
362
  }
357
363