@ansonlai/docx-redline-js 0.8.4 → 0.10.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 (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  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/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Single accessor for CURRENT paragraph/run properties.
3
+ *
4
+ * Descendant lookups (`getElementsByTagNameNS`, `getWordDescendants`, ...) cross `w:pPrChange` and
5
+ * `w:rPrChange`, so a historical `numPr`/`pStyle`/`b` snapshot looks like a current value. Everything in
6
+ * this module reads direct children of `w:pPr` / `w:rPr` only. The `*PrChange` snapshot is reachable
7
+ * only through the explicit `getHistorical*` functions (reject simulation, restore).
8
+ *
9
+ * Depends only on `core/types.js`.
10
+ */
11
+
12
+ import { NS_W } from './types.js';
13
+
14
+ function isWordElement(node, localName) {
15
+ if (!node || node.nodeType !== 1) return false;
16
+ const name = String(node.localName || node.nodeName || '').replace(/^.*:/, '');
17
+ if (localName != null && name !== localName) return false;
18
+ return !node.namespaceURI || node.namespaceURI === NS_W;
19
+ }
20
+
21
+ /** First direct child element with the given WordprocessingML local name. */
22
+ export function getDirectWordChild(node, localName) {
23
+ if (!node) return null;
24
+ for (const child of Array.from(node.childNodes || [])) {
25
+ if (isWordElement(child, localName)) return child;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /** Direct child elements with the given local name (all direct Word elements when omitted). */
31
+ export function getDirectWordChildren(node, localName) {
32
+ if (!node) return [];
33
+ return Array.from(node.childNodes || []).filter(child => isWordElement(child, localName));
34
+ }
35
+
36
+ /** Direct child of a properties element; never descends into `*PrChange` snapshots. */
37
+ export function getCurrentChild(propsEl, localName) {
38
+ return getDirectWordChild(propsEl, localName);
39
+ }
40
+
41
+ function readVal(el) {
42
+ if (!el) return null;
43
+ const value = (typeof el.getAttributeNS === 'function' && el.getAttributeNS(NS_W, 'val'))
44
+ || el.getAttribute?.('w:val')
45
+ || el.getAttribute?.('val')
46
+ || null;
47
+ return value === '' ? null : value;
48
+ }
49
+
50
+ function readNumPr(propsEl) {
51
+ const numPr = getDirectWordChild(propsEl, 'numPr');
52
+ if (!numPr) return null;
53
+ return {
54
+ element: numPr,
55
+ numId: readVal(getDirectWordChild(numPr, 'numId')),
56
+ ilvl: readVal(getDirectWordChild(numPr, 'ilvl')),
57
+ numIdElement: getDirectWordChild(numPr, 'numId'),
58
+ ilvlElement: getDirectWordChild(numPr, 'ilvl')
59
+ };
60
+ }
61
+
62
+ function readParagraphProps(pPr) {
63
+ const pStyleEl = getDirectWordChild(pPr, 'pStyle');
64
+ const outlineEl = getDirectWordChild(pPr, 'outlineLvl');
65
+ return {
66
+ pPr: pPr || null,
67
+ numPr: readNumPr(pPr),
68
+ pStyle: readVal(pStyleEl),
69
+ pStyleElement: pStyleEl,
70
+ outlineLvl: readVal(outlineEl),
71
+ outlineLvlElement: outlineEl
72
+ };
73
+ }
74
+
75
+ /** Current paragraph properties: the paragraph's direct `w:pPr` only. */
76
+ export function getCurrentParagraphProperties(p) {
77
+ return readParagraphProps(getDirectWordChild(p, 'pPr'));
78
+ }
79
+
80
+ /** Current run properties: the run's direct `w:rPr` (null when absent). */
81
+ export function getCurrentRunProperties(r) {
82
+ return getDirectWordChild(r, 'rPr');
83
+ }
84
+
85
+ /** Paragraph-mark run properties (`w:pPr/w:rPr`), distinct from any run's `rPr`. */
86
+ export function getParagraphMarkProperties(p) {
87
+ return getDirectWordChild(getDirectWordChild(p, 'pPr'), 'rPr');
88
+ }
89
+
90
+ /**
91
+ * Historical paragraph properties: the `w:pPrChange/w:pPr` snapshot. Explicit opt-in; shape matches
92
+ * `getCurrentParagraphProperties`. Returns null when the paragraph has no tracked property change.
93
+ */
94
+ export function getHistoricalParagraphProperties(p) {
95
+ const change = getDirectWordChild(getDirectWordChild(p, 'pPr'), 'pPrChange');
96
+ const snapshot = getDirectWordChild(change, 'pPr');
97
+ return snapshot ? readParagraphProps(snapshot) : null;
98
+ }
99
+
100
+ /** Historical run properties: the `w:rPrChange/w:rPr` snapshot, or null. */
101
+ export function getHistoricalRunProperties(r) {
102
+ const change = getDirectWordChild(getDirectWordChild(r, 'rPr'), 'rPrChange');
103
+ return getDirectWordChild(change, 'rPr');
104
+ }
105
+
106
+ /** All `w:p` elements at or under `root` (the paragraph itself when `root` is one). */
107
+ function paragraphsUnder(root) {
108
+ if (!root || root.nodeType !== 1) return [];
109
+ const out = isWordElement(root, 'p') ? [root] : [];
110
+ if (typeof root.getElementsByTagNameNS === 'function') {
111
+ out.push(...Array.from(root.getElementsByTagNameNS(NS_W, 'p')));
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Current `w:numId` elements (direct `pPr/numPr/numId`) of every paragraph at or under `root`.
118
+ * Excludes `pPrChange` snapshots, so Reject still restores the snapshot numId.
119
+ */
120
+ export function getCurrentNumIdElements(root) {
121
+ const out = [];
122
+ for (const p of paragraphsUnder(root)) {
123
+ const el = getCurrentParagraphProperties(p).numPr?.numIdElement;
124
+ if (el) out.push(el);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Every `w:numId` element referenced anywhere under `root`, INCLUDING `pPrChange` snapshots. Explicitly
131
+ * historical-inclusive: used when a numbering part must define every id the content can ever reference.
132
+ */
133
+ export function getAllReferencedNumIdElements(root) {
134
+ if (!root || typeof root.getElementsByTagNameNS !== 'function') return [];
135
+ return Array.from(root.getElementsByTagNameNS(NS_W, 'numId'));
136
+ }
@@ -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