@usejunior/docx-core 0.9.1 → 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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/atomizer.d.ts +15 -1
- package/dist/atomizer.d.ts.map +1 -1
- package/dist/atomizer.js +37 -1
- package/dist/atomizer.js.map +1 -1
- package/dist/baselines/atomizer/documentReconstructor.d.ts.map +1 -1
- package/dist/baselines/atomizer/documentReconstructor.js +218 -90
- package/dist/baselines/atomizer/documentReconstructor.js.map +1 -1
- package/dist/baselines/atomizer/formattingFidelity.d.ts +99 -0
- package/dist/baselines/atomizer/formattingFidelity.d.ts.map +1 -0
- package/dist/baselines/atomizer/formattingFidelity.js +449 -0
- package/dist/baselines/atomizer/formattingFidelity.js.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-bookmarks.d.ts +37 -0
- package/dist/baselines/atomizer/inPlaceModifier-bookmarks.d.ts.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-bookmarks.js +189 -0
- package/dist/baselines/atomizer/inPlaceModifier-bookmarks.js.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-containers.d.ts +74 -0
- package/dist/baselines/atomizer/inPlaceModifier-containers.d.ts.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-containers.js +171 -0
- package/dist/baselines/atomizer/inPlaceModifier-containers.js.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-deletion.d.ts +88 -0
- package/dist/baselines/atomizer/inPlaceModifier-deletion.d.ts.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-deletion.js +326 -0
- package/dist/baselines/atomizer/inPlaceModifier-deletion.js.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-postprocess.d.ts +85 -0
- package/dist/baselines/atomizer/inPlaceModifier-postprocess.d.ts.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-postprocess.js +402 -0
- package/dist/baselines/atomizer/inPlaceModifier-postprocess.js.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-presplit.d.ts +39 -0
- package/dist/baselines/atomizer/inPlaceModifier-presplit.d.ts.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-presplit.js +265 -0
- package/dist/baselines/atomizer/inPlaceModifier-presplit.js.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-shared.d.ts +62 -0
- package/dist/baselines/atomizer/inPlaceModifier-shared.d.ts.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-shared.js +139 -0
- package/dist/baselines/atomizer/inPlaceModifier-shared.js.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-wrappers.d.ts +189 -0
- package/dist/baselines/atomizer/inPlaceModifier-wrappers.d.ts.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier-wrappers.js +427 -0
- package/dist/baselines/atomizer/inPlaceModifier-wrappers.js.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier.d.ts +6 -290
- package/dist/baselines/atomizer/inPlaceModifier.d.ts.map +1 -1
- package/dist/baselines/atomizer/inPlaceModifier.js +23 -1828
- package/dist/baselines/atomizer/inPlaceModifier.js.map +1 -1
- package/dist/baselines/atomizer/pipeline.d.ts +76 -1
- package/dist/baselines/atomizer/pipeline.d.ts.map +1 -1
- package/dist/baselines/atomizer/pipeline.js +204 -27
- package/dist/baselines/atomizer/pipeline.js.map +1 -1
- package/dist/baselines/atomizer/trackChangesAcceptorAst.d.ts.map +1 -1
- package/dist/baselines/atomizer/trackChangesAcceptorAst.js +56 -160
- package/dist/baselines/atomizer/trackChangesAcceptorAst.js.map +1 -1
- package/dist/compare-types.d.ts +151 -0
- package/dist/compare-types.d.ts.map +1 -0
- package/dist/compare-types.js +2 -0
- package/dist/compare-types.js.map +1 -0
- package/dist/core-types.d.ts +5 -1
- package/dist/core-types.d.ts.map +1 -1
- package/dist/core-types.js +5 -1
- package/dist/core-types.js.map +1 -1
- package/dist/footnotes.d.ts +8 -3
- package/dist/footnotes.d.ts.map +1 -1
- package/dist/footnotes.js +8 -3
- package/dist/footnotes.js.map +1 -1
- package/dist/index.d.ts +6 -150
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/integration/libreoffice-oracle.d.ts +41 -0
- package/dist/integration/libreoffice-oracle.d.ts.map +1 -0
- package/dist/integration/libreoffice-oracle.js +282 -0
- package/dist/integration/libreoffice-oracle.js.map +1 -0
- package/dist/primitives/accept_changes.d.ts +2 -2
- package/dist/primitives/accept_changes.d.ts.map +1 -1
- package/dist/primitives/accept_changes.js +24 -79
- package/dist/primitives/accept_changes.js.map +1 -1
- package/dist/primitives/comments.d.ts +12 -3
- package/dist/primitives/comments.d.ts.map +1 -1
- package/dist/primitives/comments.js +374 -97
- package/dist/primitives/comments.js.map +1 -1
- package/dist/primitives/content_fingerprint.d.ts +29 -0
- package/dist/primitives/content_fingerprint.d.ts.map +1 -0
- package/dist/primitives/content_fingerprint.js +63 -0
- package/dist/primitives/content_fingerprint.js.map +1 -0
- package/dist/primitives/document.d.ts +56 -15
- package/dist/primitives/document.d.ts.map +1 -1
- package/dist/primitives/document.js +303 -32
- package/dist/primitives/document.js.map +1 -1
- package/dist/primitives/document_view-comments.d.ts +18 -0
- package/dist/primitives/document_view-comments.d.ts.map +1 -0
- package/dist/primitives/document_view-comments.js +159 -0
- package/dist/primitives/document_view-comments.js.map +1 -0
- package/dist/primitives/document_view-headings.d.ts +45 -0
- package/dist/primitives/document_view-headings.d.ts.map +1 -0
- package/dist/primitives/document_view-headings.js +247 -0
- package/dist/primitives/document_view-headings.js.map +1 -0
- package/dist/primitives/document_view-styles.d.ts +11 -0
- package/dist/primitives/document_view-styles.d.ts.map +1 -0
- package/dist/primitives/document_view-styles.js +104 -0
- package/dist/primitives/document_view-styles.js.map +1 -0
- package/dist/primitives/document_view-toon.d.ts +37 -0
- package/dist/primitives/document_view-toon.d.ts.map +1 -0
- package/dist/primitives/document_view-toon.js +199 -0
- package/dist/primitives/document_view-toon.js.map +1 -0
- package/dist/primitives/document_view-types.d.ts +137 -0
- package/dist/primitives/document_view-types.d.ts.map +1 -0
- package/dist/primitives/document_view-types.js +2 -0
- package/dist/primitives/document_view-types.js.map +1 -0
- package/dist/primitives/document_view.d.ts +8 -106
- package/dist/primitives/document_view.d.ts.map +1 -1
- package/dist/primitives/document_view.js +134 -301
- package/dist/primitives/document_view.js.map +1 -1
- package/dist/primitives/dom-helpers.d.ts +9 -0
- package/dist/primitives/dom-helpers.d.ts.map +1 -1
- package/dist/primitives/dom-helpers.js +10 -1
- package/dist/primitives/dom-helpers.js.map +1 -1
- package/dist/primitives/footnotes.d.ts +4 -3
- package/dist/primitives/footnotes.d.ts.map +1 -1
- package/dist/primitives/footnotes.js +232 -44
- package/dist/primitives/footnotes.js.map +1 -1
- package/dist/primitives/formatting_tags.d.ts +6 -0
- package/dist/primitives/formatting_tags.d.ts.map +1 -1
- package/dist/primitives/formatting_tags.js +6 -1
- package/dist/primitives/formatting_tags.js.map +1 -1
- package/dist/primitives/index.d.ts +6 -0
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +5 -0
- package/dist/primitives/index.js.map +1 -1
- package/dist/primitives/layout.d.ts +4 -3
- package/dist/primitives/layout.d.ts.map +1 -1
- package/dist/primitives/layout.js +32 -3
- package/dist/primitives/layout.js.map +1 -1
- package/dist/primitives/merge_runs.d.ts +21 -3
- package/dist/primitives/merge_runs.d.ts.map +1 -1
- package/dist/primitives/merge_runs.js +32 -10
- package/dist/primitives/merge_runs.js.map +1 -1
- package/dist/primitives/namespaces.d.ts +6 -0
- package/dist/primitives/namespaces.d.ts.map +1 -1
- package/dist/primitives/namespaces.js +9 -0
- package/dist/primitives/namespaces.js.map +1 -1
- package/dist/primitives/reject_changes.d.ts +2 -2
- package/dist/primitives/reject_changes.d.ts.map +1 -1
- package/dist/primitives/reject_changes.js +24 -81
- package/dist/primitives/reject_changes.js.map +1 -1
- package/dist/primitives/semantic_tags.d.ts +7 -0
- package/dist/primitives/semantic_tags.d.ts.map +1 -1
- package/dist/primitives/semantic_tags.js +21 -3
- package/dist/primitives/semantic_tags.js.map +1 -1
- package/dist/primitives/serialize_html.d.ts +36 -0
- package/dist/primitives/serialize_html.d.ts.map +1 -0
- package/dist/primitives/serialize_html.js +393 -0
- package/dist/primitives/serialize_html.js.map +1 -0
- package/dist/primitives/serialize_markdown.d.ts +16 -0
- package/dist/primitives/serialize_markdown.d.ts.map +1 -0
- package/dist/primitives/serialize_markdown.js +300 -0
- package/dist/primitives/serialize_markdown.js.map +1 -0
- package/dist/primitives/serialize_plaintext.d.ts +15 -0
- package/dist/primitives/serialize_plaintext.d.ts.map +1 -0
- package/dist/primitives/serialize_plaintext.js +154 -0
- package/dist/primitives/serialize_plaintext.js.map +1 -0
- package/dist/primitives/styles.js +22 -22
- package/dist/primitives/styles.js.map +1 -1
- package/dist/primitives/tables.d.ts.map +1 -1
- package/dist/primitives/tables.js +13 -3
- package/dist/primitives/tables.js.map +1 -1
- package/dist/primitives/text.d.ts +2 -1
- package/dist/primitives/text.d.ts.map +1 -1
- package/dist/primitives/text.js +116 -12
- package/dist/primitives/text.js.map +1 -1
- package/dist/primitives/track-changes-emitter.d.ts +139 -0
- package/dist/primitives/track-changes-emitter.d.ts.map +1 -0
- package/dist/primitives/track-changes-emitter.js +241 -0
- package/dist/primitives/track-changes-emitter.js.map +1 -0
- package/dist/primitives/xml-helpers.d.ts +29 -0
- package/dist/primitives/xml-helpers.d.ts.map +1 -0
- package/dist/primitives/xml-helpers.js +35 -0
- package/dist/primitives/xml-helpers.js.map +1 -0
- package/dist/shared/ooxml/namespaces.d.ts +4 -1
- package/dist/shared/ooxml/namespaces.d.ts.map +1 -1
- package/dist/shared/ooxml/namespaces.js +4 -1
- package/dist/shared/ooxml/namespaces.js.map +1 -1
- package/package.json +7 -6
|
@@ -11,1430 +11,18 @@
|
|
|
11
11
|
* - addFormatChange: Add <w:rPrChange> for formatting differences
|
|
12
12
|
*/
|
|
13
13
|
import { CorrelationStatus } from '../../core-types.js';
|
|
14
|
-
import {
|
|
15
|
-
import { getLeafText, childElements, findChildByTagName, insertAfterElement, wrapElement, unwrapElement, splitRunAtVisibleOffset, visibleLengthForEl, getDirectContentElements, } from '../../primitives/index.js';
|
|
16
|
-
import { areRunPropertiesEqual } from '../../format-detection.js';
|
|
14
|
+
import { childElements, findChildByTagName, insertAfterElement } from '../../primitives/index.js';
|
|
17
15
|
import { enforceConsumerCompatibility } from './consumerCompatibility.js';
|
|
18
16
|
import { XMLSerializer } from '@xmldom/xmldom';
|
|
19
|
-
import {
|
|
17
|
+
import { findChild } from '../../primitives/xml-helpers.js';
|
|
20
18
|
import { warn } from './debug.js';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const el = SYNTHETIC_DOC.createElementNS(W_NS, tag);
|
|
29
|
-
if (attrs)
|
|
30
|
-
for (const [k, v] of Object.entries(attrs))
|
|
31
|
-
el.setAttribute(k, v);
|
|
32
|
-
return el;
|
|
33
|
-
}
|
|
34
|
-
function findAncestorByTag(atom, tagName) {
|
|
35
|
-
for (let i = atom.ancestorElements.length - 1; i >= 0; i--) {
|
|
36
|
-
const el = atom.ancestorElements[i];
|
|
37
|
-
if (el.tagName === tagName)
|
|
38
|
-
return el;
|
|
39
|
-
}
|
|
40
|
-
return undefined;
|
|
41
|
-
}
|
|
42
|
-
function attachSourceElementPointers(atoms) {
|
|
43
|
-
for (const atom of atoms) {
|
|
44
|
-
atom.sourceRunElement = findAncestorByTag(atom, 'w:r');
|
|
45
|
-
atom.sourceParagraphElement = findAncestorByTag(atom, 'w:p');
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
// Container-aware insertion helpers (issue #65)
|
|
50
|
-
// ---------------------------------------------------------------------------
|
|
51
|
-
/**
|
|
52
|
-
* Error thrown when the original and revised documents have different table/container
|
|
53
|
-
* topology, making container-aware inplace insertion impossible.
|
|
54
|
-
* Caught by the adaptive pass loop to trigger rebuild fallback.
|
|
55
|
-
*/
|
|
56
|
-
export class ContainerResolutionError extends Error {
|
|
57
|
-
constructor(message) {
|
|
58
|
-
super(message);
|
|
59
|
-
this.name = 'ContainerResolutionError';
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Compute the structural path from a paragraph to the document body.
|
|
64
|
-
* Walks `parentNode` from the paragraph, recording {tag, index} for each
|
|
65
|
-
* structural container (w:tc, w:tr, w:tbl). Stops at w:body.
|
|
66
|
-
* Returns innermost-first order.
|
|
67
|
-
*
|
|
68
|
-
* Uses original-tree nodes — safe because only the revised tree is mutated.
|
|
69
|
-
*/
|
|
70
|
-
export function getContainerPath(paragraph) {
|
|
71
|
-
const path = [];
|
|
72
|
-
let current = paragraph.parentNode;
|
|
73
|
-
while (current && current.tagName) {
|
|
74
|
-
const el = current;
|
|
75
|
-
if (el.tagName === 'w:body')
|
|
76
|
-
break;
|
|
77
|
-
if (el.tagName === 'w:tc' || el.tagName === 'w:tr' || el.tagName === 'w:tbl') {
|
|
78
|
-
const parent = el.parentNode;
|
|
79
|
-
if (parent) {
|
|
80
|
-
let index = 0;
|
|
81
|
-
let sibling = el.previousSibling;
|
|
82
|
-
while (sibling) {
|
|
83
|
-
if (sibling.nodeType === 1 && sibling.tagName === el.tagName) {
|
|
84
|
-
index++;
|
|
85
|
-
}
|
|
86
|
-
sibling = sibling.previousSibling;
|
|
87
|
-
}
|
|
88
|
-
path.push({ tag: el.tagName, index });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
current = el.parentNode;
|
|
92
|
-
}
|
|
93
|
-
return path;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Resolve a container path in the revised tree.
|
|
97
|
-
* Walks the path in reverse (outermost → innermost) from `body`.
|
|
98
|
-
* Returns the deepest container (typically w:tc), or null on mismatch.
|
|
99
|
-
*/
|
|
100
|
-
export function resolveContainerInRevised(path, body) {
|
|
101
|
-
if (path.length === 0)
|
|
102
|
-
return null;
|
|
103
|
-
let current = body;
|
|
104
|
-
// Walk outermost to innermost (path is innermost-first, so reverse)
|
|
105
|
-
for (let i = path.length - 1; i >= 0; i--) {
|
|
106
|
-
const step = path[i];
|
|
107
|
-
const children = childElements(current).filter(c => c.tagName === step.tag);
|
|
108
|
-
const child = children[step.index];
|
|
109
|
-
if (!child)
|
|
110
|
-
return null; // Structural mismatch
|
|
111
|
-
current = child;
|
|
112
|
-
}
|
|
113
|
-
return current;
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Validate that the revised tree has compatible topology at the given path.
|
|
117
|
-
* Checks row count and cell count match at the target position.
|
|
118
|
-
* Returns false if there's a structural mismatch (row/cell additions, gridSpan divergence).
|
|
119
|
-
*/
|
|
120
|
-
export function validateContainerTopology(path, body) {
|
|
121
|
-
if (path.length === 0)
|
|
122
|
-
return true; // Body-level, always valid
|
|
123
|
-
let current = body;
|
|
124
|
-
for (let i = path.length - 1; i >= 0; i--) {
|
|
125
|
-
const step = path[i];
|
|
126
|
-
const children = childElements(current).filter(c => c.tagName === step.tag);
|
|
127
|
-
if (step.index >= children.length)
|
|
128
|
-
return false;
|
|
129
|
-
current = children[step.index];
|
|
130
|
-
}
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Find the correct container and insertion anchor for a deleted/moved-source atom.
|
|
135
|
-
*
|
|
136
|
-
* For body-level atoms, returns ctx.body with the global lastProcessedParagraph anchor.
|
|
137
|
-
* For table-cell atoms, maps from the original tree container to the revised tree container
|
|
138
|
-
* by structural position, and uses the per-container anchor from lastParaByContainer.
|
|
139
|
-
*
|
|
140
|
-
* Returns null if container resolution fails (topology mismatch) — caller must throw
|
|
141
|
-
* ContainerResolutionError to trigger rebuild fallback.
|
|
142
|
-
*/
|
|
143
|
-
function findTargetContainerForAtom(atom, ctx) {
|
|
144
|
-
// 1. Check if atom was in a table cell (original tree ancestors)
|
|
145
|
-
const sourceTc = findAncestorByTag(atom, 'w:tc');
|
|
146
|
-
if (!sourceTc) {
|
|
147
|
-
// Body-level paragraph — use global anchor (current behavior, correct)
|
|
148
|
-
return { container: ctx.body, insertAfter: ctx.lastProcessedParagraph };
|
|
149
|
-
}
|
|
150
|
-
// 2. Compute structural path from original tree
|
|
151
|
-
const sourcePara = atom.sourceParagraphElement;
|
|
152
|
-
if (!sourcePara) {
|
|
153
|
-
return null; // Can't resolve → force rebuild
|
|
154
|
-
}
|
|
155
|
-
const path = getContainerPath(sourcePara);
|
|
156
|
-
if (path.length === 0) {
|
|
157
|
-
// Paragraph has a w:tc ancestor but path is empty — shouldn't happen
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
// 3. Validate topology match before resolving
|
|
161
|
-
if (!validateContainerTopology(path, ctx.body)) {
|
|
162
|
-
return null; // Structural mismatch → force rebuild
|
|
163
|
-
}
|
|
164
|
-
// 4. Resolve container in revised tree
|
|
165
|
-
const revisedContainer = resolveContainerInRevised(path, ctx.body);
|
|
166
|
-
if (!revisedContainer) {
|
|
167
|
-
return null; // Resolution failed → force rebuild
|
|
168
|
-
}
|
|
169
|
-
// 5. Find container-local insertion anchor
|
|
170
|
-
const anchor = ctx.lastParaByContainer.get(revisedContainer) ?? null;
|
|
171
|
-
return { container: revisedContainer, insertAfter: anchor };
|
|
172
|
-
}
|
|
173
|
-
/**
|
|
174
|
-
* Determine whether an atom is "whitespace-only" for paragraph-level classification.
|
|
175
|
-
*
|
|
176
|
-
* We treat pure whitespace runs/tabs/breaks as ignorable noise, because LCS alignment
|
|
177
|
-
* can mark them Equal even when a whole paragraph was inserted/deleted. If we don't
|
|
178
|
-
* ignore them, Word can end up with a stub paragraph after Accept/Reject All.
|
|
179
|
-
*/
|
|
180
|
-
function isWhitespaceAtom(atom) {
|
|
181
|
-
const el = atom.contentElement;
|
|
182
|
-
if (el.tagName === EMPTY_PARAGRAPH_TAG)
|
|
183
|
-
return true;
|
|
184
|
-
if (el.tagName === 'w:t')
|
|
185
|
-
return ((getLeafText(el) ?? '').trim() === '');
|
|
186
|
-
return el.tagName === 'w:tab' || el.tagName === 'w:br' || el.tagName === 'w:cr';
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* Returns true if every non-empty atom in this paragraph is of the specified status,
|
|
190
|
-
* ignoring whitespace-only atoms.
|
|
191
|
-
*
|
|
192
|
-
* Mirrors the rebuild reconstructor's whole-paragraph classification so that inplace
|
|
193
|
-
* output behaves the same under Word's Accept/Reject All.
|
|
194
|
-
*/
|
|
195
|
-
function isEntireParagraphAtomsWithStatus(atoms, status) {
|
|
196
|
-
let sawAnyContent = false;
|
|
197
|
-
let sawTargetStatus = false;
|
|
198
|
-
for (const atom of atoms) {
|
|
199
|
-
const el = atom.contentElement;
|
|
200
|
-
if (el.tagName === EMPTY_PARAGRAPH_TAG)
|
|
201
|
-
continue;
|
|
202
|
-
sawAnyContent = true;
|
|
203
|
-
if (atom.correlationStatus === status) {
|
|
204
|
-
sawTargetStatus = true;
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
if (isWhitespaceAtom(atom))
|
|
208
|
-
continue;
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
211
|
-
return sawAnyContent && sawTargetStatus;
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Create initial revision ID state.
|
|
215
|
-
*/
|
|
216
|
-
function createRevisionIdState() {
|
|
217
|
-
return {
|
|
218
|
-
nextId: 1,
|
|
219
|
-
moveRangeIds: new Map(),
|
|
220
|
-
wrappedRuns: new Set(),
|
|
221
|
-
emittedSourceBookmarkMarkers: new Set(),
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Allocate a new revision ID.
|
|
226
|
-
*/
|
|
227
|
-
function allocateRevisionId(state) {
|
|
228
|
-
return state.nextId++;
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* Get or allocate move range IDs for a move name.
|
|
232
|
-
*/
|
|
233
|
-
function getMoveRangeIds(state, moveName) {
|
|
234
|
-
let ids = state.moveRangeIds.get(moveName);
|
|
235
|
-
if (!ids) {
|
|
236
|
-
ids = {
|
|
237
|
-
sourceRangeId: allocateRevisionId(state),
|
|
238
|
-
destRangeId: allocateRevisionId(state),
|
|
239
|
-
};
|
|
240
|
-
state.moveRangeIds.set(moveName, ids);
|
|
241
|
-
}
|
|
242
|
-
return ids;
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Format date for OOXML (ISO 8601 without milliseconds).
|
|
246
|
-
*/
|
|
247
|
-
function formatDate(date) {
|
|
248
|
-
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* Convert w:t elements to w:delText within an element tree.
|
|
252
|
-
*
|
|
253
|
-
* @param element - The element to process
|
|
254
|
-
*/
|
|
255
|
-
function convertToDelText(element) {
|
|
256
|
-
if (element.tagName === 'w:t' || element.tagName === 'w:instrText') {
|
|
257
|
-
const newTag = element.tagName === 'w:t' ? 'w:delText' : 'w:delInstrText';
|
|
258
|
-
const newEl = createEl(newTag);
|
|
259
|
-
// Copy text content
|
|
260
|
-
while (element.firstChild)
|
|
261
|
-
newEl.appendChild(element.firstChild);
|
|
262
|
-
// Copy attributes
|
|
263
|
-
for (let i = 0; i < element.attributes.length; i++) {
|
|
264
|
-
const attr = element.attributes[i];
|
|
265
|
-
newEl.setAttribute(attr.name, attr.value);
|
|
266
|
-
}
|
|
267
|
-
element.parentNode?.replaceChild(newEl, element);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
for (const child of childElements(element)) {
|
|
271
|
-
convertToDelText(child);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
/**
|
|
275
|
-
* Build the content elements that should be inserted for an atom.
|
|
276
|
-
*
|
|
277
|
-
* Collapsed field atoms use a synthetic w:t as their top-level contentElement,
|
|
278
|
-
* but retain the original field sequence in collapsedFieldAtoms. For insertion,
|
|
279
|
-
* we must replay the original sequence rather than the synthetic text.
|
|
280
|
-
*
|
|
281
|
-
* @param filterRun - When provided, only return content elements whose
|
|
282
|
-
* collapsed field atom belongs to this specific source run. Used by
|
|
283
|
-
* insertDeletedRun/insertMoveFromRun to emit one cloned run per original
|
|
284
|
-
* source run, preserving multi-run field structure.
|
|
285
|
-
*/
|
|
286
|
-
function getInsertableAtomContentElements(atom, filterRun) {
|
|
287
|
-
if (atom.collapsedFieldAtoms && atom.collapsedFieldAtoms.length > 0) {
|
|
288
|
-
if (filterRun) {
|
|
289
|
-
return atom.collapsedFieldAtoms
|
|
290
|
-
.filter((fieldAtom) => {
|
|
291
|
-
const run = fieldAtom.sourceRunElement ?? findAncestorByTag(fieldAtom, 'w:r');
|
|
292
|
-
return run === filterRun;
|
|
293
|
-
})
|
|
294
|
-
.map((fieldAtom) => fieldAtom.contentElement);
|
|
295
|
-
}
|
|
296
|
-
return atom.collapsedFieldAtoms.map((fieldAtom) => fieldAtom.contentElement);
|
|
297
|
-
}
|
|
298
|
-
return [atom.contentElement];
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Clone a source run and replace its non-rPr children with atom content.
|
|
302
|
-
*
|
|
303
|
-
* This keeps run-level formatting while allowing atom-level fragment insertion.
|
|
304
|
-
*
|
|
305
|
-
* @param filterRun - When provided, only include content elements belonging
|
|
306
|
-
* to this source run (for multi-run collapsed field replay).
|
|
307
|
-
*/
|
|
308
|
-
function cloneRunWithAtomContent(sourceRun, atom, filterRun) {
|
|
309
|
-
const clonedRun = sourceRun.cloneNode(true);
|
|
310
|
-
const retainedChildren = [];
|
|
311
|
-
for (const child of childElements(clonedRun)) {
|
|
312
|
-
if (child.tagName === 'w:rPr') {
|
|
313
|
-
retainedChildren.push(child);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
// Remove all current children from clonedRun
|
|
317
|
-
while (clonedRun.firstChild)
|
|
318
|
-
clonedRun.removeChild(clonedRun.firstChild);
|
|
319
|
-
// Re-append retained rPr children
|
|
320
|
-
for (const child of retainedChildren) {
|
|
321
|
-
clonedRun.appendChild(child);
|
|
322
|
-
}
|
|
323
|
-
for (const contentElement of getInsertableAtomContentElements(atom, filterRun)) {
|
|
324
|
-
const fragment = contentElement.cloneNode(true);
|
|
325
|
-
clonedRun.appendChild(fragment);
|
|
326
|
-
}
|
|
327
|
-
return clonedRun;
|
|
328
|
-
}
|
|
329
|
-
function cloneParagraphBoundaryBookmarkMarkers(sourceParagraph) {
|
|
330
|
-
const kids = sourceParagraph ? childElements(sourceParagraph) : [];
|
|
331
|
-
if (!sourceParagraph || kids.length === 0) {
|
|
332
|
-
return { leading: [], trailing: [], sourceLeading: [], sourceTrailing: [] };
|
|
333
|
-
}
|
|
334
|
-
const children = kids;
|
|
335
|
-
let firstRunIdx = -1;
|
|
336
|
-
let lastRunIdx = -1;
|
|
337
|
-
for (let i = 0; i < children.length; i++) {
|
|
338
|
-
if (children[i]?.tagName === 'w:r') {
|
|
339
|
-
if (firstRunIdx < 0)
|
|
340
|
-
firstRunIdx = i;
|
|
341
|
-
lastRunIdx = i;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
const leading = [];
|
|
345
|
-
const trailing = [];
|
|
346
|
-
const sourceLeading = [];
|
|
347
|
-
const sourceTrailing = [];
|
|
348
|
-
for (let i = 0; i < children.length; i++) {
|
|
349
|
-
const child = children[i];
|
|
350
|
-
if (child.tagName === 'w:bookmarkStart') {
|
|
351
|
-
if (firstRunIdx < 0 || i < firstRunIdx) {
|
|
352
|
-
const cloned = child.cloneNode(true);
|
|
353
|
-
leading.push(cloned);
|
|
354
|
-
sourceLeading.push(child);
|
|
355
|
-
}
|
|
356
|
-
continue;
|
|
357
|
-
}
|
|
358
|
-
if (child.tagName === 'w:bookmarkEnd') {
|
|
359
|
-
if (lastRunIdx < 0 || i > lastRunIdx) {
|
|
360
|
-
const cloned = child.cloneNode(true);
|
|
361
|
-
trailing.push(cloned);
|
|
362
|
-
sourceTrailing.push(child);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
return { leading, trailing, sourceLeading, sourceTrailing };
|
|
367
|
-
}
|
|
368
|
-
function insertLeadingMarkers(paragraph, markers) {
|
|
369
|
-
if (markers.length === 0)
|
|
370
|
-
return null;
|
|
371
|
-
const pPr = findChildByTagName(paragraph, 'w:pPr');
|
|
372
|
-
if (pPr) {
|
|
373
|
-
let anchor = pPr;
|
|
374
|
-
for (const marker of markers) {
|
|
375
|
-
insertAfterElement(anchor, marker);
|
|
376
|
-
anchor = marker;
|
|
377
|
-
}
|
|
378
|
-
return anchor;
|
|
379
|
-
}
|
|
380
|
-
for (let i = markers.length - 1; i >= 0; i--) {
|
|
381
|
-
paragraph.insertBefore(markers[i], paragraph.firstChild);
|
|
382
|
-
}
|
|
383
|
-
return markers[markers.length - 1] ?? null;
|
|
384
|
-
}
|
|
385
|
-
function isBookmarkMarkerTag(tagName) {
|
|
386
|
-
return tagName === 'w:bookmarkStart' || tagName === 'w:bookmarkEnd';
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* Collect direct paragraph bookmark markers adjacent to a source run.
|
|
390
|
-
*
|
|
391
|
-
* Markers between runs (or at paragraph boundaries) are represented as siblings
|
|
392
|
-
* of w:r under w:p. We clone nearby markers so reconstructed deleted/moveFrom
|
|
393
|
-
* fragments preserve bookmark names/IDs needed for Reject All parity.
|
|
394
|
-
*/
|
|
395
|
-
function collectAdjacentSourceBookmarkMarkers(sourceRun) {
|
|
396
|
-
const paragraph = sourceRun.parentNode;
|
|
397
|
-
if (!paragraph || paragraph.tagName !== 'w:p') {
|
|
398
|
-
return [];
|
|
399
|
-
}
|
|
400
|
-
const children = childElements(paragraph);
|
|
401
|
-
const runIndex = children.indexOf(sourceRun);
|
|
402
|
-
if (runIndex < 0) {
|
|
403
|
-
return [];
|
|
404
|
-
}
|
|
405
|
-
const before = [];
|
|
406
|
-
for (let i = runIndex - 1; i >= 0; i--) {
|
|
407
|
-
const child = children[i];
|
|
408
|
-
if (!child)
|
|
409
|
-
break;
|
|
410
|
-
if (child.tagName === 'w:r')
|
|
411
|
-
break;
|
|
412
|
-
if (isBookmarkMarkerTag(child.tagName)) {
|
|
413
|
-
before.unshift(child);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
const after = [];
|
|
417
|
-
for (let i = runIndex + 1; i < children.length; i++) {
|
|
418
|
-
const child = children[i];
|
|
419
|
-
if (!child)
|
|
420
|
-
break;
|
|
421
|
-
if (child.tagName === 'w:r')
|
|
422
|
-
break;
|
|
423
|
-
if (isBookmarkMarkerTag(child.tagName)) {
|
|
424
|
-
after.push(child);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
return [...before, ...after];
|
|
428
|
-
}
|
|
429
|
-
function parentElement(node) {
|
|
430
|
-
const p = node.parentNode;
|
|
431
|
-
return p && p.nodeType === 1 ? p : null;
|
|
432
|
-
}
|
|
433
|
-
function findTreeRoot(node) {
|
|
434
|
-
let current = node;
|
|
435
|
-
let parent = parentElement(current);
|
|
436
|
-
while (parent) {
|
|
437
|
-
current = parent;
|
|
438
|
-
parent = parentElement(current);
|
|
439
|
-
}
|
|
440
|
-
return current;
|
|
441
|
-
}
|
|
442
|
-
function findAncestor(node, tagName) {
|
|
443
|
-
let current = node ?? null;
|
|
444
|
-
while (current) {
|
|
445
|
-
if (current.tagName === tagName)
|
|
446
|
-
return current;
|
|
447
|
-
current = parentElement(current);
|
|
448
|
-
}
|
|
449
|
-
return undefined;
|
|
450
|
-
}
|
|
451
|
-
function hasAncestorTag(node, tagNames) {
|
|
452
|
-
let current = node ? parentElement(node) : null;
|
|
453
|
-
while (current) {
|
|
454
|
-
if (tagNames.has(current.tagName)) {
|
|
455
|
-
return true;
|
|
456
|
-
}
|
|
457
|
-
current = parentElement(current);
|
|
458
|
-
}
|
|
459
|
-
return false;
|
|
460
|
-
}
|
|
461
|
-
function paragraphHasParaInsMarker(paragraph) {
|
|
462
|
-
if (!paragraph || paragraph.tagName !== 'w:p') {
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
465
|
-
const pPr = findChildByTagName(paragraph, 'w:pPr');
|
|
466
|
-
if (!pPr) {
|
|
467
|
-
return false;
|
|
468
|
-
}
|
|
469
|
-
return Array.from(pPr.getElementsByTagName('w:ins')).length > 0;
|
|
470
|
-
}
|
|
471
|
-
function markerSurvivesReject(marker, context) {
|
|
472
|
-
// Markers nested in inserted/move-to content are removed by Reject All.
|
|
473
|
-
if (hasAncestorTag(marker, new Set(['w:ins', 'w:moveTo']))) {
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
// Paragraph-level insertion markers remove whole paragraphs on Reject All.
|
|
477
|
-
const paragraph = findAncestor(marker, 'w:p');
|
|
478
|
-
if (paragraph && context?.isParagraphRemovedOnReject?.(paragraph)) {
|
|
479
|
-
return false;
|
|
480
|
-
}
|
|
481
|
-
if (paragraphHasParaInsMarker(paragraph)) {
|
|
482
|
-
return false;
|
|
483
|
-
}
|
|
484
|
-
return true;
|
|
485
|
-
}
|
|
486
|
-
function targetTreeHasEquivalentBookmarkMarker(targetParagraph, marker, context) {
|
|
487
|
-
const treeRoot = findTreeRoot(targetParagraph);
|
|
488
|
-
if (marker.tagName === 'w:bookmarkStart') {
|
|
489
|
-
const markerId = marker.getAttribute('w:id');
|
|
490
|
-
const markerName = marker.getAttribute('w:name');
|
|
491
|
-
for (const existing of Array.from(treeRoot.getElementsByTagName('w:bookmarkStart'))) {
|
|
492
|
-
if (!markerSurvivesReject(existing, context)) {
|
|
493
|
-
continue;
|
|
494
|
-
}
|
|
495
|
-
const existingName = existing.getAttribute('w:name');
|
|
496
|
-
const existingId = existing.getAttribute('w:id');
|
|
497
|
-
if (markerName && existingName === markerName)
|
|
498
|
-
return true;
|
|
499
|
-
if (!markerName && markerId && existingId === markerId)
|
|
500
|
-
return true;
|
|
501
|
-
}
|
|
502
|
-
return false;
|
|
503
|
-
}
|
|
504
|
-
if (marker.tagName === 'w:bookmarkEnd') {
|
|
505
|
-
const markerId = marker.getAttribute('w:id');
|
|
506
|
-
if (!markerId)
|
|
507
|
-
return false;
|
|
508
|
-
for (const existing of Array.from(treeRoot.getElementsByTagName('w:bookmarkEnd'))) {
|
|
509
|
-
if (!markerSurvivesReject(existing, context)) {
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
512
|
-
if (existing.getAttribute('w:id') === markerId)
|
|
513
|
-
return true;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
return false;
|
|
517
|
-
}
|
|
518
|
-
function cloneUnemittedSourceBookmarkMarkers(sourceRun, targetParagraph, state, context) {
|
|
519
|
-
const markers = collectAdjacentSourceBookmarkMarkers(sourceRun);
|
|
520
|
-
const clones = [];
|
|
521
|
-
for (const marker of markers) {
|
|
522
|
-
if (state.emittedSourceBookmarkMarkers.has(marker)) {
|
|
523
|
-
continue;
|
|
524
|
-
}
|
|
525
|
-
if (targetTreeHasEquivalentBookmarkMarker(targetParagraph, marker, context)) {
|
|
526
|
-
state.emittedSourceBookmarkMarkers.add(marker);
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
state.emittedSourceBookmarkMarkers.add(marker);
|
|
530
|
-
const cloned = marker.cloneNode(true);
|
|
531
|
-
clones.push(cloned);
|
|
532
|
-
}
|
|
533
|
-
return clones;
|
|
534
|
-
}
|
|
535
|
-
function insertMarkersBeforeWrapper(wrapper, markers) {
|
|
536
|
-
const parent = wrapper.parentNode;
|
|
537
|
-
if (!parent)
|
|
538
|
-
return;
|
|
539
|
-
for (const marker of markers) {
|
|
540
|
-
if (!marker)
|
|
541
|
-
continue;
|
|
542
|
-
parent.insertBefore(marker, wrapper);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
function filterEquivalentBookmarkMarkers(markers, targetNode, context) {
|
|
546
|
-
return markers.filter((marker) => !targetTreeHasEquivalentBookmarkMarker(targetNode, marker, context));
|
|
547
|
-
}
|
|
548
|
-
const TRACK_CHANGE_WRAPPERS = new Set([
|
|
549
|
-
'w:ins',
|
|
550
|
-
'w:del',
|
|
551
|
-
'w:moveFrom',
|
|
552
|
-
'w:moveTo',
|
|
553
|
-
]);
|
|
554
|
-
/**
|
|
555
|
-
* Resolve the run associated with an atom boundary.
|
|
556
|
-
*
|
|
557
|
-
* For collapsed field atoms, sourceRunElement points at the first run in the
|
|
558
|
-
* field sequence. For insertion-point tracking we often need the trailing run,
|
|
559
|
-
* otherwise deleted/moved fragments can be inserted inside the field sequence.
|
|
560
|
-
*/
|
|
561
|
-
function getAtomRunAtBoundary(atom, boundary) {
|
|
562
|
-
if (atom.collapsedFieldAtoms && atom.collapsedFieldAtoms.length > 0) {
|
|
563
|
-
const fieldAtoms = boundary === 'start'
|
|
564
|
-
? atom.collapsedFieldAtoms
|
|
565
|
-
: [...atom.collapsedFieldAtoms].reverse();
|
|
566
|
-
for (const fieldAtom of fieldAtoms) {
|
|
567
|
-
const run = fieldAtom.sourceRunElement ?? findAncestorByTag(fieldAtom, 'w:r');
|
|
568
|
-
if (run)
|
|
569
|
-
return run;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
return atom.sourceRunElement ?? findAncestorByTag(atom, 'w:r');
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* Resolve all run elements represented by an atom.
|
|
576
|
-
*
|
|
577
|
-
* For collapsed-field atoms, we must treat the entire field run sequence as a
|
|
578
|
-
* single logical unit. Wrapping only the first run leaves trailing field-code
|
|
579
|
-
* runs untracked, which can leak revised field text after Reject All.
|
|
580
|
-
*/
|
|
581
|
-
function getAtomRuns(atom) {
|
|
582
|
-
if (!atom.collapsedFieldAtoms || atom.collapsedFieldAtoms.length === 0) {
|
|
583
|
-
const run = atom.sourceRunElement ?? findAncestorByTag(atom, 'w:r');
|
|
584
|
-
return run ? [run] : [];
|
|
585
|
-
}
|
|
586
|
-
const runs = [];
|
|
587
|
-
const seen = new Set();
|
|
588
|
-
for (const fieldAtom of atom.collapsedFieldAtoms) {
|
|
589
|
-
const run = fieldAtom.sourceRunElement ?? findAncestorByTag(fieldAtom, 'w:r');
|
|
590
|
-
if (!run || seen.has(run))
|
|
591
|
-
continue;
|
|
592
|
-
seen.add(run);
|
|
593
|
-
runs.push(run);
|
|
594
|
-
}
|
|
595
|
-
return runs;
|
|
596
|
-
}
|
|
597
|
-
/**
|
|
598
|
-
* Convert a run node to the correct insertion anchor.
|
|
599
|
-
*
|
|
600
|
-
* If the run is wrapped in a track-change container, the insertion anchor
|
|
601
|
-
* must be the wrapper (a paragraph child), not the nested run.
|
|
602
|
-
*/
|
|
603
|
-
function getRunInsertionAnchor(run) {
|
|
604
|
-
const parent = parentElement(run);
|
|
605
|
-
if (parent && TRACK_CHANGE_WRAPPERS.has(parent.tagName)) {
|
|
606
|
-
return parent;
|
|
607
|
-
}
|
|
608
|
-
return run;
|
|
609
|
-
}
|
|
610
|
-
/**
|
|
611
|
-
* Wrap a run element with track change markup.
|
|
612
|
-
*
|
|
613
|
-
* This is the shared implementation for wrapAsInserted, wrapAsDeleted,
|
|
614
|
-
* and the inner wrapping logic of move operations.
|
|
615
|
-
*
|
|
616
|
-
* @param options - Wrapping options
|
|
617
|
-
* @returns true if wrapped, false if run was already wrapped or has no parent
|
|
618
|
-
*/
|
|
619
|
-
function wrapRunWithTrackChange(options) {
|
|
620
|
-
const { run, tagName, author, dateStr, state, convertTextToDelText: convertText = false } = options;
|
|
621
|
-
// Skip if already wrapped
|
|
622
|
-
if (state.wrappedRuns.has(run)) {
|
|
623
|
-
return false;
|
|
624
|
-
}
|
|
625
|
-
// Skip if the run has no parent in the tree
|
|
626
|
-
if (!run.parentNode) {
|
|
627
|
-
return false;
|
|
628
|
-
}
|
|
629
|
-
// Convert w:t to w:delText if requested (for deleted content)
|
|
630
|
-
if (convertText) {
|
|
631
|
-
convertToDelText(run);
|
|
632
|
-
}
|
|
633
|
-
const id = allocateRevisionId(state);
|
|
634
|
-
const wrapper = createEl(tagName, {
|
|
635
|
-
'w:id': String(id),
|
|
636
|
-
'w:author': author,
|
|
637
|
-
'w:date': dateStr,
|
|
638
|
-
});
|
|
639
|
-
wrapElement(run, wrapper);
|
|
640
|
-
state.wrappedRuns.add(run);
|
|
641
|
-
return true;
|
|
642
|
-
}
|
|
643
|
-
/**
|
|
644
|
-
* Ensure w:pPr/w:rPr exists and add a paragraph-mark revision marker (w:ins/w:del)
|
|
645
|
-
* in the paragraph properties.
|
|
646
|
-
*
|
|
647
|
-
* This is the critical piece for whole-paragraph insert/delete idempotency:
|
|
648
|
-
* - Reject All should remove inserted paragraphs entirely (no stub breaks)
|
|
649
|
-
* - Accept All should remove deleted paragraphs entirely
|
|
650
|
-
*/
|
|
651
|
-
function addParagraphMarkRevisionMarker(paragraph, markerTag, author, dateStr, state) {
|
|
652
|
-
// Find or create pPr.
|
|
653
|
-
let pPr = findChildByTagName(paragraph, 'w:pPr');
|
|
654
|
-
if (!pPr) {
|
|
655
|
-
pPr = createEl('w:pPr');
|
|
656
|
-
// pPr should be the first child in a paragraph.
|
|
657
|
-
paragraph.insertBefore(pPr, paragraph.firstChild);
|
|
658
|
-
}
|
|
659
|
-
// Find or create rPr within pPr (paragraph mark properties).
|
|
660
|
-
let rPr = findChildByTagName(pPr, 'w:rPr');
|
|
661
|
-
if (!rPr) {
|
|
662
|
-
rPr = createEl('w:rPr');
|
|
663
|
-
// CT_PPr ordering: ... base props ..., w:rPr, w:sectPr?, w:pPrChange?
|
|
664
|
-
// Insert rPr in schema-correct position (before sectPr/pPrChange).
|
|
665
|
-
const sectPr = findChildByTagName(pPr, 'w:sectPr');
|
|
666
|
-
const pPrChange = findChildByTagName(pPr, 'w:pPrChange');
|
|
667
|
-
const insertBefore = sectPr ?? pPrChange ?? null;
|
|
668
|
-
if (insertBefore) {
|
|
669
|
-
pPr.insertBefore(rPr, insertBefore);
|
|
670
|
-
}
|
|
671
|
-
else {
|
|
672
|
-
pPr.appendChild(rPr);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
// Avoid duplicating markers.
|
|
676
|
-
if (findChildByTagName(rPr, markerTag))
|
|
677
|
-
return;
|
|
678
|
-
const id = allocateRevisionId(state);
|
|
679
|
-
const marker = createEl(markerTag, {
|
|
680
|
-
'w:id': String(id),
|
|
681
|
-
'w:author': author,
|
|
682
|
-
'w:date': dateStr,
|
|
683
|
-
});
|
|
684
|
-
// Insert marker at the start of rPr for consistency with Aspose/Word patterns.
|
|
685
|
-
rPr.insertBefore(marker, rPr.firstChild);
|
|
686
|
-
}
|
|
687
|
-
/**
|
|
688
|
-
* Wrap a run element with <w:ins> to mark it as inserted.
|
|
689
|
-
*
|
|
690
|
-
* @param run - The w:r element to wrap
|
|
691
|
-
* @param author - Author name for track changes
|
|
692
|
-
* @param dateStr - Formatted date string
|
|
693
|
-
* @param state - Revision ID state
|
|
694
|
-
* @returns true if wrapped, false if run was already wrapped or has no parent
|
|
695
|
-
*/
|
|
696
|
-
export function wrapAsInserted(run, author, dateStr, state) {
|
|
697
|
-
return wrapRunWithTrackChange({
|
|
698
|
-
run,
|
|
699
|
-
tagName: 'w:ins',
|
|
700
|
-
author,
|
|
701
|
-
dateStr,
|
|
702
|
-
state,
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
/**
|
|
706
|
-
* Wrap a run element with <w:del> to mark it as deleted.
|
|
707
|
-
* Also converts w:t to w:delText within the run.
|
|
708
|
-
*
|
|
709
|
-
* @param run - The w:r element to wrap
|
|
710
|
-
* @param author - Author name for track changes
|
|
711
|
-
* @param dateStr - Formatted date string
|
|
712
|
-
* @param state - Revision ID state
|
|
713
|
-
* @returns true if wrapped, false if run was already wrapped or has no parent
|
|
714
|
-
*/
|
|
715
|
-
export function wrapAsDeleted(run, author, dateStr, state) {
|
|
716
|
-
return wrapRunWithTrackChange({
|
|
717
|
-
run,
|
|
718
|
-
tagName: 'w:del',
|
|
719
|
-
author,
|
|
720
|
-
dateStr,
|
|
721
|
-
state,
|
|
722
|
-
convertTextToDelText: true,
|
|
723
|
-
});
|
|
724
|
-
}
|
|
725
|
-
/**
|
|
726
|
-
* Clone a deleted run from the original document and insert it into the revised document.
|
|
727
|
-
*
|
|
728
|
-
* @param deletedAtom - Atom with the deleted content
|
|
729
|
-
* @param insertAfterRun - The run to insert after (null to insert at beginning of paragraph)
|
|
730
|
-
* @param targetParagraph - The paragraph to insert into
|
|
731
|
-
* @param author - Author name
|
|
732
|
-
* @param dateStr - Formatted date
|
|
733
|
-
* @param state - Revision ID state
|
|
734
|
-
* @returns The inserted del element, or null if insertion failed
|
|
735
|
-
*/
|
|
736
|
-
export function insertDeletedRun(deletedAtom, insertAfterRun, targetParagraph, author, dateStr, state, context) {
|
|
737
|
-
// Get the source run element from the deleted atom
|
|
738
|
-
const sourceRun = deletedAtom.sourceRunElement;
|
|
739
|
-
if (!sourceRun) {
|
|
740
|
-
return null;
|
|
741
|
-
}
|
|
742
|
-
// Create w:del wrapper
|
|
743
|
-
const id = allocateRevisionId(state);
|
|
744
|
-
const del = createEl('w:del', {
|
|
745
|
-
'w:id': String(id),
|
|
746
|
-
'w:author': author,
|
|
747
|
-
'w:date': dateStr,
|
|
748
|
-
});
|
|
749
|
-
// For collapsed field atoms, replay one cloned run per original source run
|
|
750
|
-
// to preserve multi-run field structure. Without this, all field elements
|
|
751
|
-
// get packed into a single run, breaking Word's field parsing.
|
|
752
|
-
const runs = getAtomRuns(deletedAtom);
|
|
753
|
-
if (runs.length > 1) {
|
|
754
|
-
for (const run of runs) {
|
|
755
|
-
const clonedRun = cloneRunWithAtomContent(run, deletedAtom, run);
|
|
756
|
-
convertToDelText(clonedRun);
|
|
757
|
-
del.appendChild(clonedRun);
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
else {
|
|
761
|
-
const clonedRun = cloneRunWithAtomContent(sourceRun, deletedAtom);
|
|
762
|
-
convertToDelText(clonedRun);
|
|
763
|
-
del.appendChild(clonedRun);
|
|
764
|
-
}
|
|
765
|
-
// Insert at correct position
|
|
766
|
-
if (insertAfterRun) {
|
|
767
|
-
insertAfterElement(insertAfterRun, del);
|
|
768
|
-
}
|
|
769
|
-
else {
|
|
770
|
-
// Insert at the beginning of the paragraph (after pPr if present)
|
|
771
|
-
const pPr = findChildByTagName(targetParagraph, 'w:pPr');
|
|
772
|
-
if (pPr) {
|
|
773
|
-
insertAfterElement(pPr, del);
|
|
774
|
-
}
|
|
775
|
-
else {
|
|
776
|
-
targetParagraph.insertBefore(del, targetParagraph.firstChild);
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
const sourceMarkers = cloneUnemittedSourceBookmarkMarkers(sourceRun, targetParagraph, state, context);
|
|
780
|
-
if (sourceMarkers.length > 0)
|
|
781
|
-
insertMarkersBeforeWrapper(del, sourceMarkers);
|
|
782
|
-
return del;
|
|
783
|
-
}
|
|
784
|
-
/**
|
|
785
|
-
* Clone a moved-from run from the original document and insert it into the revised document.
|
|
786
|
-
*
|
|
787
|
-
* MovedSource atoms have their sourceRunElement in the ORIGINAL tree, but we need to
|
|
788
|
-
* insert the content into the REVISED tree. This function clones the run, wraps it with
|
|
789
|
-
* <w:moveFrom> and range markers, and inserts at the correct position.
|
|
790
|
-
*
|
|
791
|
-
* @param atom - Atom with the moved-from content
|
|
792
|
-
* @param moveName - Name for linking source and destination
|
|
793
|
-
* @param insertAfterRun - The run to insert after (null to insert at beginning of paragraph)
|
|
794
|
-
* @param targetParagraph - The paragraph to insert into
|
|
795
|
-
* @param author - Author name
|
|
796
|
-
* @param dateStr - Formatted date
|
|
797
|
-
* @param state - Revision ID state
|
|
798
|
-
* @returns The inserted moveFrom element, or null if insertion failed
|
|
799
|
-
*/
|
|
800
|
-
export function insertMoveFromRun(atom, moveName, insertAfterRun, targetParagraph, author, dateStr, state, context) {
|
|
801
|
-
// Get the source run element from the atom (in original tree)
|
|
802
|
-
const sourceRun = atom.sourceRunElement;
|
|
803
|
-
if (!sourceRun) {
|
|
804
|
-
return null;
|
|
805
|
-
}
|
|
806
|
-
// For collapsed field atoms, replay one cloned run per original source run.
|
|
807
|
-
const runs = getAtomRuns(atom);
|
|
808
|
-
const clonedRuns = [];
|
|
809
|
-
if (runs.length > 1) {
|
|
810
|
-
for (const run of runs) {
|
|
811
|
-
const clonedRun = cloneRunWithAtomContent(run, atom, run);
|
|
812
|
-
convertToDelText(clonedRun);
|
|
813
|
-
clonedRuns.push(clonedRun);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
else {
|
|
817
|
-
const clonedRun = cloneRunWithAtomContent(sourceRun, atom);
|
|
818
|
-
convertToDelText(clonedRun);
|
|
819
|
-
clonedRuns.push(clonedRun);
|
|
820
|
-
}
|
|
821
|
-
// Get or allocate move range IDs
|
|
822
|
-
const ids = getMoveRangeIds(state, moveName);
|
|
823
|
-
const moveId = allocateRevisionId(state);
|
|
824
|
-
// Create range start marker
|
|
825
|
-
const rangeStart = createEl('w:moveFromRangeStart', {
|
|
826
|
-
'w:id': String(ids.sourceRangeId),
|
|
827
|
-
'w:name': moveName,
|
|
828
|
-
'w:author': author,
|
|
829
|
-
'w:date': dateStr,
|
|
830
|
-
});
|
|
831
|
-
// Create moveFrom wrapper
|
|
832
|
-
const moveFrom = createEl('w:moveFrom', {
|
|
833
|
-
'w:id': String(moveId),
|
|
834
|
-
'w:author': author,
|
|
835
|
-
'w:date': dateStr,
|
|
836
|
-
});
|
|
837
|
-
// Create range end marker
|
|
838
|
-
const rangeEnd = createEl('w:moveFromRangeEnd', {
|
|
839
|
-
'w:id': String(ids.sourceRangeId),
|
|
840
|
-
});
|
|
841
|
-
// Add cloned run(s) as children of moveFrom
|
|
842
|
-
for (const clonedRun of clonedRuns) {
|
|
843
|
-
moveFrom.appendChild(clonedRun);
|
|
844
|
-
}
|
|
845
|
-
// Insert at correct position: rangeStart -> moveFrom(run) -> rangeEnd
|
|
846
|
-
if (insertAfterRun) {
|
|
847
|
-
insertAfterElement(insertAfterRun, rangeStart);
|
|
848
|
-
insertAfterElement(rangeStart, moveFrom);
|
|
849
|
-
insertAfterElement(moveFrom, rangeEnd);
|
|
850
|
-
}
|
|
851
|
-
else {
|
|
852
|
-
// Insert at the beginning of the paragraph (after pPr if present)
|
|
853
|
-
const pPr = findChildByTagName(targetParagraph, 'w:pPr');
|
|
854
|
-
if (pPr) {
|
|
855
|
-
insertAfterElement(pPr, rangeStart);
|
|
856
|
-
insertAfterElement(rangeStart, moveFrom);
|
|
857
|
-
insertAfterElement(moveFrom, rangeEnd);
|
|
858
|
-
}
|
|
859
|
-
else {
|
|
860
|
-
targetParagraph.insertBefore(rangeEnd, targetParagraph.firstChild);
|
|
861
|
-
targetParagraph.insertBefore(moveFrom, rangeEnd);
|
|
862
|
-
targetParagraph.insertBefore(rangeStart, moveFrom);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
const sourceMarkers = cloneUnemittedSourceBookmarkMarkers(sourceRun, targetParagraph, state, context);
|
|
866
|
-
if (sourceMarkers.length > 0)
|
|
867
|
-
insertMarkersBeforeWrapper(moveFrom, sourceMarkers);
|
|
868
|
-
return moveFrom;
|
|
869
|
-
}
|
|
870
|
-
/**
|
|
871
|
-
* Clone a deleted paragraph from the original document and insert it.
|
|
872
|
-
*
|
|
873
|
-
* @param deletedAtom - Atom representing the deleted paragraph
|
|
874
|
-
* @param insertAfterParagraph - Paragraph to insert after (null to insert at body start)
|
|
875
|
-
* @param targetBody - The body element to insert into
|
|
876
|
-
* @param author - Author name
|
|
877
|
-
* @param dateStr - Formatted date
|
|
878
|
-
* @param state - Revision ID state
|
|
879
|
-
* @returns The inserted paragraph, or null if insertion failed
|
|
880
|
-
*/
|
|
881
|
-
export function insertDeletedParagraph(deletedAtom, insertAfterParagraph, targetContainer, author, dateStr, state) {
|
|
882
|
-
// Get the source paragraph from the deleted atom
|
|
883
|
-
const sourceParagraph = deletedAtom.sourceParagraphElement;
|
|
884
|
-
if (!sourceParagraph) {
|
|
885
|
-
return null;
|
|
886
|
-
}
|
|
887
|
-
// Clone the paragraph
|
|
888
|
-
const clonedParagraph = sourceParagraph.cloneNode(true);
|
|
889
|
-
// Wrap runs with w:del (wrapAsDeleted handles w:t -> w:delText conversion internally)
|
|
890
|
-
const runs = Array.from(clonedParagraph.getElementsByTagName('w:r'));
|
|
891
|
-
for (const run of runs) {
|
|
892
|
-
wrapAsDeleted(run, author, dateStr, state);
|
|
893
|
-
}
|
|
894
|
-
// Insert at correct position, preserving w:tcPr as first child when target is a table cell
|
|
895
|
-
if (insertAfterParagraph) {
|
|
896
|
-
insertAfterElement(insertAfterParagraph, clonedParagraph);
|
|
897
|
-
}
|
|
898
|
-
else {
|
|
899
|
-
const tcPr = targetContainer.tagName === 'w:tc'
|
|
900
|
-
? findChildByTagName(targetContainer, 'w:tcPr')
|
|
901
|
-
: null;
|
|
902
|
-
if (tcPr) {
|
|
903
|
-
insertAfterElement(tcPr, clonedParagraph);
|
|
904
|
-
}
|
|
905
|
-
else {
|
|
906
|
-
targetContainer.insertBefore(clonedParagraph, targetContainer.firstChild);
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
return clonedParagraph;
|
|
910
|
-
}
|
|
911
|
-
const MOVE_CONFIG = {
|
|
912
|
-
from: {
|
|
913
|
-
wrapperTag: 'w:moveFrom',
|
|
914
|
-
rangeStartTag: 'w:moveFromRangeStart',
|
|
915
|
-
rangeEndTag: 'w:moveFromRangeEnd',
|
|
916
|
-
rangeIdKey: 'sourceRangeId',
|
|
917
|
-
convertTextToDelText: true, // Moved-from content appears as deleted
|
|
918
|
-
},
|
|
919
|
-
to: {
|
|
920
|
-
wrapperTag: 'w:moveTo',
|
|
921
|
-
rangeStartTag: 'w:moveToRangeStart',
|
|
922
|
-
rangeEndTag: 'w:moveToRangeEnd',
|
|
923
|
-
rangeIdKey: 'destRangeId',
|
|
924
|
-
convertTextToDelText: false, // Moved-to content keeps w:t
|
|
925
|
-
},
|
|
926
|
-
};
|
|
927
|
-
/**
|
|
928
|
-
* Wrap a run element with move tracking (shared implementation for moveFrom/moveTo).
|
|
929
|
-
*
|
|
930
|
-
* @param run - The w:r element to wrap
|
|
931
|
-
* @param moveName - Name for linking source and destination
|
|
932
|
-
* @param direction - 'from' for moveFrom, 'to' for moveTo
|
|
933
|
-
* @param author - Author name
|
|
934
|
-
* @param dateStr - Formatted date
|
|
935
|
-
* @param state - Revision ID state
|
|
936
|
-
* @returns true if wrapped
|
|
937
|
-
*/
|
|
938
|
-
function wrapAsMove(run, moveName, direction, author, dateStr, state) {
|
|
939
|
-
if (state.wrappedRuns.has(run)) {
|
|
940
|
-
return false;
|
|
941
|
-
}
|
|
942
|
-
const parent = parentElement(run);
|
|
943
|
-
if (!parent) {
|
|
944
|
-
return false;
|
|
945
|
-
}
|
|
946
|
-
const config = MOVE_CONFIG[direction];
|
|
947
|
-
const ids = getMoveRangeIds(state, moveName);
|
|
948
|
-
const moveId = allocateRevisionId(state);
|
|
949
|
-
const rangeId = ids[config.rangeIdKey];
|
|
950
|
-
// Convert w:t to w:delText if needed (for moveFrom content)
|
|
951
|
-
if (config.convertTextToDelText) {
|
|
952
|
-
convertToDelText(run);
|
|
953
|
-
}
|
|
954
|
-
// Create range start marker
|
|
955
|
-
const rangeStart = createEl(config.rangeStartTag, {
|
|
956
|
-
'w:id': String(rangeId),
|
|
957
|
-
'w:name': moveName,
|
|
958
|
-
'w:author': author,
|
|
959
|
-
'w:date': dateStr,
|
|
960
|
-
});
|
|
961
|
-
// Create move wrapper
|
|
962
|
-
const moveWrapper = createEl(config.wrapperTag, {
|
|
963
|
-
'w:id': String(moveId),
|
|
964
|
-
'w:author': author,
|
|
965
|
-
'w:date': dateStr,
|
|
966
|
-
});
|
|
967
|
-
// Create range end marker
|
|
968
|
-
const rangeEnd = createEl(config.rangeEndTag, {
|
|
969
|
-
'w:id': String(rangeId),
|
|
970
|
-
});
|
|
971
|
-
// Insert: rangeStart -> moveWrapper(run) -> rangeEnd
|
|
972
|
-
run.parentNode.insertBefore(rangeStart, run);
|
|
973
|
-
wrapElement(run, moveWrapper);
|
|
974
|
-
insertAfterElement(moveWrapper, rangeEnd);
|
|
975
|
-
state.wrappedRuns.add(run);
|
|
976
|
-
return true;
|
|
977
|
-
}
|
|
978
|
-
/**
|
|
979
|
-
* Wrap a run element with <w:moveFrom> for moved-from content.
|
|
980
|
-
*
|
|
981
|
-
* @param run - The w:r element to wrap
|
|
982
|
-
* @param moveName - Name for linking source and destination
|
|
983
|
-
* @param author - Author name
|
|
984
|
-
* @param dateStr - Formatted date
|
|
985
|
-
* @param state - Revision ID state
|
|
986
|
-
* @returns true if wrapped
|
|
987
|
-
*/
|
|
988
|
-
export function wrapAsMoveFrom(run, moveName, author, dateStr, state) {
|
|
989
|
-
return wrapAsMove(run, moveName, 'from', author, dateStr, state);
|
|
990
|
-
}
|
|
991
|
-
/**
|
|
992
|
-
* Wrap a run element with <w:moveTo> for moved-to content.
|
|
993
|
-
*
|
|
994
|
-
* @param run - The w:r element to wrap
|
|
995
|
-
* @param moveName - Name for linking source and destination
|
|
996
|
-
* @param author - Author name
|
|
997
|
-
* @param dateStr - Formatted date
|
|
998
|
-
* @param state - Revision ID state
|
|
999
|
-
* @returns true if wrapped
|
|
1000
|
-
*/
|
|
1001
|
-
export function wrapAsMoveTo(run, moveName, author, dateStr, state) {
|
|
1002
|
-
return wrapAsMove(run, moveName, 'to', author, dateStr, state);
|
|
1003
|
-
}
|
|
1004
|
-
/**
|
|
1005
|
-
* Add format change tracking to a run's properties.
|
|
1006
|
-
*
|
|
1007
|
-
* @param run - The w:r element with changed formatting
|
|
1008
|
-
* @param oldRunProperties - The original run properties (w:rPr)
|
|
1009
|
-
* @param author - Author name
|
|
1010
|
-
* @param dateStr - Formatted date
|
|
1011
|
-
* @param state - Revision ID state
|
|
1012
|
-
*/
|
|
1013
|
-
export function addFormatChange(run, oldRunProperties, author, dateStr, state) {
|
|
1014
|
-
// Find or create w:rPr
|
|
1015
|
-
let rPr = findChildByTagName(run, 'w:rPr');
|
|
1016
|
-
if (!rPr) {
|
|
1017
|
-
rPr = createEl('w:rPr');
|
|
1018
|
-
// Insert rPr at the beginning of run's children
|
|
1019
|
-
run.insertBefore(rPr, run.firstChild);
|
|
1020
|
-
}
|
|
1021
|
-
// Create rPrChange
|
|
1022
|
-
const id = allocateRevisionId(state);
|
|
1023
|
-
const rPrChange = createEl('w:rPrChange', {
|
|
1024
|
-
'w:id': String(id),
|
|
1025
|
-
'w:author': author,
|
|
1026
|
-
'w:date': dateStr,
|
|
1027
|
-
});
|
|
1028
|
-
// Clone old properties into a w:rPr wrapper inside rPrChange (OOXML spec requires
|
|
1029
|
-
// rPrChange to contain a single w:rPr child holding the previous formatting).
|
|
1030
|
-
if (oldRunProperties) {
|
|
1031
|
-
const oldRPr = createEl('w:rPr');
|
|
1032
|
-
for (const child of childElements(oldRunProperties)) {
|
|
1033
|
-
const cloned = child.cloneNode(true);
|
|
1034
|
-
oldRPr.appendChild(cloned);
|
|
1035
|
-
}
|
|
1036
|
-
rPrChange.appendChild(oldRPr);
|
|
1037
|
-
}
|
|
1038
|
-
// Add rPrChange to rPr
|
|
1039
|
-
rPr.appendChild(rPrChange);
|
|
1040
|
-
}
|
|
1041
|
-
/**
|
|
1042
|
-
* Add a paragraph property change element (w:pPrChange) to record the "before"
|
|
1043
|
-
* state of paragraph properties. This is needed for Google Docs to display
|
|
1044
|
-
* inserted paragraphs as tracked changes.
|
|
1045
|
-
*
|
|
1046
|
-
* The child `<w:pPr>` inside `w:pPrChange` must conform to CT_PPrBase — it
|
|
1047
|
-
* MUST NOT contain w:rPr, w:sectPr, or w:pPrChange.
|
|
1048
|
-
*
|
|
1049
|
-
* @param paragraph - The w:p element
|
|
1050
|
-
* @param author - Author name
|
|
1051
|
-
* @param dateStr - Formatted date
|
|
1052
|
-
* @param state - Revision ID state
|
|
1053
|
-
*/
|
|
1054
|
-
export function addParagraphPropertyChange(paragraph, author, dateStr, state) {
|
|
1055
|
-
let pPr = findChildByTagName(paragraph, 'w:pPr');
|
|
1056
|
-
if (!pPr) {
|
|
1057
|
-
pPr = createEl('w:pPr');
|
|
1058
|
-
paragraph.insertBefore(pPr, paragraph.firstChild);
|
|
1059
|
-
}
|
|
1060
|
-
// Idempotent — don't add a second pPrChange.
|
|
1061
|
-
if (findChildByTagName(pPr, 'w:pPrChange'))
|
|
1062
|
-
return;
|
|
1063
|
-
const id = allocateRevisionId(state);
|
|
1064
|
-
const pPrChange = createEl('w:pPrChange', {
|
|
1065
|
-
'w:id': String(id),
|
|
1066
|
-
'w:author': author,
|
|
1067
|
-
'w:date': dateStr,
|
|
1068
|
-
});
|
|
1069
|
-
// Clone current pPr content as "before" snapshot.
|
|
1070
|
-
// pPrChange child pPr must be CT_PPrBase — exclude rPr, rPrChange, sectPr, pPrChange.
|
|
1071
|
-
const EXCLUDED = new Set(['w:rPr', 'w:rPrChange', 'w:pPrChange', 'w:sectPr']);
|
|
1072
|
-
const oldPPr = createEl('w:pPr');
|
|
1073
|
-
for (const child of childElements(pPr)) {
|
|
1074
|
-
if (!EXCLUDED.has(child.tagName))
|
|
1075
|
-
oldPPr.appendChild(child.cloneNode(true));
|
|
1076
|
-
}
|
|
1077
|
-
pPrChange.appendChild(oldPPr);
|
|
1078
|
-
pPr.appendChild(pPrChange); // pPrChange goes last in pPr per schema
|
|
1079
|
-
}
|
|
1080
|
-
/**
|
|
1081
|
-
* Tag names that represent visible content inside a w:r element.
|
|
1082
|
-
* A run containing at least one of these is considered substantive (non-empty).
|
|
1083
|
-
*/
|
|
1084
|
-
const RUN_VISIBLE_CONTENT_TAGS = new Set([
|
|
1085
|
-
'w:t', 'w:tab', 'w:br', 'w:cr', 'w:drawing', 'w:object', 'w:pict',
|
|
1086
|
-
'w:sym', 'w:fldChar', 'w:instrText',
|
|
1087
|
-
]);
|
|
1088
|
-
/**
|
|
1089
|
-
* Returns true if a w:r element contains at least one visible content child.
|
|
1090
|
-
* Empty runs (containing only w:rPr or nothing) return false.
|
|
1091
|
-
*/
|
|
1092
|
-
export function runHasVisibleContent(run) {
|
|
1093
|
-
for (let i = 0; i < run.childNodes.length; i++) {
|
|
1094
|
-
const child = run.childNodes[i];
|
|
1095
|
-
if (child.nodeType === 1 && RUN_VISIBLE_CONTENT_TAGS.has(child.tagName)) {
|
|
1096
|
-
return true;
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
return false;
|
|
1100
|
-
}
|
|
1101
|
-
/**
|
|
1102
|
-
* Wrap an inserted empty paragraph with <w:ins>.
|
|
1103
|
-
*
|
|
1104
|
-
* For empty paragraphs (no content, only pPr), we wrap the entire paragraph.
|
|
1105
|
-
*
|
|
1106
|
-
* @param paragraph - The w:p element
|
|
1107
|
-
* @param author - Author name
|
|
1108
|
-
* @param dateStr - Formatted date
|
|
1109
|
-
* @param state - Revision ID state
|
|
1110
|
-
*/
|
|
1111
|
-
export function wrapParagraphAsInserted(paragraph, author, dateStr, state) {
|
|
1112
|
-
// For paragraphs with substantive run content: skip the paragraph-mark marker.
|
|
1113
|
-
// Google Docs ignores (or actively hides) w:ins-wrapped runs when they
|
|
1114
|
-
// coexist with PPR-INS markers. Since individual runs are already wrapped
|
|
1115
|
-
// with <w:ins>, the paragraph-level marker is redundant for non-empty
|
|
1116
|
-
// paragraphs and omitting it maximises cross-application compatibility.
|
|
1117
|
-
//
|
|
1118
|
-
// For empty paragraphs (no runs, or only empty w:r shells): we MUST add
|
|
1119
|
-
// the PPR-INS marker so that Reject All removes the paragraph. Without it,
|
|
1120
|
-
// the empty paragraph shell survives reject, causing round-trip safety failures.
|
|
1121
|
-
//
|
|
1122
|
-
// Important: empty <w:r> elements (no w:t, w:tab, w:br, etc.) should NOT
|
|
1123
|
-
// count as substantive content. They are empty shells that don't produce
|
|
1124
|
-
// visible output and should not prevent PPR-INS from being added.
|
|
1125
|
-
let hasSubstantiveContent = false;
|
|
1126
|
-
for (const child of childElements(paragraph)) {
|
|
1127
|
-
if (child.tagName === 'w:ins') {
|
|
1128
|
-
// Check if the w:ins wrapper contains runs with visible content
|
|
1129
|
-
for (let i = 0; i < child.childNodes.length; i++) {
|
|
1130
|
-
const insChild = child.childNodes[i];
|
|
1131
|
-
if (insChild.nodeType === 1 && insChild.tagName === 'w:r' &&
|
|
1132
|
-
runHasVisibleContent(insChild)) {
|
|
1133
|
-
hasSubstantiveContent = true;
|
|
1134
|
-
break;
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
if (hasSubstantiveContent)
|
|
1138
|
-
break;
|
|
1139
|
-
}
|
|
1140
|
-
else if (child.tagName === 'w:r' && runHasVisibleContent(child)) {
|
|
1141
|
-
hasSubstantiveContent = true;
|
|
1142
|
-
break;
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
if (hasSubstantiveContent) {
|
|
1146
|
-
return true;
|
|
1147
|
-
}
|
|
1148
|
-
addParagraphMarkRevisionMarker(paragraph, 'w:ins', author, dateStr, state);
|
|
1149
|
-
return true;
|
|
1150
|
-
}
|
|
1151
|
-
/**
|
|
1152
|
-
* Wrap a deleted empty paragraph with <w:del>.
|
|
1153
|
-
*
|
|
1154
|
-
* @param paragraph - The w:p element
|
|
1155
|
-
* @param author - Author name
|
|
1156
|
-
* @param dateStr - Formatted date
|
|
1157
|
-
* @param state - Revision ID state
|
|
1158
|
-
*/
|
|
1159
|
-
export function wrapParagraphAsDeleted(paragraph, author, dateStr, state) {
|
|
1160
|
-
// See wrapParagraphAsInserted: represent paragraph deletion via a paragraph-mark
|
|
1161
|
-
// revision marker in w:pPr/w:rPr so Accept/Reject All behaves correctly.
|
|
1162
|
-
addParagraphMarkRevisionMarker(paragraph, 'w:del', author, dateStr, state);
|
|
1163
|
-
return true;
|
|
1164
|
-
}
|
|
1165
|
-
// Field-character tag names that should not be split.
|
|
1166
|
-
const FIELD_CHAR_TAG_NAMES = new Set([
|
|
1167
|
-
'w:fldChar', 'w:instrText', 'w:delInstrText',
|
|
1168
|
-
]);
|
|
1169
|
-
/**
|
|
1170
|
-
* Visible text length for an atom's contentElement.
|
|
1171
|
-
*
|
|
1172
|
-
* Atom contentElements created by the word-split atomizer use prefixed tagNames
|
|
1173
|
-
* (e.g. `"w:t"`) without namespace URIs, so we match on `tagName` rather than
|
|
1174
|
-
* `localName`/`namespaceURI` (which is what the DOM-level `visibleLengthForEl` does).
|
|
1175
|
-
*/
|
|
1176
|
-
function atomContentVisibleLength(el) {
|
|
1177
|
-
const tag = el.tagName;
|
|
1178
|
-
if (tag === 'w:t')
|
|
1179
|
-
return (el.textContent ?? '').length;
|
|
1180
|
-
if (tag === 'w:tab' || tag === 'w:br')
|
|
1181
|
-
return 1;
|
|
1182
|
-
// w:cr is treated as zero-length (consistent with visibleLengthForEl which also returns 0).
|
|
1183
|
-
return 0;
|
|
1184
|
-
}
|
|
1185
|
-
/**
|
|
1186
|
-
* Pre-split revised-tree runs that contain atoms with mixed correlation statuses.
|
|
1187
|
-
*
|
|
1188
|
-
* Without this, `handleInserted` wraps the entire run with `<w:ins>`, destroying
|
|
1189
|
-
* Equal content in the same run. After splitting, each fragment is a separate
|
|
1190
|
-
* `<w:r>` and existing per-status handlers work without modification.
|
|
1191
|
-
*
|
|
1192
|
-
* Safety: wrapped in try/catch per run group. If any DOM operation fails, the
|
|
1193
|
-
* run is skipped and the existing fallback-to-rebuild architecture handles it.
|
|
1194
|
-
*/
|
|
1195
|
-
export function preSplitMixedStatusRuns(mergedAtoms) {
|
|
1196
|
-
// Group atoms by their sourceRunElement (revised-tree runs only).
|
|
1197
|
-
const runGroups = new Map();
|
|
1198
|
-
for (const atom of mergedAtoms) {
|
|
1199
|
-
if (!atom.sourceRunElement)
|
|
1200
|
-
continue;
|
|
1201
|
-
// Skip original-tree atoms — Deleted/MovedSource runs are cloned, not wrapped.
|
|
1202
|
-
if (atom.correlationStatus === CorrelationStatus.Deleted ||
|
|
1203
|
-
atom.correlationStatus === CorrelationStatus.MovedSource)
|
|
1204
|
-
continue;
|
|
1205
|
-
// Skip collapsed field atoms (multi-run field sequences).
|
|
1206
|
-
if (atom.collapsedFieldAtoms && atom.collapsedFieldAtoms.length > 0)
|
|
1207
|
-
continue;
|
|
1208
|
-
// Skip field character elements — semantically fragile.
|
|
1209
|
-
if (FIELD_CHAR_TAG_NAMES.has(atom.contentElement.tagName))
|
|
1210
|
-
continue;
|
|
1211
|
-
const group = runGroups.get(atom.sourceRunElement);
|
|
1212
|
-
if (group) {
|
|
1213
|
-
group.push(atom);
|
|
1214
|
-
}
|
|
1215
|
-
else {
|
|
1216
|
-
runGroups.set(atom.sourceRunElement, [atom]);
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
for (const [run, atoms] of runGroups) {
|
|
1220
|
-
// Early check: skip single-status runs before any DOM work.
|
|
1221
|
-
const statuses = new Set(atoms.map((a) => a.correlationStatus));
|
|
1222
|
-
if (statuses.size <= 1)
|
|
1223
|
-
continue;
|
|
1224
|
-
// Guard: skip runs already detached from the tree.
|
|
1225
|
-
if (!run.parentNode)
|
|
1226
|
-
continue;
|
|
1227
|
-
try {
|
|
1228
|
-
// Compute the run's actual visible length via DOM traversal.
|
|
1229
|
-
const contentEls = getDirectContentElements(run);
|
|
1230
|
-
let runVisibleLength = 0;
|
|
1231
|
-
for (const cel of contentEls) {
|
|
1232
|
-
runVisibleLength += visibleLengthForEl(cel);
|
|
1233
|
-
}
|
|
1234
|
-
// Cross-run safety: if sum of atom lengths exceeds run visible length,
|
|
1235
|
-
// this group contains a cross-run merged atom (passes 3/4). Skip it.
|
|
1236
|
-
let sumAtomLengths = 0;
|
|
1237
|
-
for (const atom of atoms) {
|
|
1238
|
-
sumAtomLengths += atomContentVisibleLength(atom.contentElement);
|
|
1239
|
-
}
|
|
1240
|
-
if (sumAtomLengths > runVisibleLength)
|
|
1241
|
-
continue;
|
|
1242
|
-
const spans = [];
|
|
1243
|
-
let offset = 0;
|
|
1244
|
-
for (const atom of atoms) {
|
|
1245
|
-
const len = atomContentVisibleLength(atom.contentElement);
|
|
1246
|
-
const lastSpan = spans[spans.length - 1];
|
|
1247
|
-
if (lastSpan && lastSpan.status === atom.correlationStatus) {
|
|
1248
|
-
lastSpan.length += len;
|
|
1249
|
-
lastSpan.atoms.push(atom);
|
|
1250
|
-
}
|
|
1251
|
-
else {
|
|
1252
|
-
spans.push({
|
|
1253
|
-
status: atom.correlationStatus,
|
|
1254
|
-
startOffset: offset,
|
|
1255
|
-
length: len,
|
|
1256
|
-
atoms: [atom],
|
|
1257
|
-
});
|
|
1258
|
-
}
|
|
1259
|
-
offset += len;
|
|
1260
|
-
}
|
|
1261
|
-
// If only one span after grouping, no split needed.
|
|
1262
|
-
if (spans.length <= 1)
|
|
1263
|
-
continue;
|
|
1264
|
-
// Collect split points: startOffset of each span after the first.
|
|
1265
|
-
const splitPoints = [];
|
|
1266
|
-
for (let i = 1; i < spans.length; i++) {
|
|
1267
|
-
const pt = spans[i].startOffset;
|
|
1268
|
-
// Filter out degenerate split points at boundaries.
|
|
1269
|
-
if (pt > 0 && pt < runVisibleLength) {
|
|
1270
|
-
splitPoints.push(pt);
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
if (splitPoints.length === 0)
|
|
1274
|
-
continue;
|
|
1275
|
-
// Split DOM run right-to-left to keep earlier offsets valid.
|
|
1276
|
-
const rightFragments = [];
|
|
1277
|
-
for (let i = splitPoints.length - 1; i >= 0; i--) {
|
|
1278
|
-
const { right } = splitRunAtVisibleOffset(run, splitPoints[i]);
|
|
1279
|
-
rightFragments.push(right);
|
|
1280
|
-
}
|
|
1281
|
-
// Map fragments: [originalRun (leftmost), ...reverse(rightFragments)]
|
|
1282
|
-
// After R-to-L splits, rightFragments are in reverse document order.
|
|
1283
|
-
const fragments = [run, ...rightFragments.reverse()];
|
|
1284
|
-
// Update atom sourceRunElement pointers to the correct fragment.
|
|
1285
|
-
// Each span maps to one fragment in order.
|
|
1286
|
-
for (let i = 0; i < spans.length; i++) {
|
|
1287
|
-
const fragment = fragments[i];
|
|
1288
|
-
if (!fragment)
|
|
1289
|
-
continue;
|
|
1290
|
-
for (const atom of spans[i].atoms) {
|
|
1291
|
-
atom.sourceRunElement = fragment;
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
catch (_err) {
|
|
1296
|
-
// DOM operation failed — skip this run. The existing fallback-to-rebuild
|
|
1297
|
-
// architecture will handle it if the overall safety check fails.
|
|
1298
|
-
warn('preSplitMixedStatusRuns', `Skipping run split due to error: ${_err}`);
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
/**
|
|
1303
|
-
* Pre-split revised-tree runs where word-split Equal atoms from the same run
|
|
1304
|
-
* are interleaved with Deleted/MovedSource atoms in the merged atom list.
|
|
1305
|
-
*
|
|
1306
|
-
* `preSplitMixedStatusRuns` handles the case where a single run contains atoms
|
|
1307
|
-
* with DIFFERENT statuses (e.g., some Equal and some Inserted). But it cannot
|
|
1308
|
-
* handle the case where ALL atoms from a run are Equal yet Deleted atoms (from
|
|
1309
|
-
* the original tree) are interspersed between them in the merged list.
|
|
1310
|
-
*
|
|
1311
|
-
* Without this split, `handleEqual` sees all Equal atoms pointing to the same
|
|
1312
|
-
* run and skips position advancement (the `lastRevisedRunAnchor` optimization).
|
|
1313
|
-
* Subsequent `handleDeleted` calls then insert deleted content at the wrong
|
|
1314
|
-
* position because the cursor never advanced past the shared run.
|
|
1315
|
-
*
|
|
1316
|
-
* This function detects interleaved sequences and splits the DOM run so each
|
|
1317
|
-
* contiguous group of Equal atoms gets its own run fragment. The handlers then
|
|
1318
|
-
* advance the cursor correctly across fragments.
|
|
1319
|
-
*/
|
|
1320
|
-
export function preSplitInterleavedWordRuns(mergedAtoms) {
|
|
1321
|
-
const runToGroups = new Map();
|
|
1322
|
-
// Track cumulative offset per run (sums visible lengths of atoms seen so far)
|
|
1323
|
-
const runToOffset = new Map();
|
|
1324
|
-
let lastRevisedRun = null;
|
|
1325
|
-
for (const atom of mergedAtoms) {
|
|
1326
|
-
// Skip atoms from the original tree (Deleted/MovedSource have runs in the
|
|
1327
|
-
// original tree, not the revised tree).
|
|
1328
|
-
if (atom.correlationStatus === CorrelationStatus.Deleted ||
|
|
1329
|
-
atom.correlationStatus === CorrelationStatus.MovedSource) {
|
|
1330
|
-
// A Deleted/MovedSource atom between Equal atoms from the same run
|
|
1331
|
-
// creates an interleaving gap. Mark this by clearing lastRevisedRun
|
|
1332
|
-
// so the next Equal atom from the same run starts a new group.
|
|
1333
|
-
lastRevisedRun = null;
|
|
1334
|
-
continue;
|
|
1335
|
-
}
|
|
1336
|
-
const run = atom.sourceRunElement;
|
|
1337
|
-
if (!run)
|
|
1338
|
-
continue;
|
|
1339
|
-
// Skip collapsed field atoms — multi-run field sequences.
|
|
1340
|
-
if (atom.collapsedFieldAtoms && atom.collapsedFieldAtoms.length > 0)
|
|
1341
|
-
continue;
|
|
1342
|
-
// Skip field character elements — semantically fragile.
|
|
1343
|
-
if (FIELD_CHAR_TAG_NAMES.has(atom.contentElement.tagName))
|
|
1344
|
-
continue;
|
|
1345
|
-
const atomLen = atomContentVisibleLength(atom.contentElement);
|
|
1346
|
-
const currentOffset = runToOffset.get(run) ?? 0;
|
|
1347
|
-
runToOffset.set(run, currentOffset + atomLen);
|
|
1348
|
-
const groups = runToGroups.get(run);
|
|
1349
|
-
if (!groups) {
|
|
1350
|
-
// First time seeing this run — create initial group.
|
|
1351
|
-
runToGroups.set(run, [{
|
|
1352
|
-
startOffset: currentOffset,
|
|
1353
|
-
length: atomLen,
|
|
1354
|
-
atoms: [atom],
|
|
1355
|
-
}]);
|
|
1356
|
-
lastRevisedRun = run;
|
|
1357
|
-
continue;
|
|
1358
|
-
}
|
|
1359
|
-
if (lastRevisedRun === run) {
|
|
1360
|
-
// Contiguous with the previous atom from the same run — extend group.
|
|
1361
|
-
const lastGroup = groups[groups.length - 1];
|
|
1362
|
-
lastGroup.length += atomLen;
|
|
1363
|
-
lastGroup.atoms.push(atom);
|
|
1364
|
-
}
|
|
1365
|
-
else {
|
|
1366
|
-
// Gap detected (a Deleted/MovedSource atom intervened). Start new group.
|
|
1367
|
-
groups.push({
|
|
1368
|
-
startOffset: currentOffset,
|
|
1369
|
-
length: atomLen,
|
|
1370
|
-
atoms: [atom],
|
|
1371
|
-
});
|
|
1372
|
-
}
|
|
1373
|
-
lastRevisedRun = run;
|
|
1374
|
-
}
|
|
1375
|
-
// Now split runs that have more than one group.
|
|
1376
|
-
for (const [run, groups] of runToGroups) {
|
|
1377
|
-
if (groups.length <= 1)
|
|
1378
|
-
continue;
|
|
1379
|
-
// Guard: skip runs already detached from the tree.
|
|
1380
|
-
if (!run.parentNode)
|
|
1381
|
-
continue;
|
|
1382
|
-
try {
|
|
1383
|
-
// Compute actual visible length of the DOM run.
|
|
1384
|
-
const contentEls = getDirectContentElements(run);
|
|
1385
|
-
let runVisibleLength = 0;
|
|
1386
|
-
for (const cel of contentEls) {
|
|
1387
|
-
runVisibleLength += visibleLengthForEl(cel);
|
|
1388
|
-
}
|
|
1389
|
-
// Safety: if the sum of atom lengths exceeds run visible length,
|
|
1390
|
-
// something is off (cross-run atoms, etc.). Skip.
|
|
1391
|
-
let sumAtomLengths = 0;
|
|
1392
|
-
for (const group of groups) {
|
|
1393
|
-
sumAtomLengths += group.length;
|
|
1394
|
-
}
|
|
1395
|
-
if (sumAtomLengths > runVisibleLength)
|
|
1396
|
-
continue;
|
|
1397
|
-
// Collect split points: the startOffset of each group after the first.
|
|
1398
|
-
const splitPoints = [];
|
|
1399
|
-
for (let i = 1; i < groups.length; i++) {
|
|
1400
|
-
const pt = groups[i].startOffset;
|
|
1401
|
-
if (pt > 0 && pt < runVisibleLength) {
|
|
1402
|
-
splitPoints.push(pt);
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
if (splitPoints.length === 0)
|
|
1406
|
-
continue;
|
|
1407
|
-
// Split DOM run right-to-left to keep earlier offsets valid.
|
|
1408
|
-
const rightFragments = [];
|
|
1409
|
-
for (let i = splitPoints.length - 1; i >= 0; i--) {
|
|
1410
|
-
const { right } = splitRunAtVisibleOffset(run, splitPoints[i]);
|
|
1411
|
-
rightFragments.push(right);
|
|
1412
|
-
}
|
|
1413
|
-
// Map fragments: [originalRun (leftmost), ...reverse(rightFragments)]
|
|
1414
|
-
const fragments = [run, ...rightFragments.reverse()];
|
|
1415
|
-
// Update atom sourceRunElement pointers to the correct fragment.
|
|
1416
|
-
for (let i = 0; i < groups.length; i++) {
|
|
1417
|
-
const fragment = fragments[i];
|
|
1418
|
-
if (!fragment)
|
|
1419
|
-
continue;
|
|
1420
|
-
for (const atom of groups[i].atoms) {
|
|
1421
|
-
atom.sourceRunElement = fragment;
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
catch (_err) {
|
|
1426
|
-
warn('preSplitInterleavedWordRuns', `Skipping run split due to error: ${_err}`);
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
/**
|
|
1431
|
-
* Modify the revised document's AST in-place based on comparison results.
|
|
1432
|
-
*
|
|
1433
|
-
* @param revisedRoot - Root element of the revised document
|
|
1434
|
-
* @param mergedAtoms - Atoms with correlation status from comparison
|
|
1435
|
-
* @param options - Modification options
|
|
1436
|
-
* @returns The modified XML string
|
|
1437
|
-
*/
|
|
19
|
+
import { attachSourceElementPointers, createEl, createRevisionIdState, allocateRevisionId, formatDate, paragraphHasParaInsMarker, } from './inPlaceModifier-shared.js';
|
|
20
|
+
import { ContainerResolutionError, findTargetContainerForAtom, isEntireParagraphAtomsWithStatus, } from './inPlaceModifier-containers.js';
|
|
21
|
+
import { cloneParagraphBoundaryBookmarkMarkers, filterEquivalentBookmarkMarkers, insertLeadingMarkers, } from './inPlaceModifier-bookmarks.js';
|
|
22
|
+
import { addFormatChange, getAtomRunAtBoundary, getAtomRuns, getRunInsertionAnchor, wrapAsInserted, wrapAsMoveTo, wrapParagraphAsDeleted, wrapParagraphAsInserted, } from './inPlaceModifier-wrappers.js';
|
|
23
|
+
import { insertDeletedParagraph, insertDeletedRun, insertMoveFromRun } from './inPlaceModifier-deletion.js';
|
|
24
|
+
import { preSplitInterleavedWordRuns, preSplitMixedStatusRuns } from './inPlaceModifier-presplit.js';
|
|
25
|
+
import { coalesceDelInsPairChains, groupDeletionsBeforeInsertions, mergeAdjacentTrackChangeSiblings, mergeWhitespaceBridgedTrackChanges, suppressNoOpChangePairs, } from './inPlaceModifier-postprocess.js';
|
|
1438
26
|
export function modifyRevisedDocument(revisedRoot, originalAtoms, revisedAtoms, mergedAtoms, options) {
|
|
1439
27
|
const { author, date } = options;
|
|
1440
28
|
const dateStr = formatDate(date);
|
|
@@ -1483,6 +71,10 @@ function isParagraphRemovedOnRejectInContext(paragraph, ctx) {
|
|
|
1483
71
|
function handleInserted(atom, ctx) {
|
|
1484
72
|
const runs = getAtomRuns(atom);
|
|
1485
73
|
if (runs.length > 0) {
|
|
74
|
+
// ECMA-376 Part 4 permits w:fldChar inside <w:ins> (only <w:del> bars it),
|
|
75
|
+
// so an inserted complete field stays wrapped as a single <w:ins>. The
|
|
76
|
+
// fragmentation work for issue #217 is scoped to the <w:del> side via
|
|
77
|
+
// insertDeletedRun.
|
|
1486
78
|
for (const run of runs) {
|
|
1487
79
|
wrapAsInserted(run, ctx.author, ctx.dateStr, ctx.state);
|
|
1488
80
|
}
|
|
@@ -1769,6 +361,9 @@ function handleMovedSource(atom, ctx) {
|
|
|
1769
361
|
function handleMovedDestination(atom, ctx) {
|
|
1770
362
|
const runs = getAtomRuns(atom);
|
|
1771
363
|
if (runs.length > 0) {
|
|
364
|
+
// Move destinations behave like insertions; ECMA-376 does not bar w:fldChar
|
|
365
|
+
// from <w:moveTo> (only <w:del> is explicitly forbidden). Keep the existing
|
|
366
|
+
// single-wrapper behavior to avoid fragmenting a moved-in field.
|
|
1772
367
|
for (const run of runs) {
|
|
1773
368
|
wrapAsMoveTo(run, atom.moveName || 'move1', ctx.author, ctx.dateStr, ctx.state);
|
|
1774
369
|
}
|
|
@@ -1881,63 +476,8 @@ const ATOM_HANDLERS = {
|
|
|
1881
476
|
[CorrelationStatus.Equal]: handleEqual,
|
|
1882
477
|
[CorrelationStatus.Unknown]: handleEqual,
|
|
1883
478
|
};
|
|
1884
|
-
const DELETION_LIKE_STATUSES = new Set([
|
|
1885
|
-
CorrelationStatus.Deleted,
|
|
1886
|
-
CorrelationStatus.MovedSource,
|
|
1887
|
-
]);
|
|
1888
|
-
const INSERTION_LIKE_STATUSES = new Set([
|
|
1889
|
-
CorrelationStatus.Inserted,
|
|
1890
|
-
CorrelationStatus.MovedDestination,
|
|
1891
|
-
]);
|
|
1892
|
-
/**
|
|
1893
|
-
* Reorder merged atoms so that within each contiguous block of non-equal atoms,
|
|
1894
|
-
* all deletion-like atoms come before all insertion-like atoms.
|
|
1895
|
-
*
|
|
1896
|
-
* This produces grouped tracked changes ("<del>old words</del><ins>new words</ins>")
|
|
1897
|
-
* instead of alternating word-by-word pairs ("<del>old1</del><ins>new1</ins><del>old2</del>...").
|
|
1898
|
-
*/
|
|
1899
|
-
export function groupDeletionsBeforeInsertions(atoms) {
|
|
1900
|
-
const result = [];
|
|
1901
|
-
let i = 0;
|
|
1902
|
-
while (i < atoms.length) {
|
|
1903
|
-
const atom = atoms[i];
|
|
1904
|
-
const status = atom.correlationStatus;
|
|
1905
|
-
// Pass through equal/format-changed/unknown atoms unchanged
|
|
1906
|
-
if (!DELETION_LIKE_STATUSES.has(status) && !INSERTION_LIKE_STATUSES.has(status)) {
|
|
1907
|
-
result.push(atom);
|
|
1908
|
-
i++;
|
|
1909
|
-
continue;
|
|
1910
|
-
}
|
|
1911
|
-
// Collect a contiguous block of change atoms (deletions + insertions)
|
|
1912
|
-
const deletions = [];
|
|
1913
|
-
const insertions = [];
|
|
1914
|
-
while (i < atoms.length) {
|
|
1915
|
-
const s = atoms[i].correlationStatus;
|
|
1916
|
-
if (DELETION_LIKE_STATUSES.has(s)) {
|
|
1917
|
-
deletions.push(atoms[i]);
|
|
1918
|
-
}
|
|
1919
|
-
else if (INSERTION_LIKE_STATUSES.has(s)) {
|
|
1920
|
-
insertions.push(atoms[i]);
|
|
1921
|
-
}
|
|
1922
|
-
else {
|
|
1923
|
-
break;
|
|
1924
|
-
}
|
|
1925
|
-
i++;
|
|
1926
|
-
}
|
|
1927
|
-
// Emit all deletions first, then all insertions
|
|
1928
|
-
result.push(...deletions, ...insertions);
|
|
1929
|
-
}
|
|
1930
|
-
return result;
|
|
1931
|
-
}
|
|
1932
|
-
/**
|
|
1933
|
-
* Process atoms and apply track changes to the revised AST.
|
|
1934
|
-
*
|
|
1935
|
-
* Uses a strategy pattern with registered handlers for each correlation status,
|
|
1936
|
-
* making it easy to add new status types without modifying this function.
|
|
1937
|
-
*/
|
|
1938
479
|
function processAtoms(mergedAtoms, _originalAtoms, revisedAtoms, author, dateStr, state, revisedRoot) {
|
|
1939
|
-
const
|
|
1940
|
-
const body = bodyElements[0];
|
|
480
|
+
const body = findChild(revisedRoot, 'w:body');
|
|
1941
481
|
if (!body) {
|
|
1942
482
|
warn('inPlaceModifier', 'Cannot process atoms: no w:body element found');
|
|
1943
483
|
// Return a minimal context to avoid callers having to handle undefined.
|
|
@@ -2099,355 +639,10 @@ function applyWholeParagraphRevisionMarkers(mergedAtoms, ctx) {
|
|
|
2099
639
|
}
|
|
2100
640
|
}
|
|
2101
641
|
}
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
*/
|
|
2109
|
-
function mergeAdjacentTrackChangeSiblings(root) {
|
|
2110
|
-
function traverse(node) {
|
|
2111
|
-
const children = childElements(node);
|
|
2112
|
-
if (children.length < 2) {
|
|
2113
|
-
// Still recurse into children
|
|
2114
|
-
for (const child of childElements(node))
|
|
2115
|
-
traverse(child);
|
|
2116
|
-
return;
|
|
2117
|
-
}
|
|
2118
|
-
for (let i = 0; i < children.length - 1;) {
|
|
2119
|
-
const a = children[i];
|
|
2120
|
-
const b = children[i + 1];
|
|
2121
|
-
if (a.tagName === b.tagName && TRACK_CHANGE_WRAPPERS.has(a.tagName) &&
|
|
2122
|
-
a.getAttribute('w:author') === b.getAttribute('w:author') &&
|
|
2123
|
-
a.getAttribute('w:date') === b.getAttribute('w:date')) {
|
|
2124
|
-
// Absorb b's children into a
|
|
2125
|
-
while (b.firstChild)
|
|
2126
|
-
a.appendChild(b.firstChild);
|
|
2127
|
-
node.removeChild(b);
|
|
2128
|
-
// Remove b from our local children snapshot and don't increment i —
|
|
2129
|
-
// recheck a with its new next sibling.
|
|
2130
|
-
children.splice(i + 1, 1);
|
|
2131
|
-
continue;
|
|
2132
|
-
}
|
|
2133
|
-
i++;
|
|
2134
|
-
}
|
|
2135
|
-
// Recurse into current children (re-query after mutations)
|
|
2136
|
-
for (const child of childElements(node)) {
|
|
2137
|
-
traverse(child);
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
traverse(root);
|
|
2141
|
-
}
|
|
2142
|
-
// =============================================================================
|
|
2143
|
-
// Bug 1: Suppress field-adjacent false no-op del/ins pairs (issue #42)
|
|
2144
|
-
// =============================================================================
|
|
2145
|
-
/**
|
|
2146
|
-
* Build a normalized content signature for a run's non-rPr children.
|
|
2147
|
-
* On the del side, maps w:delText → w:t and w:delInstrText → w:instrText
|
|
2148
|
-
* so that content from del wrappers can be compared to ins wrappers.
|
|
2149
|
-
*/
|
|
2150
|
-
function normalizeRunContentSignature(run, isDelSide) {
|
|
2151
|
-
const parts = [];
|
|
2152
|
-
for (let i = 0; i < run.childNodes.length; i++) {
|
|
2153
|
-
const child = run.childNodes[i];
|
|
2154
|
-
if (child.nodeType !== 1)
|
|
2155
|
-
continue;
|
|
2156
|
-
const el = child;
|
|
2157
|
-
if (el.tagName === 'w:rPr')
|
|
2158
|
-
continue;
|
|
2159
|
-
let tag = el.tagName;
|
|
2160
|
-
if (isDelSide) {
|
|
2161
|
-
if (tag === 'w:delText')
|
|
2162
|
-
tag = 'w:t';
|
|
2163
|
-
else if (tag === 'w:delInstrText')
|
|
2164
|
-
tag = 'w:instrText';
|
|
2165
|
-
}
|
|
2166
|
-
const text = el.textContent ?? '';
|
|
2167
|
-
parts.push(`<${tag}>${text}</${tag}>`);
|
|
2168
|
-
}
|
|
2169
|
-
return parts.join('');
|
|
2170
|
-
}
|
|
2171
|
-
/**
|
|
2172
|
-
* Check if an adjacent w:del + w:ins pair is a no-op (identical text and formatting).
|
|
2173
|
-
* Both wrappers must contain the same number of runs with matching rPr and content.
|
|
2174
|
-
*/
|
|
2175
|
-
export function isNoOpPair(del, ins) {
|
|
2176
|
-
const delRuns = childElements(del).filter(c => c.tagName === 'w:r');
|
|
2177
|
-
const insRuns = childElements(ins).filter(c => c.tagName === 'w:r');
|
|
2178
|
-
if (delRuns.length !== insRuns.length)
|
|
2179
|
-
return false;
|
|
2180
|
-
if (delRuns.length === 0)
|
|
2181
|
-
return false;
|
|
2182
|
-
for (let i = 0; i < delRuns.length; i++) {
|
|
2183
|
-
const delRun = delRuns[i];
|
|
2184
|
-
const insRun = insRuns[i];
|
|
2185
|
-
// Compare formatting via canonical rPr comparison
|
|
2186
|
-
const delRPr = findChildByTagName(delRun, 'w:rPr');
|
|
2187
|
-
const insRPr = findChildByTagName(insRun, 'w:rPr');
|
|
2188
|
-
if (!areRunPropertiesEqual(delRPr ?? null, insRPr ?? null))
|
|
2189
|
-
return false;
|
|
2190
|
-
// Compare content structure (text, tabs, breaks, field chars, etc.)
|
|
2191
|
-
const delSig = normalizeRunContentSignature(delRun, true);
|
|
2192
|
-
const insSig = normalizeRunContentSignature(insRun, false);
|
|
2193
|
-
if (delSig !== insSig)
|
|
2194
|
-
return false;
|
|
2195
|
-
}
|
|
2196
|
-
return true;
|
|
2197
|
-
}
|
|
2198
|
-
/**
|
|
2199
|
-
* Suppress no-op del/ins pairs — adjacent w:del + w:ins wrappers where the
|
|
2200
|
-
* content and formatting are identical. These arise from field-adjacent atoms
|
|
2201
|
-
* that are false-positive changes.
|
|
2202
|
-
*
|
|
2203
|
-
* When a no-op is detected, both wrappers are unwrapped, leaving the ins-side
|
|
2204
|
-
* runs as plain (non-tracked) children. The del-side runs are removed.
|
|
2205
|
-
*/
|
|
2206
|
-
export function suppressNoOpChangePairs(root) {
|
|
2207
|
-
function traverse(node) {
|
|
2208
|
-
const children = childElements(node);
|
|
2209
|
-
for (let i = 0; i < children.length - 1;) {
|
|
2210
|
-
const a = children[i];
|
|
2211
|
-
const b = children[i + 1];
|
|
2212
|
-
if (a.tagName === 'w:del' && b.tagName === 'w:ins' && isNoOpPair(a, b)) {
|
|
2213
|
-
// Remove the del wrapper and its content entirely
|
|
2214
|
-
node.removeChild(a);
|
|
2215
|
-
// Unwrap the ins wrapper — promote its children to the parent
|
|
2216
|
-
unwrapElement(b);
|
|
2217
|
-
// Re-snapshot children after mutation
|
|
2218
|
-
children.splice(i, 2, ...childElements(node).slice(i));
|
|
2219
|
-
// Don't increment — recheck from same position
|
|
2220
|
-
continue;
|
|
2221
|
-
}
|
|
2222
|
-
i++;
|
|
2223
|
-
}
|
|
2224
|
-
// Recurse into current children (re-query after mutations)
|
|
2225
|
-
for (const child of childElements(node)) {
|
|
2226
|
-
traverse(child);
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
traverse(root);
|
|
2230
|
-
}
|
|
2231
|
-
// =============================================================================
|
|
2232
|
-
// Bug 2: Merge whitespace-bridged track change siblings (issue #42)
|
|
2233
|
-
// =============================================================================
|
|
2234
|
-
/**
|
|
2235
|
-
* Narrow whitespace predicate for bridging: returns true only if a w:r element's
|
|
2236
|
-
* visible children are exclusively w:t elements with whitespace-only text content.
|
|
2237
|
-
* Excludes w:tab, w:br, w:cr which have layout significance.
|
|
2238
|
-
*/
|
|
2239
|
-
function isInlineWhitespaceOnlyRun(run) {
|
|
2240
|
-
if (run.tagName !== 'w:r')
|
|
2241
|
-
return false;
|
|
2242
|
-
let hasVisibleChild = false;
|
|
2243
|
-
for (let i = 0; i < run.childNodes.length; i++) {
|
|
2244
|
-
const child = run.childNodes[i];
|
|
2245
|
-
if (child.nodeType !== 1)
|
|
2246
|
-
continue;
|
|
2247
|
-
const el = child;
|
|
2248
|
-
if (el.tagName === 'w:rPr')
|
|
2249
|
-
continue;
|
|
2250
|
-
// Only w:t with whitespace-only text is allowed
|
|
2251
|
-
if (el.tagName === 'w:t') {
|
|
2252
|
-
const text = el.textContent ?? '';
|
|
2253
|
-
if (text.length === 0 || !/^\s+$/.test(text))
|
|
2254
|
-
return false;
|
|
2255
|
-
hasVisibleChild = true;
|
|
2256
|
-
continue;
|
|
2257
|
-
}
|
|
2258
|
-
// Any other visible element (w:tab, w:br, w:cr, w:fldChar, etc.) disqualifies
|
|
2259
|
-
return false;
|
|
2260
|
-
}
|
|
2261
|
-
return hasVisibleChild;
|
|
2262
|
-
}
|
|
2263
|
-
/**
|
|
2264
|
-
* Merge track-change wrappers (w:del or w:ins) that are separated by a
|
|
2265
|
-
* whitespace-only run. This groups "word-by-word" tracked changes into
|
|
2266
|
-
* contiguous blocks for cleaner presentation.
|
|
2267
|
-
*
|
|
2268
|
-
* For w:del: clones the whitespace run, converts w:t→w:delText, and absorbs
|
|
2269
|
-
* both the whitespace and the second wrapper's children into the first wrapper.
|
|
2270
|
-
*
|
|
2271
|
-
* For w:ins: moves the whitespace run into the first wrapper, then absorbs
|
|
2272
|
-
* the second wrapper's children.
|
|
2273
|
-
*
|
|
2274
|
-
* Both projections (Accept All, Reject All) remain correct because each
|
|
2275
|
-
* wrapper independently contains the whitespace it needs.
|
|
2276
|
-
*/
|
|
2277
|
-
export function mergeWhitespaceBridgedTrackChanges(root) {
|
|
2278
|
-
function traverse(node) {
|
|
2279
|
-
const children = childElements(node);
|
|
2280
|
-
for (let i = 0; i < children.length - 2;) {
|
|
2281
|
-
const a = children[i];
|
|
2282
|
-
const mid = children[i + 1];
|
|
2283
|
-
const b = children[i + 2];
|
|
2284
|
-
// Check: same track-change tag, same author/date, with whitespace-only run between.
|
|
2285
|
-
// Only bridge w:ins and w:moveTo — bridging w:del is unsafe because the
|
|
2286
|
-
// intervening whitespace is Equal content needed by the accept projection.
|
|
2287
|
-
const bridgeableTags = new Set(['w:ins', 'w:moveTo']);
|
|
2288
|
-
if (a.tagName === b.tagName &&
|
|
2289
|
-
bridgeableTags.has(a.tagName) &&
|
|
2290
|
-
a.getAttribute('w:author') === b.getAttribute('w:author') &&
|
|
2291
|
-
a.getAttribute('w:date') === b.getAttribute('w:date') &&
|
|
2292
|
-
isInlineWhitespaceOnlyRun(mid)) {
|
|
2293
|
-
// Move the whitespace run into the first wrapper, then absorb second's children
|
|
2294
|
-
a.appendChild(mid);
|
|
2295
|
-
while (b.firstChild)
|
|
2296
|
-
a.appendChild(b.firstChild);
|
|
2297
|
-
node.removeChild(b);
|
|
2298
|
-
// mid was moved into a, b removed from parent — splice both from snapshot
|
|
2299
|
-
children.splice(i + 1, 2);
|
|
2300
|
-
// Don't increment — recheck a with new next sibling
|
|
2301
|
-
continue;
|
|
2302
|
-
}
|
|
2303
|
-
i++;
|
|
2304
|
-
}
|
|
2305
|
-
// Recurse into current children (re-query after mutations)
|
|
2306
|
-
for (const child of childElements(node)) {
|
|
2307
|
-
traverse(child);
|
|
2308
|
-
}
|
|
2309
|
-
}
|
|
2310
|
-
traverse(root);
|
|
2311
|
-
}
|
|
2312
|
-
// =============================================================================
|
|
2313
|
-
// Bug 2b: Coalesce del/ins pair chains across whitespace (issue #42)
|
|
2314
|
-
// =============================================================================
|
|
2315
|
-
/**
|
|
2316
|
-
* Convert w:t → w:delText and w:instrText → w:delInstrText within a run,
|
|
2317
|
-
* preserving xml:space attributes. Used for cloning whitespace runs into
|
|
2318
|
-
* w:del wrappers during pair-chain coalescing.
|
|
2319
|
-
*/
|
|
2320
|
-
function convertRunTextToDelText(run) {
|
|
2321
|
-
for (let i = 0; i < run.childNodes.length; i++) {
|
|
2322
|
-
const child = run.childNodes[i];
|
|
2323
|
-
if (child.nodeType !== 1)
|
|
2324
|
-
continue;
|
|
2325
|
-
const el = child;
|
|
2326
|
-
if (el.tagName === 'w:t' || el.tagName === 'w:instrText') {
|
|
2327
|
-
const newTag = el.tagName === 'w:t' ? 'w:delText' : 'w:delInstrText';
|
|
2328
|
-
const newEl = createEl(newTag);
|
|
2329
|
-
// Copy text content
|
|
2330
|
-
while (el.firstChild)
|
|
2331
|
-
newEl.appendChild(el.firstChild);
|
|
2332
|
-
// Copy attributes (including xml:space="preserve")
|
|
2333
|
-
for (let j = 0; j < el.attributes.length; j++) {
|
|
2334
|
-
const attr = el.attributes[j];
|
|
2335
|
-
newEl.setAttribute(attr.name, attr.value);
|
|
2336
|
-
}
|
|
2337
|
-
run.replaceChild(newEl, el);
|
|
2338
|
-
}
|
|
2339
|
-
}
|
|
2340
|
-
}
|
|
2341
|
-
/**
|
|
2342
|
-
* Coalesce alternating del/ins pair chains separated by whitespace-only runs
|
|
2343
|
-
* into single grouped del + ins wrappers.
|
|
2344
|
-
*
|
|
2345
|
-
* Pattern: [w:del, w:ins, ws-segment..., w:del, w:ins, ws-segment..., w:del, w:ins]
|
|
2346
|
-
*
|
|
2347
|
-
* For each whitespace segment between consecutive [del, ins] pairs:
|
|
2348
|
-
* 1. Clone each ws-run → convert to delText → append to first del
|
|
2349
|
-
* 2. Clone each ws-run → keep as w:t → append to first ins
|
|
2350
|
-
* 3. Move nextDel's children into first del
|
|
2351
|
-
* 4. Move nextIns's children into first ins
|
|
2352
|
-
* 5. Remove original ws-runs, empty nextDel, empty nextIns from parent
|
|
2353
|
-
*
|
|
2354
|
-
* Safety invariants:
|
|
2355
|
-
* - Only bridges when both del AND ins absorb the whitespace (both projections correct)
|
|
2356
|
-
* - Incomplete tail [del, ins, ws, del] (no trailing ins) → stop chain, don't bridge
|
|
2357
|
-
* - All wrappers in chain must share same w:author and w:date
|
|
2358
|
-
*/
|
|
2359
|
-
export function coalesceDelInsPairChains(root) {
|
|
2360
|
-
function traverse(node) {
|
|
2361
|
-
const children = childElements(node);
|
|
2362
|
-
for (let i = 0; i < children.length - 1;) {
|
|
2363
|
-
const firstDel = children[i];
|
|
2364
|
-
const firstIns = children[i + 1];
|
|
2365
|
-
// Must start with a [del, ins] pair
|
|
2366
|
-
if (firstDel.tagName !== 'w:del' || firstIns.tagName !== 'w:ins') {
|
|
2367
|
-
i++;
|
|
2368
|
-
continue;
|
|
2369
|
-
}
|
|
2370
|
-
const author = firstDel.getAttribute('w:author');
|
|
2371
|
-
const date = firstDel.getAttribute('w:date');
|
|
2372
|
-
// All four must match author/date
|
|
2373
|
-
if (firstIns.getAttribute('w:author') !== author ||
|
|
2374
|
-
firstIns.getAttribute('w:date') !== date) {
|
|
2375
|
-
i++;
|
|
2376
|
-
continue;
|
|
2377
|
-
}
|
|
2378
|
-
// Try to extend the chain by absorbing subsequent [ws..., del, ins] triples
|
|
2379
|
-
let cursor = i + 2; // position after firstIns
|
|
2380
|
-
let chainExtended = false;
|
|
2381
|
-
while (cursor < children.length) {
|
|
2382
|
-
// Collect whitespace segment (1..N consecutive whitespace-only runs)
|
|
2383
|
-
const wsStart = cursor;
|
|
2384
|
-
while (cursor < children.length && isInlineWhitespaceOnlyRun(children[cursor])) {
|
|
2385
|
-
cursor++;
|
|
2386
|
-
}
|
|
2387
|
-
const wsEnd = cursor;
|
|
2388
|
-
const wsCount = wsEnd - wsStart;
|
|
2389
|
-
if (wsCount === 0)
|
|
2390
|
-
break; // No whitespace → end of chain
|
|
2391
|
-
// Must have a complete [del, ins] pair after the whitespace
|
|
2392
|
-
if (cursor + 1 >= children.length)
|
|
2393
|
-
break; // Not enough elements
|
|
2394
|
-
const nextDel = children[cursor];
|
|
2395
|
-
const nextIns = children[cursor + 1];
|
|
2396
|
-
if (nextDel.tagName !== 'w:del' || nextIns.tagName !== 'w:ins')
|
|
2397
|
-
break;
|
|
2398
|
-
// Author/date must match
|
|
2399
|
-
if (nextDel.getAttribute('w:author') !== author ||
|
|
2400
|
-
nextDel.getAttribute('w:date') !== date ||
|
|
2401
|
-
nextIns.getAttribute('w:author') !== author ||
|
|
2402
|
-
nextIns.getAttribute('w:date') !== date)
|
|
2403
|
-
break;
|
|
2404
|
-
// All conditions met — absorb this [ws..., del, ins] into the first pair
|
|
2405
|
-
// 1. Clone whitespace runs into del (as delText) and ins (as w:t)
|
|
2406
|
-
for (let w = wsStart; w < wsEnd; w++) {
|
|
2407
|
-
const wsRun = children[w];
|
|
2408
|
-
const delClone = wsRun.cloneNode(true);
|
|
2409
|
-
convertRunTextToDelText(delClone);
|
|
2410
|
-
firstDel.appendChild(delClone);
|
|
2411
|
-
const insClone = wsRun.cloneNode(true);
|
|
2412
|
-
firstIns.appendChild(insClone);
|
|
2413
|
-
}
|
|
2414
|
-
// 2. Move nextDel's children into firstDel
|
|
2415
|
-
while (nextDel.firstChild)
|
|
2416
|
-
firstDel.appendChild(nextDel.firstChild);
|
|
2417
|
-
// 3. Move nextIns's children into firstIns
|
|
2418
|
-
while (nextIns.firstChild)
|
|
2419
|
-
firstIns.appendChild(nextIns.firstChild);
|
|
2420
|
-
// 4. Remove ws-runs, nextDel, nextIns from parent
|
|
2421
|
-
for (let w = wsStart; w < wsEnd; w++) {
|
|
2422
|
-
node.removeChild(children[w]);
|
|
2423
|
-
}
|
|
2424
|
-
node.removeChild(nextDel);
|
|
2425
|
-
node.removeChild(nextIns);
|
|
2426
|
-
// 5. Splice removed elements from children snapshot
|
|
2427
|
-
// Remove wsCount + 2 elements starting at wsStart
|
|
2428
|
-
children.splice(wsStart, wsCount + 2);
|
|
2429
|
-
// Reset cursor to continue checking after firstIns
|
|
2430
|
-
cursor = wsStart;
|
|
2431
|
-
chainExtended = true;
|
|
2432
|
-
}
|
|
2433
|
-
// Advance past the (possibly extended) [del, ins] pair
|
|
2434
|
-
i += chainExtended ? 2 : 1;
|
|
2435
|
-
// If no chain was formed, we only skip the del (i++ already happened above
|
|
2436
|
-
// for the non-chain case), but if it was a chain we skip both del+ins.
|
|
2437
|
-
// Actually: if chain wasn't extended, we need to check if firstDel+firstIns
|
|
2438
|
-
// alone should advance by 2 or by 1. Since they ARE a del+ins pair but
|
|
2439
|
-
// no chain formed, skip both.
|
|
2440
|
-
if (!chainExtended) {
|
|
2441
|
-
i++; // skip the ins too (total i += 2 from the earlier i++)
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
// Recurse into current children (re-query after mutations)
|
|
2445
|
-
for (const child of childElements(node)) {
|
|
2446
|
-
traverse(child);
|
|
2447
|
-
}
|
|
2448
|
-
}
|
|
2449
|
-
traverse(root);
|
|
2450
|
-
}
|
|
2451
|
-
// Re-export for convenience
|
|
2452
|
-
export { createRevisionIdState };
|
|
642
|
+
export { createRevisionIdState, } from './inPlaceModifier-shared.js';
|
|
643
|
+
export { ContainerResolutionError, getContainerPath, resolveContainerInRevised, validateContainerTopology, } from './inPlaceModifier-containers.js';
|
|
644
|
+
export { addFormatChange, addParagraphPropertyChange, runHasVisibleContent, wrapAsDeleted, wrapAsInserted, wrapAsMoveFrom, wrapAsMoveTo, wrapParagraphAsDeleted, wrapParagraphAsInserted, } from './inPlaceModifier-wrappers.js';
|
|
645
|
+
export { insertDeletedParagraph, insertDeletedRun, insertMoveFromRun, } from './inPlaceModifier-deletion.js';
|
|
646
|
+
export { preSplitInterleavedWordRuns, preSplitMixedStatusRuns, } from './inPlaceModifier-presplit.js';
|
|
647
|
+
export { coalesceDelInsPairChains, groupDeletionsBeforeInsertions, isNoOpPair, mergeWhitespaceBridgedTrackChanges, suppressNoOpChangePairs, } from './inPlaceModifier-postprocess.js';
|
|
2453
648
|
//# sourceMappingURL=inPlaceModifier.js.map
|