@usejunior/docx-core 0.0.1 → 0.1.1
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/LICENSE +21 -0
- package/README.md +86 -28
- package/dist/.tsbuildinfo +1 -0
- package/dist/atomizer.d.ts +218 -0
- package/dist/atomizer.d.ts.map +1 -0
- package/dist/atomizer.js +856 -0
- package/dist/atomizer.js.map +1 -0
- package/dist/baselines/atomizer/atomLcs.d.ts +96 -0
- package/dist/baselines/atomizer/atomLcs.d.ts.map +1 -0
- package/dist/baselines/atomizer/atomLcs.js +347 -0
- package/dist/baselines/atomizer/atomLcs.js.map +1 -0
- package/dist/baselines/atomizer/debug.d.ts +41 -0
- package/dist/baselines/atomizer/debug.d.ts.map +1 -0
- package/dist/baselines/atomizer/debug.js +85 -0
- package/dist/baselines/atomizer/debug.js.map +1 -0
- package/dist/baselines/atomizer/documentReconstructor.d.ts +64 -0
- package/dist/baselines/atomizer/documentReconstructor.d.ts.map +1 -0
- package/dist/baselines/atomizer/documentReconstructor.js +939 -0
- package/dist/baselines/atomizer/documentReconstructor.js.map +1 -0
- package/dist/baselines/atomizer/hierarchicalLcs.d.ts +111 -0
- package/dist/baselines/atomizer/hierarchicalLcs.d.ts.map +1 -0
- package/dist/baselines/atomizer/hierarchicalLcs.js +469 -0
- package/dist/baselines/atomizer/hierarchicalLcs.js.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier.d.ts +183 -0
- package/dist/baselines/atomizer/inPlaceModifier.d.ts.map +1 -0
- package/dist/baselines/atomizer/inPlaceModifier.js +1600 -0
- package/dist/baselines/atomizer/inPlaceModifier.js.map +1 -0
- package/dist/baselines/atomizer/numberingIntegration.d.ts +59 -0
- package/dist/baselines/atomizer/numberingIntegration.d.ts.map +1 -0
- package/dist/baselines/atomizer/numberingIntegration.js +209 -0
- package/dist/baselines/atomizer/numberingIntegration.js.map +1 -0
- package/dist/baselines/atomizer/pipeline.d.ts +65 -0
- package/dist/baselines/atomizer/pipeline.d.ts.map +1 -0
- package/dist/baselines/atomizer/pipeline.js +510 -0
- package/dist/baselines/atomizer/pipeline.js.map +1 -0
- package/dist/baselines/atomizer/premergeRuns.d.ts +26 -0
- package/dist/baselines/atomizer/premergeRuns.d.ts.map +1 -0
- package/dist/baselines/atomizer/premergeRuns.js +150 -0
- package/dist/baselines/atomizer/premergeRuns.js.map +1 -0
- package/dist/baselines/atomizer/trackChangesAcceptor.d.ts +63 -0
- package/dist/baselines/atomizer/trackChangesAcceptor.d.ts.map +1 -0
- package/dist/baselines/atomizer/trackChangesAcceptor.js +254 -0
- package/dist/baselines/atomizer/trackChangesAcceptor.js.map +1 -0
- package/dist/baselines/atomizer/trackChangesAcceptorAst.d.ts +64 -0
- package/dist/baselines/atomizer/trackChangesAcceptorAst.d.ts.map +1 -0
- package/dist/baselines/atomizer/trackChangesAcceptorAst.js +586 -0
- package/dist/baselines/atomizer/trackChangesAcceptorAst.js.map +1 -0
- package/dist/baselines/atomizer/xmlToWmlElement.d.ts +65 -0
- package/dist/baselines/atomizer/xmlToWmlElement.d.ts.map +1 -0
- package/dist/baselines/atomizer/xmlToWmlElement.js +95 -0
- package/dist/baselines/atomizer/xmlToWmlElement.js.map +1 -0
- package/dist/baselines/diffmatch/documentBuilder.d.ts +44 -0
- package/dist/baselines/diffmatch/documentBuilder.d.ts.map +1 -0
- package/dist/baselines/diffmatch/documentBuilder.js +227 -0
- package/dist/baselines/diffmatch/documentBuilder.js.map +1 -0
- package/dist/baselines/diffmatch/paragraphAlignment.d.ts +75 -0
- package/dist/baselines/diffmatch/paragraphAlignment.d.ts.map +1 -0
- package/dist/baselines/diffmatch/paragraphAlignment.js +206 -0
- package/dist/baselines/diffmatch/paragraphAlignment.js.map +1 -0
- package/dist/baselines/diffmatch/pipeline.d.ts +33 -0
- package/dist/baselines/diffmatch/pipeline.d.ts.map +1 -0
- package/dist/baselines/diffmatch/pipeline.js +84 -0
- package/dist/baselines/diffmatch/pipeline.js.map +1 -0
- package/dist/baselines/diffmatch/runDiff.d.ts +53 -0
- package/dist/baselines/diffmatch/runDiff.d.ts.map +1 -0
- package/dist/baselines/diffmatch/runDiff.js +253 -0
- package/dist/baselines/diffmatch/runDiff.js.map +1 -0
- package/dist/baselines/diffmatch/trackChangesRenderer.d.ts +64 -0
- package/dist/baselines/diffmatch/trackChangesRenderer.d.ts.map +1 -0
- package/dist/baselines/diffmatch/trackChangesRenderer.js +178 -0
- package/dist/baselines/diffmatch/trackChangesRenderer.js.map +1 -0
- package/dist/baselines/diffmatch/xmlParser.d.ts +45 -0
- package/dist/baselines/diffmatch/xmlParser.d.ts.map +1 -0
- package/dist/baselines/diffmatch/xmlParser.js +344 -0
- package/dist/baselines/diffmatch/xmlParser.js.map +1 -0
- package/dist/baselines/wmlcomparer/DocxodusWasm.d.ts +51 -0
- package/dist/baselines/wmlcomparer/DocxodusWasm.d.ts.map +1 -0
- package/dist/baselines/wmlcomparer/DocxodusWasm.js +83 -0
- package/dist/baselines/wmlcomparer/DocxodusWasm.js.map +1 -0
- package/dist/baselines/wmlcomparer/DotnetCli.d.ts +40 -0
- package/dist/baselines/wmlcomparer/DotnetCli.d.ts.map +1 -0
- package/dist/baselines/wmlcomparer/DotnetCli.js +135 -0
- package/dist/baselines/wmlcomparer/DotnetCli.js.map +1 -0
- package/dist/benchmark/metrics.d.ts +72 -0
- package/dist/benchmark/metrics.d.ts.map +1 -0
- package/dist/benchmark/metrics.js +45 -0
- package/dist/benchmark/metrics.js.map +1 -0
- package/dist/benchmark/reporter.d.ts +23 -0
- package/dist/benchmark/reporter.d.ts.map +1 -0
- package/dist/benchmark/reporter.js +147 -0
- package/dist/benchmark/reporter.js.map +1 -0
- package/dist/benchmark/runner.d.ts +30 -0
- package/dist/benchmark/runner.d.ts.map +1 -0
- package/dist/benchmark/runner.js +233 -0
- package/dist/benchmark/runner.js.map +1 -0
- package/dist/cli/compare-two.d.ts +28 -0
- package/dist/cli/compare-two.d.ts.map +1 -0
- package/dist/cli/compare-two.js +110 -0
- package/dist/cli/compare-two.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +21 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core-types.d.ts +296 -0
- package/dist/core-types.d.ts.map +1 -0
- package/dist/core-types.js +122 -0
- package/dist/core-types.js.map +1 -0
- package/dist/footnotes.d.ts +144 -0
- package/dist/footnotes.d.ts.map +1 -0
- package/dist/footnotes.js +291 -0
- package/dist/footnotes.js.map +1 -0
- package/dist/format-detection.d.ts +120 -0
- package/dist/format-detection.d.ts.map +1 -0
- package/dist/format-detection.js +338 -0
- package/dist/format-detection.js.map +1 -0
- package/dist/index.d.ts +177 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/integration/output-artifacts.d.ts +6 -0
- package/dist/integration/output-artifacts.d.ts.map +1 -0
- package/dist/integration/output-artifacts.js +30 -0
- package/dist/integration/output-artifacts.js.map +1 -0
- package/dist/move-detection.d.ts +211 -0
- package/dist/move-detection.d.ts.map +1 -0
- package/dist/move-detection.js +391 -0
- package/dist/move-detection.js.map +1 -0
- package/dist/numbering.d.ts +136 -0
- package/dist/numbering.d.ts.map +1 -0
- package/dist/numbering.js +446 -0
- package/dist/numbering.js.map +1 -0
- package/dist/primitives/accept_changes.d.ts +30 -0
- package/dist/primitives/accept_changes.d.ts.map +1 -0
- package/dist/primitives/accept_changes.js +241 -0
- package/dist/primitives/accept_changes.js.map +1 -0
- package/dist/primitives/bookmarks.d.ts +12 -0
- package/dist/primitives/bookmarks.d.ts.map +1 -0
- package/dist/primitives/bookmarks.js +248 -0
- package/dist/primitives/bookmarks.js.map +1 -0
- package/dist/primitives/comments.d.ts +88 -0
- package/dist/primitives/comments.d.ts.map +1 -0
- package/dist/primitives/comments.js +703 -0
- package/dist/primitives/comments.js.map +1 -0
- package/dist/primitives/document.d.ts +168 -0
- package/dist/primitives/document.d.ts.map +1 -0
- package/dist/primitives/document.js +532 -0
- package/dist/primitives/document.js.map +1 -0
- package/dist/primitives/document_view.d.ts +93 -0
- package/dist/primitives/document_view.d.ts.map +1 -0
- package/dist/primitives/document_view.js +722 -0
- package/dist/primitives/document_view.js.map +1 -0
- package/dist/primitives/dom-helpers.d.ts +94 -0
- package/dist/primitives/dom-helpers.d.ts.map +1 -0
- package/dist/primitives/dom-helpers.js +219 -0
- package/dist/primitives/dom-helpers.js.map +1 -0
- package/dist/primitives/errors.d.ts +7 -0
- package/dist/primitives/errors.d.ts.map +1 -0
- package/dist/primitives/errors.js +10 -0
- package/dist/primitives/errors.js.map +1 -0
- package/dist/primitives/extract_revisions.d.ts +50 -0
- package/dist/primitives/extract_revisions.d.ts.map +1 -0
- package/dist/primitives/extract_revisions.js +340 -0
- package/dist/primitives/extract_revisions.js.map +1 -0
- package/dist/primitives/footnotes.d.ts +37 -0
- package/dist/primitives/footnotes.d.ts.map +1 -0
- package/dist/primitives/footnotes.js +552 -0
- package/dist/primitives/footnotes.js.map +1 -0
- package/dist/primitives/formatting_tags.d.ts +30 -0
- package/dist/primitives/formatting_tags.d.ts.map +1 -0
- package/dist/primitives/formatting_tags.js +217 -0
- package/dist/primitives/formatting_tags.js.map +1 -0
- package/dist/primitives/index.d.ts +26 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +26 -0
- package/dist/primitives/index.js.map +1 -0
- package/dist/primitives/layout.d.ts +53 -0
- package/dist/primitives/layout.d.ts.map +1 -0
- package/dist/primitives/layout.js +178 -0
- package/dist/primitives/layout.js.map +1 -0
- package/dist/primitives/list_labels.d.ts +19 -0
- package/dist/primitives/list_labels.d.ts.map +1 -0
- package/dist/primitives/list_labels.js +57 -0
- package/dist/primitives/list_labels.js.map +1 -0
- package/dist/primitives/matching.d.ts +17 -0
- package/dist/primitives/matching.d.ts.map +1 -0
- package/dist/primitives/matching.js +144 -0
- package/dist/primitives/matching.js.map +1 -0
- package/dist/primitives/merge_runs.d.ts +23 -0
- package/dist/primitives/merge_runs.d.ts.map +1 -0
- package/dist/primitives/merge_runs.js +195 -0
- package/dist/primitives/merge_runs.js.map +1 -0
- package/dist/primitives/namespaces.d.ts +90 -0
- package/dist/primitives/namespaces.d.ts.map +1 -0
- package/dist/primitives/namespaces.js +107 -0
- package/dist/primitives/namespaces.js.map +1 -0
- package/dist/primitives/numbering.d.ts +27 -0
- package/dist/primitives/numbering.d.ts.map +1 -0
- package/dist/primitives/numbering.js +182 -0
- package/dist/primitives/numbering.js.map +1 -0
- package/dist/primitives/prevent_double_elevation.d.ts +18 -0
- package/dist/primitives/prevent_double_elevation.d.ts.map +1 -0
- package/dist/primitives/prevent_double_elevation.js +190 -0
- package/dist/primitives/prevent_double_elevation.js.map +1 -0
- package/dist/primitives/reject_changes.d.ts +27 -0
- package/dist/primitives/reject_changes.d.ts.map +1 -0
- package/dist/primitives/reject_changes.js +371 -0
- package/dist/primitives/reject_changes.js.map +1 -0
- package/dist/primitives/relationships.d.ts +7 -0
- package/dist/primitives/relationships.d.ts.map +1 -0
- package/dist/primitives/relationships.js +24 -0
- package/dist/primitives/relationships.js.map +1 -0
- package/dist/primitives/semantic_tags.d.ts +32 -0
- package/dist/primitives/semantic_tags.d.ts.map +1 -0
- package/dist/primitives/semantic_tags.js +139 -0
- package/dist/primitives/semantic_tags.js.map +1 -0
- package/dist/primitives/simplify_redlines.d.ts +19 -0
- package/dist/primitives/simplify_redlines.d.ts.map +1 -0
- package/dist/primitives/simplify_redlines.js +94 -0
- package/dist/primitives/simplify_redlines.js.map +1 -0
- package/dist/primitives/styles.d.ts +36 -0
- package/dist/primitives/styles.d.ts.map +1 -0
- package/dist/primitives/styles.js +190 -0
- package/dist/primitives/styles.js.map +1 -0
- package/dist/primitives/text.d.ts +27 -0
- package/dist/primitives/text.d.ts.map +1 -0
- package/dist/primitives/text.js +416 -0
- package/dist/primitives/text.js.map +1 -0
- package/dist/primitives/validate_document.d.ts +24 -0
- package/dist/primitives/validate_document.d.ts.map +1 -0
- package/dist/primitives/validate_document.js +147 -0
- package/dist/primitives/validate_document.js.map +1 -0
- package/dist/primitives/xml.d.ts +5 -0
- package/dist/primitives/xml.d.ts.map +1 -0
- package/dist/primitives/xml.js +19 -0
- package/dist/primitives/xml.js.map +1 -0
- package/dist/primitives/zip.d.ts +25 -0
- package/dist/primitives/zip.d.ts.map +1 -0
- package/dist/primitives/zip.js +78 -0
- package/dist/primitives/zip.js.map +1 -0
- package/dist/shared/docx/DocxArchive.d.ts +94 -0
- package/dist/shared/docx/DocxArchive.d.ts.map +1 -0
- package/dist/shared/docx/DocxArchive.js +169 -0
- package/dist/shared/docx/DocxArchive.js.map +1 -0
- package/dist/shared/ooxml/namespaces.d.ts +149 -0
- package/dist/shared/ooxml/namespaces.d.ts.map +1 -0
- package/dist/shared/ooxml/namespaces.js +224 -0
- package/dist/shared/ooxml/namespaces.js.map +1 -0
- package/dist/shared/ooxml/types.d.ts +136 -0
- package/dist/shared/ooxml/types.d.ts.map +1 -0
- package/dist/shared/ooxml/types.js +7 -0
- package/dist/shared/ooxml/types.js.map +1 -0
- package/package.json +63 -6
|
@@ -0,0 +1,1600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-Place AST Modifier
|
|
3
|
+
*
|
|
4
|
+
* Modifies the revised document's AST in-place to add track changes markup.
|
|
5
|
+
* This replaces the reconstruction-based approach with direct tree manipulation.
|
|
6
|
+
*
|
|
7
|
+
* Key operations:
|
|
8
|
+
* - wrapAsInserted: Wrap run elements with <w:ins> for inserted content
|
|
9
|
+
* - insertDeletedContent: Clone and insert deleted content with <w:del> wrapper
|
|
10
|
+
* - wrapAsMoveFrom/wrapAsMoveTo: Add move tracking with range markers
|
|
11
|
+
* - addFormatChange: Add <w:rPrChange> for formatting differences
|
|
12
|
+
*/
|
|
13
|
+
import { CorrelationStatus } from '../../core-types.js';
|
|
14
|
+
import { EMPTY_PARAGRAPH_TAG } from '../../atomizer.js';
|
|
15
|
+
import { getLeafText, childElements, findChildByTagName, insertAfterElement, wrapElement, splitRunAtVisibleOffset, visibleLengthForEl, getDirectContentElements, } from '../../primitives/index.js';
|
|
16
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
17
|
+
import { warn } from './debug.js';
|
|
18
|
+
const SYNTHETIC_DOC = new DOMParser().parseFromString('<root xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>', 'application/xml');
|
|
19
|
+
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
20
|
+
/**
|
|
21
|
+
* Create a namespaced OOXML element with optional attributes.
|
|
22
|
+
* Uses SYNTHETIC_DOC so elements can be adopted by any document tree.
|
|
23
|
+
*/
|
|
24
|
+
function createEl(tag, attrs) {
|
|
25
|
+
const el = SYNTHETIC_DOC.createElementNS(W_NS, tag);
|
|
26
|
+
if (attrs)
|
|
27
|
+
for (const [k, v] of Object.entries(attrs))
|
|
28
|
+
el.setAttribute(k, v);
|
|
29
|
+
return el;
|
|
30
|
+
}
|
|
31
|
+
function findAncestorByTag(atom, tagName) {
|
|
32
|
+
for (let i = atom.ancestorElements.length - 1; i >= 0; i--) {
|
|
33
|
+
const el = atom.ancestorElements[i];
|
|
34
|
+
if (el.tagName === tagName)
|
|
35
|
+
return el;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
function attachSourceElementPointers(atoms) {
|
|
40
|
+
for (const atom of atoms) {
|
|
41
|
+
atom.sourceRunElement = findAncestorByTag(atom, 'w:r');
|
|
42
|
+
atom.sourceParagraphElement = findAncestorByTag(atom, 'w:p');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Determine whether an atom is "whitespace-only" for paragraph-level classification.
|
|
47
|
+
*
|
|
48
|
+
* We treat pure whitespace runs/tabs/breaks as ignorable noise, because LCS alignment
|
|
49
|
+
* can mark them Equal even when a whole paragraph was inserted/deleted. If we don't
|
|
50
|
+
* ignore them, Word can end up with a stub paragraph after Accept/Reject All.
|
|
51
|
+
*/
|
|
52
|
+
function isWhitespaceAtom(atom) {
|
|
53
|
+
const el = atom.contentElement;
|
|
54
|
+
if (el.tagName === EMPTY_PARAGRAPH_TAG)
|
|
55
|
+
return true;
|
|
56
|
+
if (el.tagName === 'w:t')
|
|
57
|
+
return ((getLeafText(el) ?? '').trim() === '');
|
|
58
|
+
return el.tagName === 'w:tab' || el.tagName === 'w:br' || el.tagName === 'w:cr';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Returns true if every non-empty atom in this paragraph is of the specified status,
|
|
62
|
+
* ignoring whitespace-only atoms.
|
|
63
|
+
*
|
|
64
|
+
* Mirrors the rebuild reconstructor's whole-paragraph classification so that inplace
|
|
65
|
+
* output behaves the same under Word's Accept/Reject All.
|
|
66
|
+
*/
|
|
67
|
+
function isEntireParagraphAtomsWithStatus(atoms, status) {
|
|
68
|
+
let sawAnyContent = false;
|
|
69
|
+
let sawTargetStatus = false;
|
|
70
|
+
for (const atom of atoms) {
|
|
71
|
+
const el = atom.contentElement;
|
|
72
|
+
if (el.tagName === EMPTY_PARAGRAPH_TAG)
|
|
73
|
+
continue;
|
|
74
|
+
sawAnyContent = true;
|
|
75
|
+
if (atom.correlationStatus === status) {
|
|
76
|
+
sawTargetStatus = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (isWhitespaceAtom(atom))
|
|
80
|
+
continue;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return sawAnyContent && sawTargetStatus;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Create initial revision ID state.
|
|
87
|
+
*/
|
|
88
|
+
function createRevisionIdState() {
|
|
89
|
+
return {
|
|
90
|
+
nextId: 1,
|
|
91
|
+
moveRangeIds: new Map(),
|
|
92
|
+
wrappedRuns: new Set(),
|
|
93
|
+
emittedSourceBookmarkMarkers: new Set(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Allocate a new revision ID.
|
|
98
|
+
*/
|
|
99
|
+
function allocateRevisionId(state) {
|
|
100
|
+
return state.nextId++;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get or allocate move range IDs for a move name.
|
|
104
|
+
*/
|
|
105
|
+
function getMoveRangeIds(state, moveName) {
|
|
106
|
+
let ids = state.moveRangeIds.get(moveName);
|
|
107
|
+
if (!ids) {
|
|
108
|
+
ids = {
|
|
109
|
+
sourceRangeId: allocateRevisionId(state),
|
|
110
|
+
destRangeId: allocateRevisionId(state),
|
|
111
|
+
};
|
|
112
|
+
state.moveRangeIds.set(moveName, ids);
|
|
113
|
+
}
|
|
114
|
+
return ids;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Format date for OOXML (ISO 8601 without milliseconds).
|
|
118
|
+
*/
|
|
119
|
+
function formatDate(date) {
|
|
120
|
+
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Convert w:t elements to w:delText within an element tree.
|
|
124
|
+
*
|
|
125
|
+
* @param element - The element to process
|
|
126
|
+
*/
|
|
127
|
+
function convertToDelText(element) {
|
|
128
|
+
if (element.tagName === 'w:t') {
|
|
129
|
+
const newEl = createEl('w:delText');
|
|
130
|
+
// Copy text content
|
|
131
|
+
while (element.firstChild)
|
|
132
|
+
newEl.appendChild(element.firstChild);
|
|
133
|
+
// Copy attributes
|
|
134
|
+
for (let i = 0; i < element.attributes.length; i++) {
|
|
135
|
+
const attr = element.attributes[i];
|
|
136
|
+
newEl.setAttribute(attr.name, attr.value);
|
|
137
|
+
}
|
|
138
|
+
element.parentNode?.replaceChild(newEl, element);
|
|
139
|
+
// Recurse into children of the new element (none expected for w:t, but be safe)
|
|
140
|
+
for (const child of childElements(newEl)) {
|
|
141
|
+
convertToDelText(child);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const child of childElements(element)) {
|
|
146
|
+
convertToDelText(child);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Build the content elements that should be inserted for an atom.
|
|
151
|
+
*
|
|
152
|
+
* Collapsed field atoms use a synthetic w:t as their top-level contentElement,
|
|
153
|
+
* but retain the original field sequence in collapsedFieldAtoms. For insertion,
|
|
154
|
+
* we must replay the original sequence rather than the synthetic text.
|
|
155
|
+
*/
|
|
156
|
+
function getInsertableAtomContentElements(atom) {
|
|
157
|
+
if (atom.collapsedFieldAtoms && atom.collapsedFieldAtoms.length > 0) {
|
|
158
|
+
return atom.collapsedFieldAtoms.map((fieldAtom) => fieldAtom.contentElement);
|
|
159
|
+
}
|
|
160
|
+
return [atom.contentElement];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Clone a source run and replace its non-rPr children with atom content.
|
|
164
|
+
*
|
|
165
|
+
* This keeps run-level formatting while allowing atom-level fragment insertion.
|
|
166
|
+
*/
|
|
167
|
+
function cloneRunWithAtomContent(sourceRun, atom) {
|
|
168
|
+
const clonedRun = sourceRun.cloneNode(true);
|
|
169
|
+
const retainedChildren = [];
|
|
170
|
+
for (const child of childElements(clonedRun)) {
|
|
171
|
+
if (child.tagName === 'w:rPr') {
|
|
172
|
+
retainedChildren.push(child);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Remove all current children from clonedRun
|
|
176
|
+
while (clonedRun.firstChild)
|
|
177
|
+
clonedRun.removeChild(clonedRun.firstChild);
|
|
178
|
+
// Re-append retained rPr children
|
|
179
|
+
for (const child of retainedChildren) {
|
|
180
|
+
clonedRun.appendChild(child);
|
|
181
|
+
}
|
|
182
|
+
for (const contentElement of getInsertableAtomContentElements(atom)) {
|
|
183
|
+
const fragment = contentElement.cloneNode(true);
|
|
184
|
+
clonedRun.appendChild(fragment);
|
|
185
|
+
}
|
|
186
|
+
return clonedRun;
|
|
187
|
+
}
|
|
188
|
+
function cloneParagraphBoundaryBookmarkMarkers(sourceParagraph) {
|
|
189
|
+
const kids = sourceParagraph ? childElements(sourceParagraph) : [];
|
|
190
|
+
if (!sourceParagraph || kids.length === 0) {
|
|
191
|
+
return { leading: [], trailing: [], sourceLeading: [], sourceTrailing: [] };
|
|
192
|
+
}
|
|
193
|
+
const children = kids;
|
|
194
|
+
let firstRunIdx = -1;
|
|
195
|
+
let lastRunIdx = -1;
|
|
196
|
+
for (let i = 0; i < children.length; i++) {
|
|
197
|
+
if (children[i]?.tagName === 'w:r') {
|
|
198
|
+
if (firstRunIdx < 0)
|
|
199
|
+
firstRunIdx = i;
|
|
200
|
+
lastRunIdx = i;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const leading = [];
|
|
204
|
+
const trailing = [];
|
|
205
|
+
const sourceLeading = [];
|
|
206
|
+
const sourceTrailing = [];
|
|
207
|
+
for (let i = 0; i < children.length; i++) {
|
|
208
|
+
const child = children[i];
|
|
209
|
+
if (child.tagName === 'w:bookmarkStart') {
|
|
210
|
+
if (firstRunIdx < 0 || i < firstRunIdx) {
|
|
211
|
+
const cloned = child.cloneNode(true);
|
|
212
|
+
leading.push(cloned);
|
|
213
|
+
sourceLeading.push(child);
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (child.tagName === 'w:bookmarkEnd') {
|
|
218
|
+
if (lastRunIdx < 0 || i > lastRunIdx) {
|
|
219
|
+
const cloned = child.cloneNode(true);
|
|
220
|
+
trailing.push(cloned);
|
|
221
|
+
sourceTrailing.push(child);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { leading, trailing, sourceLeading, sourceTrailing };
|
|
226
|
+
}
|
|
227
|
+
function insertLeadingMarkers(paragraph, markers) {
|
|
228
|
+
if (markers.length === 0)
|
|
229
|
+
return null;
|
|
230
|
+
const pPr = findChildByTagName(paragraph, 'w:pPr');
|
|
231
|
+
if (pPr) {
|
|
232
|
+
let anchor = pPr;
|
|
233
|
+
for (const marker of markers) {
|
|
234
|
+
insertAfterElement(anchor, marker);
|
|
235
|
+
anchor = marker;
|
|
236
|
+
}
|
|
237
|
+
return anchor;
|
|
238
|
+
}
|
|
239
|
+
for (let i = markers.length - 1; i >= 0; i--) {
|
|
240
|
+
paragraph.insertBefore(markers[i], paragraph.firstChild);
|
|
241
|
+
}
|
|
242
|
+
return markers[markers.length - 1] ?? null;
|
|
243
|
+
}
|
|
244
|
+
function isBookmarkMarkerTag(tagName) {
|
|
245
|
+
return tagName === 'w:bookmarkStart' || tagName === 'w:bookmarkEnd';
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Collect direct paragraph bookmark markers adjacent to a source run.
|
|
249
|
+
*
|
|
250
|
+
* Markers between runs (or at paragraph boundaries) are represented as siblings
|
|
251
|
+
* of w:r under w:p. We clone nearby markers so reconstructed deleted/moveFrom
|
|
252
|
+
* fragments preserve bookmark names/IDs needed for Reject All parity.
|
|
253
|
+
*/
|
|
254
|
+
function collectAdjacentSourceBookmarkMarkers(sourceRun) {
|
|
255
|
+
const paragraph = sourceRun.parentNode;
|
|
256
|
+
if (!paragraph || paragraph.tagName !== 'w:p') {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
const children = childElements(paragraph);
|
|
260
|
+
const runIndex = children.indexOf(sourceRun);
|
|
261
|
+
if (runIndex < 0) {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
const before = [];
|
|
265
|
+
for (let i = runIndex - 1; i >= 0; i--) {
|
|
266
|
+
const child = children[i];
|
|
267
|
+
if (!child)
|
|
268
|
+
break;
|
|
269
|
+
if (child.tagName === 'w:r')
|
|
270
|
+
break;
|
|
271
|
+
if (isBookmarkMarkerTag(child.tagName)) {
|
|
272
|
+
before.unshift(child);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const after = [];
|
|
276
|
+
for (let i = runIndex + 1; i < children.length; i++) {
|
|
277
|
+
const child = children[i];
|
|
278
|
+
if (!child)
|
|
279
|
+
break;
|
|
280
|
+
if (child.tagName === 'w:r')
|
|
281
|
+
break;
|
|
282
|
+
if (isBookmarkMarkerTag(child.tagName)) {
|
|
283
|
+
after.push(child);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return [...before, ...after];
|
|
287
|
+
}
|
|
288
|
+
function parentElement(node) {
|
|
289
|
+
const p = node.parentNode;
|
|
290
|
+
return p && p.nodeType === 1 ? p : null;
|
|
291
|
+
}
|
|
292
|
+
function findTreeRoot(node) {
|
|
293
|
+
let current = node;
|
|
294
|
+
let parent = parentElement(current);
|
|
295
|
+
while (parent) {
|
|
296
|
+
current = parent;
|
|
297
|
+
parent = parentElement(current);
|
|
298
|
+
}
|
|
299
|
+
return current;
|
|
300
|
+
}
|
|
301
|
+
function findAncestor(node, tagName) {
|
|
302
|
+
let current = node ?? null;
|
|
303
|
+
while (current) {
|
|
304
|
+
if (current.tagName === tagName)
|
|
305
|
+
return current;
|
|
306
|
+
current = parentElement(current);
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
function hasAncestorTag(node, tagNames) {
|
|
311
|
+
let current = node ? parentElement(node) : null;
|
|
312
|
+
while (current) {
|
|
313
|
+
if (tagNames.has(current.tagName)) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
current = parentElement(current);
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
function paragraphHasParaInsMarker(paragraph) {
|
|
321
|
+
if (!paragraph || paragraph.tagName !== 'w:p') {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
const pPr = findChildByTagName(paragraph, 'w:pPr');
|
|
325
|
+
if (!pPr) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
return Array.from(pPr.getElementsByTagName('w:ins')).length > 0;
|
|
329
|
+
}
|
|
330
|
+
function markerSurvivesReject(marker, context) {
|
|
331
|
+
// Markers nested in inserted/move-to content are removed by Reject All.
|
|
332
|
+
if (hasAncestorTag(marker, new Set(['w:ins', 'w:moveTo']))) {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
// Paragraph-level insertion markers remove whole paragraphs on Reject All.
|
|
336
|
+
const paragraph = findAncestor(marker, 'w:p');
|
|
337
|
+
if (paragraph && context?.isParagraphRemovedOnReject?.(paragraph)) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (paragraphHasParaInsMarker(paragraph)) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
function targetTreeHasEquivalentBookmarkMarker(targetParagraph, marker, context) {
|
|
346
|
+
const treeRoot = findTreeRoot(targetParagraph);
|
|
347
|
+
if (marker.tagName === 'w:bookmarkStart') {
|
|
348
|
+
const markerId = marker.getAttribute('w:id');
|
|
349
|
+
const markerName = marker.getAttribute('w:name');
|
|
350
|
+
for (const existing of Array.from(treeRoot.getElementsByTagName('w:bookmarkStart'))) {
|
|
351
|
+
if (!markerSurvivesReject(existing, context)) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const existingName = existing.getAttribute('w:name');
|
|
355
|
+
const existingId = existing.getAttribute('w:id');
|
|
356
|
+
if (markerName && existingName === markerName)
|
|
357
|
+
return true;
|
|
358
|
+
if (!markerName && markerId && existingId === markerId)
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
if (marker.tagName === 'w:bookmarkEnd') {
|
|
364
|
+
const markerId = marker.getAttribute('w:id');
|
|
365
|
+
if (!markerId)
|
|
366
|
+
return false;
|
|
367
|
+
for (const existing of Array.from(treeRoot.getElementsByTagName('w:bookmarkEnd'))) {
|
|
368
|
+
if (!markerSurvivesReject(existing, context)) {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (existing.getAttribute('w:id') === markerId)
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
function cloneUnemittedSourceBookmarkMarkers(sourceRun, targetParagraph, state, context) {
|
|
378
|
+
const markers = collectAdjacentSourceBookmarkMarkers(sourceRun);
|
|
379
|
+
const clones = [];
|
|
380
|
+
for (const marker of markers) {
|
|
381
|
+
if (state.emittedSourceBookmarkMarkers.has(marker)) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (targetTreeHasEquivalentBookmarkMarker(targetParagraph, marker, context)) {
|
|
385
|
+
state.emittedSourceBookmarkMarkers.add(marker);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
state.emittedSourceBookmarkMarkers.add(marker);
|
|
389
|
+
const cloned = marker.cloneNode(true);
|
|
390
|
+
clones.push(cloned);
|
|
391
|
+
}
|
|
392
|
+
return clones;
|
|
393
|
+
}
|
|
394
|
+
function prependMarkersToWrapper(wrapper, markers) {
|
|
395
|
+
for (let i = markers.length - 1; i >= 0; i--) {
|
|
396
|
+
const marker = markers[i];
|
|
397
|
+
if (!marker)
|
|
398
|
+
continue;
|
|
399
|
+
wrapper.insertBefore(marker, wrapper.firstChild);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function filterEquivalentBookmarkMarkers(markers, targetNode, context) {
|
|
403
|
+
return markers.filter((marker) => !targetTreeHasEquivalentBookmarkMarker(targetNode, marker, context));
|
|
404
|
+
}
|
|
405
|
+
const TRACK_CHANGE_WRAPPERS = new Set([
|
|
406
|
+
'w:ins',
|
|
407
|
+
'w:del',
|
|
408
|
+
'w:moveFrom',
|
|
409
|
+
'w:moveTo',
|
|
410
|
+
]);
|
|
411
|
+
/**
|
|
412
|
+
* Resolve the run associated with an atom boundary.
|
|
413
|
+
*
|
|
414
|
+
* For collapsed field atoms, sourceRunElement points at the first run in the
|
|
415
|
+
* field sequence. For insertion-point tracking we often need the trailing run,
|
|
416
|
+
* otherwise deleted/moved fragments can be inserted inside the field sequence.
|
|
417
|
+
*/
|
|
418
|
+
function getAtomRunAtBoundary(atom, boundary) {
|
|
419
|
+
if (atom.collapsedFieldAtoms && atom.collapsedFieldAtoms.length > 0) {
|
|
420
|
+
const fieldAtoms = boundary === 'start'
|
|
421
|
+
? atom.collapsedFieldAtoms
|
|
422
|
+
: [...atom.collapsedFieldAtoms].reverse();
|
|
423
|
+
for (const fieldAtom of fieldAtoms) {
|
|
424
|
+
const run = fieldAtom.sourceRunElement ?? findAncestorByTag(fieldAtom, 'w:r');
|
|
425
|
+
if (run)
|
|
426
|
+
return run;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return atom.sourceRunElement ?? findAncestorByTag(atom, 'w:r');
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Resolve all run elements represented by an atom.
|
|
433
|
+
*
|
|
434
|
+
* For collapsed-field atoms, we must treat the entire field run sequence as a
|
|
435
|
+
* single logical unit. Wrapping only the first run leaves trailing field-code
|
|
436
|
+
* runs untracked, which can leak revised field text after Reject All.
|
|
437
|
+
*/
|
|
438
|
+
function getAtomRuns(atom) {
|
|
439
|
+
if (!atom.collapsedFieldAtoms || atom.collapsedFieldAtoms.length === 0) {
|
|
440
|
+
const run = atom.sourceRunElement ?? findAncestorByTag(atom, 'w:r');
|
|
441
|
+
return run ? [run] : [];
|
|
442
|
+
}
|
|
443
|
+
const runs = [];
|
|
444
|
+
const seen = new Set();
|
|
445
|
+
for (const fieldAtom of atom.collapsedFieldAtoms) {
|
|
446
|
+
const run = fieldAtom.sourceRunElement ?? findAncestorByTag(fieldAtom, 'w:r');
|
|
447
|
+
if (!run || seen.has(run))
|
|
448
|
+
continue;
|
|
449
|
+
seen.add(run);
|
|
450
|
+
runs.push(run);
|
|
451
|
+
}
|
|
452
|
+
return runs;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Convert a run node to the correct insertion anchor.
|
|
456
|
+
*
|
|
457
|
+
* If the run is wrapped in a track-change container, the insertion anchor
|
|
458
|
+
* must be the wrapper (a paragraph child), not the nested run.
|
|
459
|
+
*/
|
|
460
|
+
function getRunInsertionAnchor(run) {
|
|
461
|
+
const parent = parentElement(run);
|
|
462
|
+
if (parent && TRACK_CHANGE_WRAPPERS.has(parent.tagName)) {
|
|
463
|
+
return parent;
|
|
464
|
+
}
|
|
465
|
+
return run;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Wrap a run element with track change markup.
|
|
469
|
+
*
|
|
470
|
+
* This is the shared implementation for wrapAsInserted, wrapAsDeleted,
|
|
471
|
+
* and the inner wrapping logic of move operations.
|
|
472
|
+
*
|
|
473
|
+
* @param options - Wrapping options
|
|
474
|
+
* @returns true if wrapped, false if run was already wrapped or has no parent
|
|
475
|
+
*/
|
|
476
|
+
function wrapRunWithTrackChange(options) {
|
|
477
|
+
const { run, tagName, author, dateStr, state, convertTextToDelText: convertText = false } = options;
|
|
478
|
+
// Skip if already wrapped
|
|
479
|
+
if (state.wrappedRuns.has(run)) {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
// Skip if the run has no parent in the tree
|
|
483
|
+
if (!run.parentNode) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
// Convert w:t to w:delText if requested (for deleted content)
|
|
487
|
+
if (convertText) {
|
|
488
|
+
convertToDelText(run);
|
|
489
|
+
}
|
|
490
|
+
const id = allocateRevisionId(state);
|
|
491
|
+
const wrapper = createEl(tagName, {
|
|
492
|
+
'w:id': String(id),
|
|
493
|
+
'w:author': author,
|
|
494
|
+
'w:date': dateStr,
|
|
495
|
+
});
|
|
496
|
+
wrapElement(run, wrapper);
|
|
497
|
+
state.wrappedRuns.add(run);
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Ensure w:pPr/w:rPr exists and add a paragraph-mark revision marker (w:ins/w:del)
|
|
502
|
+
* in the paragraph properties.
|
|
503
|
+
*
|
|
504
|
+
* This is the critical piece for whole-paragraph insert/delete idempotency:
|
|
505
|
+
* - Reject All should remove inserted paragraphs entirely (no stub breaks)
|
|
506
|
+
* - Accept All should remove deleted paragraphs entirely
|
|
507
|
+
*/
|
|
508
|
+
function addParagraphMarkRevisionMarker(paragraph, markerTag, author, dateStr, state) {
|
|
509
|
+
// Find or create pPr.
|
|
510
|
+
let pPr = findChildByTagName(paragraph, 'w:pPr');
|
|
511
|
+
if (!pPr) {
|
|
512
|
+
pPr = createEl('w:pPr');
|
|
513
|
+
// pPr should be the first child in a paragraph.
|
|
514
|
+
paragraph.insertBefore(pPr, paragraph.firstChild);
|
|
515
|
+
}
|
|
516
|
+
// Find or create rPr within pPr (paragraph mark properties).
|
|
517
|
+
let rPr = findChildByTagName(pPr, 'w:rPr');
|
|
518
|
+
if (!rPr) {
|
|
519
|
+
rPr = createEl('w:rPr');
|
|
520
|
+
// Keep existing pPr children order stable; rPr commonly appears after spacing/jc.
|
|
521
|
+
pPr.appendChild(rPr);
|
|
522
|
+
}
|
|
523
|
+
// Avoid duplicating markers.
|
|
524
|
+
if (findChildByTagName(rPr, markerTag))
|
|
525
|
+
return;
|
|
526
|
+
const id = allocateRevisionId(state);
|
|
527
|
+
const marker = createEl(markerTag, {
|
|
528
|
+
'w:id': String(id),
|
|
529
|
+
'w:author': author,
|
|
530
|
+
'w:date': dateStr,
|
|
531
|
+
});
|
|
532
|
+
// Insert marker at the start of rPr for consistency with Aspose/Word patterns.
|
|
533
|
+
rPr.insertBefore(marker, rPr.firstChild);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Wrap a run element with <w:ins> to mark it as inserted.
|
|
537
|
+
*
|
|
538
|
+
* @param run - The w:r element to wrap
|
|
539
|
+
* @param author - Author name for track changes
|
|
540
|
+
* @param dateStr - Formatted date string
|
|
541
|
+
* @param state - Revision ID state
|
|
542
|
+
* @returns true if wrapped, false if run was already wrapped or has no parent
|
|
543
|
+
*/
|
|
544
|
+
export function wrapAsInserted(run, author, dateStr, state) {
|
|
545
|
+
return wrapRunWithTrackChange({
|
|
546
|
+
run,
|
|
547
|
+
tagName: 'w:ins',
|
|
548
|
+
author,
|
|
549
|
+
dateStr,
|
|
550
|
+
state,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Wrap a run element with <w:del> to mark it as deleted.
|
|
555
|
+
* Also converts w:t to w:delText within the run.
|
|
556
|
+
*
|
|
557
|
+
* @param run - The w:r element to wrap
|
|
558
|
+
* @param author - Author name for track changes
|
|
559
|
+
* @param dateStr - Formatted date string
|
|
560
|
+
* @param state - Revision ID state
|
|
561
|
+
* @returns true if wrapped, false if run was already wrapped or has no parent
|
|
562
|
+
*/
|
|
563
|
+
export function wrapAsDeleted(run, author, dateStr, state) {
|
|
564
|
+
return wrapRunWithTrackChange({
|
|
565
|
+
run,
|
|
566
|
+
tagName: 'w:del',
|
|
567
|
+
author,
|
|
568
|
+
dateStr,
|
|
569
|
+
state,
|
|
570
|
+
convertTextToDelText: true,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Clone a deleted run from the original document and insert it into the revised document.
|
|
575
|
+
*
|
|
576
|
+
* @param deletedAtom - Atom with the deleted content
|
|
577
|
+
* @param insertAfterRun - The run to insert after (null to insert at beginning of paragraph)
|
|
578
|
+
* @param targetParagraph - The paragraph to insert into
|
|
579
|
+
* @param author - Author name
|
|
580
|
+
* @param dateStr - Formatted date
|
|
581
|
+
* @param state - Revision ID state
|
|
582
|
+
* @returns The inserted del element, or null if insertion failed
|
|
583
|
+
*/
|
|
584
|
+
export function insertDeletedRun(deletedAtom, insertAfterRun, targetParagraph, author, dateStr, state, context) {
|
|
585
|
+
// Get the source run element from the deleted atom
|
|
586
|
+
const sourceRun = deletedAtom.sourceRunElement;
|
|
587
|
+
if (!sourceRun) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
// Clone only the atom fragment while preserving run-level formatting.
|
|
591
|
+
// For collapsed fields, replay the original field sequence rather than
|
|
592
|
+
// the synthetic collapsed w:t placeholder.
|
|
593
|
+
const clonedRun = cloneRunWithAtomContent(sourceRun, deletedAtom);
|
|
594
|
+
// Convert w:t to w:delText
|
|
595
|
+
convertToDelText(clonedRun);
|
|
596
|
+
// Create w:del wrapper
|
|
597
|
+
const id = allocateRevisionId(state);
|
|
598
|
+
const del = createEl('w:del', {
|
|
599
|
+
'w:id': String(id),
|
|
600
|
+
'w:author': author,
|
|
601
|
+
'w:date': dateStr,
|
|
602
|
+
});
|
|
603
|
+
// Add cloned run as child of del
|
|
604
|
+
del.appendChild(clonedRun);
|
|
605
|
+
// Insert at correct position
|
|
606
|
+
if (insertAfterRun) {
|
|
607
|
+
insertAfterElement(insertAfterRun, del);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
// Insert at the beginning of the paragraph (after pPr if present)
|
|
611
|
+
const pPr = findChildByTagName(targetParagraph, 'w:pPr');
|
|
612
|
+
if (pPr) {
|
|
613
|
+
insertAfterElement(pPr, del);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
targetParagraph.insertBefore(del, targetParagraph.firstChild);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const sourceMarkers = cloneUnemittedSourceBookmarkMarkers(sourceRun, targetParagraph, state, context);
|
|
620
|
+
if (sourceMarkers.length > 0)
|
|
621
|
+
prependMarkersToWrapper(del, sourceMarkers);
|
|
622
|
+
return del;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Clone a moved-from run from the original document and insert it into the revised document.
|
|
626
|
+
*
|
|
627
|
+
* MovedSource atoms have their sourceRunElement in the ORIGINAL tree, but we need to
|
|
628
|
+
* insert the content into the REVISED tree. This function clones the run, wraps it with
|
|
629
|
+
* <w:moveFrom> and range markers, and inserts at the correct position.
|
|
630
|
+
*
|
|
631
|
+
* @param atom - Atom with the moved-from content
|
|
632
|
+
* @param moveName - Name for linking source and destination
|
|
633
|
+
* @param insertAfterRun - The run to insert after (null to insert at beginning of paragraph)
|
|
634
|
+
* @param targetParagraph - The paragraph to insert into
|
|
635
|
+
* @param author - Author name
|
|
636
|
+
* @param dateStr - Formatted date
|
|
637
|
+
* @param state - Revision ID state
|
|
638
|
+
* @returns The inserted moveFrom element, or null if insertion failed
|
|
639
|
+
*/
|
|
640
|
+
export function insertMoveFromRun(atom, moveName, insertAfterRun, targetParagraph, author, dateStr, state, context) {
|
|
641
|
+
// Get the source run element from the atom (in original tree)
|
|
642
|
+
const sourceRun = atom.sourceRunElement;
|
|
643
|
+
if (!sourceRun) {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
// Clone only the atom fragment while preserving run-level formatting.
|
|
647
|
+
// For collapsed fields, replay the original field sequence rather than
|
|
648
|
+
// the synthetic collapsed w:t placeholder.
|
|
649
|
+
const clonedRun = cloneRunWithAtomContent(sourceRun, atom);
|
|
650
|
+
// Convert w:t to w:delText (moved-from content appears as deleted)
|
|
651
|
+
convertToDelText(clonedRun);
|
|
652
|
+
// Get or allocate move range IDs
|
|
653
|
+
const ids = getMoveRangeIds(state, moveName);
|
|
654
|
+
const moveId = allocateRevisionId(state);
|
|
655
|
+
// Create range start marker
|
|
656
|
+
const rangeStart = createEl('w:moveFromRangeStart', {
|
|
657
|
+
'w:id': String(ids.sourceRangeId),
|
|
658
|
+
'w:name': moveName,
|
|
659
|
+
'w:author': author,
|
|
660
|
+
'w:date': dateStr,
|
|
661
|
+
});
|
|
662
|
+
// Create moveFrom wrapper
|
|
663
|
+
const moveFrom = createEl('w:moveFrom', {
|
|
664
|
+
'w:id': String(moveId),
|
|
665
|
+
'w:author': author,
|
|
666
|
+
'w:date': dateStr,
|
|
667
|
+
});
|
|
668
|
+
// Create range end marker
|
|
669
|
+
const rangeEnd = createEl('w:moveFromRangeEnd', {
|
|
670
|
+
'w:id': String(ids.sourceRangeId),
|
|
671
|
+
});
|
|
672
|
+
// Add cloned run as child of moveFrom
|
|
673
|
+
moveFrom.appendChild(clonedRun);
|
|
674
|
+
// Insert at correct position: rangeStart -> moveFrom(run) -> rangeEnd
|
|
675
|
+
if (insertAfterRun) {
|
|
676
|
+
insertAfterElement(insertAfterRun, rangeStart);
|
|
677
|
+
insertAfterElement(rangeStart, moveFrom);
|
|
678
|
+
insertAfterElement(moveFrom, rangeEnd);
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
// Insert at the beginning of the paragraph (after pPr if present)
|
|
682
|
+
const pPr = findChildByTagName(targetParagraph, 'w:pPr');
|
|
683
|
+
if (pPr) {
|
|
684
|
+
insertAfterElement(pPr, rangeStart);
|
|
685
|
+
insertAfterElement(rangeStart, moveFrom);
|
|
686
|
+
insertAfterElement(moveFrom, rangeEnd);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
targetParagraph.insertBefore(rangeEnd, targetParagraph.firstChild);
|
|
690
|
+
targetParagraph.insertBefore(moveFrom, rangeEnd);
|
|
691
|
+
targetParagraph.insertBefore(rangeStart, moveFrom);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const sourceMarkers = cloneUnemittedSourceBookmarkMarkers(sourceRun, targetParagraph, state, context);
|
|
695
|
+
if (sourceMarkers.length > 0)
|
|
696
|
+
prependMarkersToWrapper(moveFrom, sourceMarkers);
|
|
697
|
+
return moveFrom;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Clone a deleted paragraph from the original document and insert it.
|
|
701
|
+
*
|
|
702
|
+
* @param deletedAtom - Atom representing the deleted paragraph
|
|
703
|
+
* @param insertAfterParagraph - Paragraph to insert after (null to insert at body start)
|
|
704
|
+
* @param targetBody - The body element to insert into
|
|
705
|
+
* @param author - Author name
|
|
706
|
+
* @param dateStr - Formatted date
|
|
707
|
+
* @param state - Revision ID state
|
|
708
|
+
* @returns The inserted paragraph, or null if insertion failed
|
|
709
|
+
*/
|
|
710
|
+
export function insertDeletedParagraph(deletedAtom, insertAfterParagraph, targetBody, author, dateStr, state) {
|
|
711
|
+
// Get the source paragraph from the deleted atom
|
|
712
|
+
const sourceParagraph = deletedAtom.sourceParagraphElement;
|
|
713
|
+
if (!sourceParagraph) {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
// Clone the paragraph
|
|
717
|
+
const clonedParagraph = sourceParagraph.cloneNode(true);
|
|
718
|
+
// Wrap runs with w:del (wrapAsDeleted handles w:t -> w:delText conversion internally)
|
|
719
|
+
const runs = Array.from(clonedParagraph.getElementsByTagName('w:r'));
|
|
720
|
+
for (const run of runs) {
|
|
721
|
+
wrapAsDeleted(run, author, dateStr, state);
|
|
722
|
+
}
|
|
723
|
+
// Insert at correct position
|
|
724
|
+
if (insertAfterParagraph) {
|
|
725
|
+
insertAfterElement(insertAfterParagraph, clonedParagraph);
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
targetBody.insertBefore(clonedParagraph, targetBody.firstChild);
|
|
729
|
+
}
|
|
730
|
+
return clonedParagraph;
|
|
731
|
+
}
|
|
732
|
+
const MOVE_CONFIG = {
|
|
733
|
+
from: {
|
|
734
|
+
wrapperTag: 'w:moveFrom',
|
|
735
|
+
rangeStartTag: 'w:moveFromRangeStart',
|
|
736
|
+
rangeEndTag: 'w:moveFromRangeEnd',
|
|
737
|
+
rangeIdKey: 'sourceRangeId',
|
|
738
|
+
convertTextToDelText: true, // Moved-from content appears as deleted
|
|
739
|
+
},
|
|
740
|
+
to: {
|
|
741
|
+
wrapperTag: 'w:moveTo',
|
|
742
|
+
rangeStartTag: 'w:moveToRangeStart',
|
|
743
|
+
rangeEndTag: 'w:moveToRangeEnd',
|
|
744
|
+
rangeIdKey: 'destRangeId',
|
|
745
|
+
convertTextToDelText: false, // Moved-to content keeps w:t
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
/**
|
|
749
|
+
* Wrap a run element with move tracking (shared implementation for moveFrom/moveTo).
|
|
750
|
+
*
|
|
751
|
+
* @param run - The w:r element to wrap
|
|
752
|
+
* @param moveName - Name for linking source and destination
|
|
753
|
+
* @param direction - 'from' for moveFrom, 'to' for moveTo
|
|
754
|
+
* @param author - Author name
|
|
755
|
+
* @param dateStr - Formatted date
|
|
756
|
+
* @param state - Revision ID state
|
|
757
|
+
* @returns true if wrapped
|
|
758
|
+
*/
|
|
759
|
+
function wrapAsMove(run, moveName, direction, author, dateStr, state) {
|
|
760
|
+
if (state.wrappedRuns.has(run)) {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
const parent = parentElement(run);
|
|
764
|
+
if (!parent) {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
const config = MOVE_CONFIG[direction];
|
|
768
|
+
const ids = getMoveRangeIds(state, moveName);
|
|
769
|
+
const moveId = allocateRevisionId(state);
|
|
770
|
+
const rangeId = ids[config.rangeIdKey];
|
|
771
|
+
// Convert w:t to w:delText if needed (for moveFrom content)
|
|
772
|
+
if (config.convertTextToDelText) {
|
|
773
|
+
convertToDelText(run);
|
|
774
|
+
}
|
|
775
|
+
// Create range start marker
|
|
776
|
+
const rangeStart = createEl(config.rangeStartTag, {
|
|
777
|
+
'w:id': String(rangeId),
|
|
778
|
+
'w:name': moveName,
|
|
779
|
+
'w:author': author,
|
|
780
|
+
'w:date': dateStr,
|
|
781
|
+
});
|
|
782
|
+
// Create move wrapper
|
|
783
|
+
const moveWrapper = createEl(config.wrapperTag, {
|
|
784
|
+
'w:id': String(moveId),
|
|
785
|
+
'w:author': author,
|
|
786
|
+
'w:date': dateStr,
|
|
787
|
+
});
|
|
788
|
+
// Create range end marker
|
|
789
|
+
const rangeEnd = createEl(config.rangeEndTag, {
|
|
790
|
+
'w:id': String(rangeId),
|
|
791
|
+
});
|
|
792
|
+
// Insert: rangeStart -> moveWrapper(run) -> rangeEnd
|
|
793
|
+
run.parentNode.insertBefore(rangeStart, run);
|
|
794
|
+
wrapElement(run, moveWrapper);
|
|
795
|
+
insertAfterElement(moveWrapper, rangeEnd);
|
|
796
|
+
state.wrappedRuns.add(run);
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Wrap a run element with <w:moveFrom> for moved-from content.
|
|
801
|
+
*
|
|
802
|
+
* @param run - The w:r element to wrap
|
|
803
|
+
* @param moveName - Name for linking source and destination
|
|
804
|
+
* @param author - Author name
|
|
805
|
+
* @param dateStr - Formatted date
|
|
806
|
+
* @param state - Revision ID state
|
|
807
|
+
* @returns true if wrapped
|
|
808
|
+
*/
|
|
809
|
+
export function wrapAsMoveFrom(run, moveName, author, dateStr, state) {
|
|
810
|
+
return wrapAsMove(run, moveName, 'from', author, dateStr, state);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Wrap a run element with <w:moveTo> for moved-to content.
|
|
814
|
+
*
|
|
815
|
+
* @param run - The w:r element to wrap
|
|
816
|
+
* @param moveName - Name for linking source and destination
|
|
817
|
+
* @param author - Author name
|
|
818
|
+
* @param dateStr - Formatted date
|
|
819
|
+
* @param state - Revision ID state
|
|
820
|
+
* @returns true if wrapped
|
|
821
|
+
*/
|
|
822
|
+
export function wrapAsMoveTo(run, moveName, author, dateStr, state) {
|
|
823
|
+
return wrapAsMove(run, moveName, 'to', author, dateStr, state);
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Add format change tracking to a run's properties.
|
|
827
|
+
*
|
|
828
|
+
* @param run - The w:r element with changed formatting
|
|
829
|
+
* @param oldRunProperties - The original run properties (w:rPr)
|
|
830
|
+
* @param author - Author name
|
|
831
|
+
* @param dateStr - Formatted date
|
|
832
|
+
* @param state - Revision ID state
|
|
833
|
+
*/
|
|
834
|
+
export function addFormatChange(run, oldRunProperties, author, dateStr, state) {
|
|
835
|
+
// Find or create w:rPr
|
|
836
|
+
let rPr = findChildByTagName(run, 'w:rPr');
|
|
837
|
+
if (!rPr) {
|
|
838
|
+
rPr = createEl('w:rPr');
|
|
839
|
+
// Insert rPr at the beginning of run's children
|
|
840
|
+
run.insertBefore(rPr, run.firstChild);
|
|
841
|
+
}
|
|
842
|
+
// Create rPrChange
|
|
843
|
+
const id = allocateRevisionId(state);
|
|
844
|
+
const rPrChange = createEl('w:rPrChange', {
|
|
845
|
+
'w:id': String(id),
|
|
846
|
+
'w:author': author,
|
|
847
|
+
'w:date': dateStr,
|
|
848
|
+
});
|
|
849
|
+
// Clone old properties as children of rPrChange
|
|
850
|
+
if (oldRunProperties) {
|
|
851
|
+
for (const child of childElements(oldRunProperties)) {
|
|
852
|
+
const cloned = child.cloneNode(true);
|
|
853
|
+
rPrChange.appendChild(cloned);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// Add rPrChange to rPr
|
|
857
|
+
rPr.appendChild(rPrChange);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Wrap an inserted empty paragraph with <w:ins>.
|
|
861
|
+
*
|
|
862
|
+
* For empty paragraphs (no content, only pPr), we wrap the entire paragraph.
|
|
863
|
+
*
|
|
864
|
+
* @param paragraph - The w:p element
|
|
865
|
+
* @param author - Author name
|
|
866
|
+
* @param dateStr - Formatted date
|
|
867
|
+
* @param state - Revision ID state
|
|
868
|
+
*/
|
|
869
|
+
export function wrapParagraphAsInserted(paragraph, author, dateStr, state) {
|
|
870
|
+
// IMPORTANT: <w:ins> is not a container for <w:p> in WordprocessingML.
|
|
871
|
+
// For paragraph insertions (including empty paragraphs), we encode a paragraph-mark
|
|
872
|
+
// revision marker in w:pPr/w:rPr instead of wrapping the paragraph element.
|
|
873
|
+
addParagraphMarkRevisionMarker(paragraph, 'w:ins', author, dateStr, state);
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Wrap a deleted empty paragraph with <w:del>.
|
|
878
|
+
*
|
|
879
|
+
* @param paragraph - The w:p element
|
|
880
|
+
* @param author - Author name
|
|
881
|
+
* @param dateStr - Formatted date
|
|
882
|
+
* @param state - Revision ID state
|
|
883
|
+
*/
|
|
884
|
+
export function wrapParagraphAsDeleted(paragraph, author, dateStr, state) {
|
|
885
|
+
// See wrapParagraphAsInserted: represent paragraph deletion via a paragraph-mark
|
|
886
|
+
// revision marker in w:pPr/w:rPr so Accept/Reject All behaves correctly.
|
|
887
|
+
addParagraphMarkRevisionMarker(paragraph, 'w:del', author, dateStr, state);
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
890
|
+
// Field-character tag names that should not be split.
|
|
891
|
+
const FIELD_CHAR_TAG_NAMES = new Set([
|
|
892
|
+
'w:fldChar', 'w:instrText', 'w:delInstrText',
|
|
893
|
+
]);
|
|
894
|
+
/**
|
|
895
|
+
* Visible text length for an atom's contentElement.
|
|
896
|
+
*
|
|
897
|
+
* Atom contentElements created by the word-split atomizer use prefixed tagNames
|
|
898
|
+
* (e.g. `"w:t"`) without namespace URIs, so we match on `tagName` rather than
|
|
899
|
+
* `localName`/`namespaceURI` (which is what the DOM-level `visibleLengthForEl` does).
|
|
900
|
+
*/
|
|
901
|
+
function atomContentVisibleLength(el) {
|
|
902
|
+
const tag = el.tagName;
|
|
903
|
+
if (tag === 'w:t')
|
|
904
|
+
return (el.textContent ?? '').length;
|
|
905
|
+
if (tag === 'w:tab' || tag === 'w:br')
|
|
906
|
+
return 1;
|
|
907
|
+
// w:cr is treated as zero-length (consistent with visibleLengthForEl which also returns 0).
|
|
908
|
+
return 0;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Pre-split revised-tree runs that contain atoms with mixed correlation statuses.
|
|
912
|
+
*
|
|
913
|
+
* Without this, `handleInserted` wraps the entire run with `<w:ins>`, destroying
|
|
914
|
+
* Equal content in the same run. After splitting, each fragment is a separate
|
|
915
|
+
* `<w:r>` and existing per-status handlers work without modification.
|
|
916
|
+
*
|
|
917
|
+
* Safety: wrapped in try/catch per run group. If any DOM operation fails, the
|
|
918
|
+
* run is skipped and the existing fallback-to-rebuild architecture handles it.
|
|
919
|
+
*/
|
|
920
|
+
export function preSplitMixedStatusRuns(mergedAtoms) {
|
|
921
|
+
// Group atoms by their sourceRunElement (revised-tree runs only).
|
|
922
|
+
const runGroups = new Map();
|
|
923
|
+
for (const atom of mergedAtoms) {
|
|
924
|
+
if (!atom.sourceRunElement)
|
|
925
|
+
continue;
|
|
926
|
+
// Skip original-tree atoms — Deleted/MovedSource runs are cloned, not wrapped.
|
|
927
|
+
if (atom.correlationStatus === CorrelationStatus.Deleted ||
|
|
928
|
+
atom.correlationStatus === CorrelationStatus.MovedSource)
|
|
929
|
+
continue;
|
|
930
|
+
// Skip collapsed field atoms (multi-run field sequences).
|
|
931
|
+
if (atom.collapsedFieldAtoms && atom.collapsedFieldAtoms.length > 0)
|
|
932
|
+
continue;
|
|
933
|
+
// Skip field character elements — semantically fragile.
|
|
934
|
+
if (FIELD_CHAR_TAG_NAMES.has(atom.contentElement.tagName))
|
|
935
|
+
continue;
|
|
936
|
+
const group = runGroups.get(atom.sourceRunElement);
|
|
937
|
+
if (group) {
|
|
938
|
+
group.push(atom);
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
runGroups.set(atom.sourceRunElement, [atom]);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
for (const [run, atoms] of runGroups) {
|
|
945
|
+
// Early check: skip single-status runs before any DOM work.
|
|
946
|
+
const statuses = new Set(atoms.map((a) => a.correlationStatus));
|
|
947
|
+
if (statuses.size <= 1)
|
|
948
|
+
continue;
|
|
949
|
+
// Guard: skip runs already detached from the tree.
|
|
950
|
+
if (!run.parentNode)
|
|
951
|
+
continue;
|
|
952
|
+
try {
|
|
953
|
+
// Compute the run's actual visible length via DOM traversal.
|
|
954
|
+
const contentEls = getDirectContentElements(run);
|
|
955
|
+
let runVisibleLength = 0;
|
|
956
|
+
for (const cel of contentEls) {
|
|
957
|
+
runVisibleLength += visibleLengthForEl(cel);
|
|
958
|
+
}
|
|
959
|
+
// Cross-run safety: if sum of atom lengths exceeds run visible length,
|
|
960
|
+
// this group contains a cross-run merged atom (passes 3/4). Skip it.
|
|
961
|
+
let sumAtomLengths = 0;
|
|
962
|
+
for (const atom of atoms) {
|
|
963
|
+
sumAtomLengths += atomContentVisibleLength(atom.contentElement);
|
|
964
|
+
}
|
|
965
|
+
if (sumAtomLengths > runVisibleLength)
|
|
966
|
+
continue;
|
|
967
|
+
const spans = [];
|
|
968
|
+
let offset = 0;
|
|
969
|
+
for (const atom of atoms) {
|
|
970
|
+
const len = atomContentVisibleLength(atom.contentElement);
|
|
971
|
+
const lastSpan = spans[spans.length - 1];
|
|
972
|
+
if (lastSpan && lastSpan.status === atom.correlationStatus) {
|
|
973
|
+
lastSpan.length += len;
|
|
974
|
+
lastSpan.atoms.push(atom);
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
spans.push({
|
|
978
|
+
status: atom.correlationStatus,
|
|
979
|
+
startOffset: offset,
|
|
980
|
+
length: len,
|
|
981
|
+
atoms: [atom],
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
offset += len;
|
|
985
|
+
}
|
|
986
|
+
// If only one span after grouping, no split needed.
|
|
987
|
+
if (spans.length <= 1)
|
|
988
|
+
continue;
|
|
989
|
+
// Collect split points: startOffset of each span after the first.
|
|
990
|
+
const splitPoints = [];
|
|
991
|
+
for (let i = 1; i < spans.length; i++) {
|
|
992
|
+
const pt = spans[i].startOffset;
|
|
993
|
+
// Filter out degenerate split points at boundaries.
|
|
994
|
+
if (pt > 0 && pt < runVisibleLength) {
|
|
995
|
+
splitPoints.push(pt);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (splitPoints.length === 0)
|
|
999
|
+
continue;
|
|
1000
|
+
// Split DOM run right-to-left to keep earlier offsets valid.
|
|
1001
|
+
const rightFragments = [];
|
|
1002
|
+
for (let i = splitPoints.length - 1; i >= 0; i--) {
|
|
1003
|
+
const { right } = splitRunAtVisibleOffset(run, splitPoints[i]);
|
|
1004
|
+
rightFragments.push(right);
|
|
1005
|
+
}
|
|
1006
|
+
// Map fragments: [originalRun (leftmost), ...reverse(rightFragments)]
|
|
1007
|
+
// After R-to-L splits, rightFragments are in reverse document order.
|
|
1008
|
+
const fragments = [run, ...rightFragments.reverse()];
|
|
1009
|
+
// Update atom sourceRunElement pointers to the correct fragment.
|
|
1010
|
+
// Each span maps to one fragment in order.
|
|
1011
|
+
for (let i = 0; i < spans.length; i++) {
|
|
1012
|
+
const fragment = fragments[i];
|
|
1013
|
+
if (!fragment)
|
|
1014
|
+
continue;
|
|
1015
|
+
for (const atom of spans[i].atoms) {
|
|
1016
|
+
atom.sourceRunElement = fragment;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
catch (_err) {
|
|
1021
|
+
// DOM operation failed — skip this run. The existing fallback-to-rebuild
|
|
1022
|
+
// architecture will handle it if the overall safety check fails.
|
|
1023
|
+
warn('preSplitMixedStatusRuns', `Skipping run split due to error: ${_err}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Modify the revised document's AST in-place based on comparison results.
|
|
1029
|
+
*
|
|
1030
|
+
* @param revisedRoot - Root element of the revised document
|
|
1031
|
+
* @param mergedAtoms - Atoms with correlation status from comparison
|
|
1032
|
+
* @param options - Modification options
|
|
1033
|
+
* @returns The modified XML string
|
|
1034
|
+
*/
|
|
1035
|
+
export function modifyRevisedDocument(revisedRoot, originalAtoms, revisedAtoms, mergedAtoms, options) {
|
|
1036
|
+
const { author, date } = options;
|
|
1037
|
+
const dateStr = formatDate(date);
|
|
1038
|
+
const state = createRevisionIdState();
|
|
1039
|
+
// In-place mode needs concrete AST node pointers for run/paragraph edits.
|
|
1040
|
+
// Populate these once up-front so handlers don't have to rescan ancestor chains.
|
|
1041
|
+
attachSourceElementPointers(originalAtoms);
|
|
1042
|
+
attachSourceElementPointers(revisedAtoms);
|
|
1043
|
+
preSplitMixedStatusRuns(mergedAtoms);
|
|
1044
|
+
// Process atoms and apply track changes to the revised tree
|
|
1045
|
+
// Group atoms by paragraph for efficient processing
|
|
1046
|
+
const ctx = processAtoms(mergedAtoms, originalAtoms, revisedAtoms, author, dateStr, state, revisedRoot);
|
|
1047
|
+
// Add paragraph-mark revision markers for whole-paragraph insert/delete cases.
|
|
1048
|
+
// This is required for idempotency in Word:
|
|
1049
|
+
// - Reject All should remove inserted paragraphs entirely
|
|
1050
|
+
// - Accept All should remove deleted paragraphs entirely
|
|
1051
|
+
applyWholeParagraphRevisionMarkers(mergedAtoms, ctx);
|
|
1052
|
+
// Merge adjacent <w:ins>/<w:del> siblings to reduce revision fragmentation.
|
|
1053
|
+
mergeAdjacentTrackChangeSiblings(ctx.body);
|
|
1054
|
+
// Serialize the modified tree
|
|
1055
|
+
return new XMLSerializer().serializeToString(revisedRoot.ownerDocument || revisedRoot);
|
|
1056
|
+
}
|
|
1057
|
+
function isParagraphRemovedOnRejectInContext(paragraph, ctx) {
|
|
1058
|
+
if (paragraphHasParaInsMarker(paragraph)) {
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
const unifiedIndex = ctx.revisedParagraphToUnifiedIndex.get(paragraph);
|
|
1062
|
+
return unifiedIndex !== undefined && ctx.fullyInsertedParagraphIndices.has(unifiedIndex);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Handle Inserted atoms - wrap the run with <w:ins>.
|
|
1066
|
+
* Inserted atoms have sourceRunElement in the REVISED tree.
|
|
1067
|
+
*/
|
|
1068
|
+
function handleInserted(atom, ctx) {
|
|
1069
|
+
const runs = getAtomRuns(atom);
|
|
1070
|
+
if (runs.length > 0) {
|
|
1071
|
+
for (const run of runs) {
|
|
1072
|
+
wrapAsInserted(run, ctx.author, ctx.dateStr, ctx.state);
|
|
1073
|
+
}
|
|
1074
|
+
const endRun = getAtomRunAtBoundary(atom, 'end') ?? runs[runs.length - 1];
|
|
1075
|
+
const insertionPoint = getRunInsertionAnchor(endRun);
|
|
1076
|
+
return {
|
|
1077
|
+
newLastRun: insertionPoint,
|
|
1078
|
+
newLastParagraph: atom.sourceParagraphElement ?? ctx.lastProcessedParagraph,
|
|
1079
|
+
newLastParagraphIndex: atom.paragraphIndex,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
else if (atom.isEmptyParagraph && atom.sourceParagraphElement) {
|
|
1083
|
+
// Empty inserted paragraph: mark paragraph properties instead of wrapping <w:p>.
|
|
1084
|
+
wrapParagraphAsInserted(atom.sourceParagraphElement, ctx.author, ctx.dateStr, ctx.state);
|
|
1085
|
+
return {
|
|
1086
|
+
newLastParagraph: atom.sourceParagraphElement,
|
|
1087
|
+
newLastParagraphIndex: atom.paragraphIndex,
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
return {};
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Handle Deleted atoms - clone from original and insert with <w:del>.
|
|
1094
|
+
* Deleted atoms have sourceRunElement in the ORIGINAL tree.
|
|
1095
|
+
* We need to clone and insert into the REVISED tree.
|
|
1096
|
+
*
|
|
1097
|
+
* Paragraph placement logic:
|
|
1098
|
+
* 1. If the atom's unified paragraph exists in the revised document, insert there
|
|
1099
|
+
* 2. If we've already created a paragraph for this unified index, use it
|
|
1100
|
+
* 3. Otherwise, create a new paragraph and insert it at the correct position
|
|
1101
|
+
*/
|
|
1102
|
+
function handleDeleted(atom, ctx) {
|
|
1103
|
+
const bookmarkSurvivalContext = {
|
|
1104
|
+
isParagraphRemovedOnReject: (paragraph) => isParagraphRemovedOnRejectInContext(paragraph, ctx),
|
|
1105
|
+
};
|
|
1106
|
+
// Handle empty deleted paragraphs specially
|
|
1107
|
+
if (atom.isEmptyParagraph && atom.sourceParagraphElement) {
|
|
1108
|
+
const createdPara = insertDeletedParagraph(atom, ctx.lastProcessedParagraph, ctx.body, ctx.author, ctx.dateStr, ctx.state);
|
|
1109
|
+
if (createdPara && atom.paragraphIndex !== undefined) {
|
|
1110
|
+
ctx.createdParagraphs.set(atom.paragraphIndex, createdPara);
|
|
1111
|
+
}
|
|
1112
|
+
if (createdPara) {
|
|
1113
|
+
wrapParagraphAsDeleted(createdPara, ctx.author, ctx.dateStr, ctx.state);
|
|
1114
|
+
}
|
|
1115
|
+
return {
|
|
1116
|
+
newLastParagraph: createdPara ?? ctx.lastProcessedParagraph,
|
|
1117
|
+
newLastParagraphIndex: atom.paragraphIndex,
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
if (atom.sourceRunElement) {
|
|
1121
|
+
const unifiedPara = atom.paragraphIndex;
|
|
1122
|
+
let targetParagraph;
|
|
1123
|
+
let insertAfterRun = null;
|
|
1124
|
+
// Determine target paragraph and insertion point
|
|
1125
|
+
if (unifiedPara !== undefined) {
|
|
1126
|
+
// Check if this unified paragraph exists in the revised document
|
|
1127
|
+
const revisedPara = ctx.unifiedParaToElement.get(unifiedPara);
|
|
1128
|
+
const revisedParagraphRemovedOnReject = revisedPara !== undefined &&
|
|
1129
|
+
(ctx.fullyInsertedParagraphIndices.has(unifiedPara) || paragraphHasParaInsMarker(revisedPara));
|
|
1130
|
+
if (revisedPara && !revisedParagraphRemovedOnReject) {
|
|
1131
|
+
// Paragraph exists in revised and survives Reject All - insert into it.
|
|
1132
|
+
targetParagraph = revisedPara;
|
|
1133
|
+
// If this is the same paragraph we last processed, use lastProcessedRun
|
|
1134
|
+
if (ctx.lastParagraphIndex === unifiedPara) {
|
|
1135
|
+
insertAfterRun = ctx.lastProcessedRun;
|
|
1136
|
+
}
|
|
1137
|
+
// Otherwise, insert at the beginning of the paragraph (insertAfterRun = null)
|
|
1138
|
+
}
|
|
1139
|
+
else {
|
|
1140
|
+
// Paragraph is absent in revised OR will be removed on Reject All.
|
|
1141
|
+
// Route deleted content into a created paragraph so reject output keeps
|
|
1142
|
+
// original-order text and bookmark markers.
|
|
1143
|
+
const createdPara = ctx.createdParagraphs.get(unifiedPara);
|
|
1144
|
+
if (createdPara) {
|
|
1145
|
+
targetParagraph = createdPara;
|
|
1146
|
+
insertAfterRun = ctx.createdParagraphLastRun.get(unifiedPara) ?? null;
|
|
1147
|
+
}
|
|
1148
|
+
else {
|
|
1149
|
+
// Need to create a new paragraph for this deleted content
|
|
1150
|
+
const newPara = createEl('w:p');
|
|
1151
|
+
const boundaryMarkers = cloneParagraphBoundaryBookmarkMarkers(atom.sourceParagraphElement);
|
|
1152
|
+
for (const marker of [...boundaryMarkers.sourceLeading, ...boundaryMarkers.sourceTrailing]) {
|
|
1153
|
+
ctx.state.emittedSourceBookmarkMarkers.add(marker);
|
|
1154
|
+
}
|
|
1155
|
+
const leadingMarkers = filterEquivalentBookmarkMarkers(boundaryMarkers.leading, ctx.body, bookmarkSurvivalContext);
|
|
1156
|
+
const trailingMarkers = filterEquivalentBookmarkMarkers(boundaryMarkers.trailing, ctx.body, bookmarkSurvivalContext);
|
|
1157
|
+
// Preserve paragraph properties from the original paragraph for fidelity.
|
|
1158
|
+
const srcP = atom.sourceParagraphElement;
|
|
1159
|
+
const srcPPr = srcP ? findChildByTagName(srcP, 'w:pPr') : null;
|
|
1160
|
+
if (srcPPr) {
|
|
1161
|
+
const clonedPPr = srcPPr.cloneNode(true);
|
|
1162
|
+
newPara.appendChild(clonedPPr);
|
|
1163
|
+
}
|
|
1164
|
+
if (ctx.lastProcessedParagraph) {
|
|
1165
|
+
insertAfterElement(ctx.lastProcessedParagraph, newPara);
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
ctx.body.insertBefore(newPara, ctx.body.firstChild);
|
|
1169
|
+
}
|
|
1170
|
+
ctx.createdParagraphs.set(unifiedPara, newPara);
|
|
1171
|
+
const leadingTail = insertLeadingMarkers(newPara, leadingMarkers);
|
|
1172
|
+
if (leadingTail) {
|
|
1173
|
+
ctx.createdParagraphLastRun.set(unifiedPara, leadingTail);
|
|
1174
|
+
}
|
|
1175
|
+
if (trailingMarkers.length > 0) {
|
|
1176
|
+
ctx.createdParagraphTrailingBookmarks.set(unifiedPara, trailingMarkers);
|
|
1177
|
+
}
|
|
1178
|
+
targetParagraph = newPara;
|
|
1179
|
+
insertAfterRun = leadingTail;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
// Fall back to last processed paragraph if we couldn't determine target
|
|
1184
|
+
if (!targetParagraph) {
|
|
1185
|
+
targetParagraph = ctx.lastProcessedParagraph ??
|
|
1186
|
+
childElements(ctx.body).find(c => c.tagName === 'w:p');
|
|
1187
|
+
}
|
|
1188
|
+
if (!targetParagraph) {
|
|
1189
|
+
warn('inPlaceModifier', 'Cannot insert deleted content: no target paragraph found', {
|
|
1190
|
+
atomText: atom.contentElement?.textContent,
|
|
1191
|
+
});
|
|
1192
|
+
return {};
|
|
1193
|
+
}
|
|
1194
|
+
const del = insertDeletedRun(atom, insertAfterRun, targetParagraph, ctx.author, ctx.dateStr, ctx.state, bookmarkSurvivalContext);
|
|
1195
|
+
if (del) {
|
|
1196
|
+
// Track last run in created paragraphs
|
|
1197
|
+
if (unifiedPara !== undefined && ctx.createdParagraphs.has(unifiedPara)) {
|
|
1198
|
+
ctx.createdParagraphLastRun.set(unifiedPara, del);
|
|
1199
|
+
}
|
|
1200
|
+
return {
|
|
1201
|
+
newLastRun: del,
|
|
1202
|
+
newLastParagraph: targetParagraph,
|
|
1203
|
+
newLastParagraphIndex: atom.paragraphIndex,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return {};
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Handle MovedSource atoms - clone from original and insert with <w:moveFrom>.
|
|
1211
|
+
*
|
|
1212
|
+
* MovedSource atoms have sourceRunElement pointing to the ORIGINAL tree.
|
|
1213
|
+
* We need to clone the content and insert it into the REVISED tree.
|
|
1214
|
+
*
|
|
1215
|
+
* Paragraph placement logic (same as handleDeleted):
|
|
1216
|
+
* 1. If the atom's unified paragraph exists in the revised document, insert there
|
|
1217
|
+
* 2. If we've already created a paragraph for this unified index, use it
|
|
1218
|
+
* 3. Otherwise, create a new paragraph and insert it at the correct position
|
|
1219
|
+
*/
|
|
1220
|
+
function handleMovedSource(atom, ctx) {
|
|
1221
|
+
const bookmarkSurvivalContext = {
|
|
1222
|
+
isParagraphRemovedOnReject: (paragraph) => isParagraphRemovedOnRejectInContext(paragraph, ctx),
|
|
1223
|
+
};
|
|
1224
|
+
if (atom.sourceRunElement) {
|
|
1225
|
+
const unifiedPara = atom.paragraphIndex;
|
|
1226
|
+
let targetParagraph;
|
|
1227
|
+
let insertAfterRun = null;
|
|
1228
|
+
// Determine target paragraph and insertion point
|
|
1229
|
+
if (unifiedPara !== undefined) {
|
|
1230
|
+
// Check if this unified paragraph exists in the revised document
|
|
1231
|
+
const revisedPara = ctx.unifiedParaToElement.get(unifiedPara);
|
|
1232
|
+
const revisedParagraphRemovedOnReject = revisedPara !== undefined &&
|
|
1233
|
+
(ctx.fullyInsertedParagraphIndices.has(unifiedPara) || paragraphHasParaInsMarker(revisedPara));
|
|
1234
|
+
if (revisedPara && !revisedParagraphRemovedOnReject) {
|
|
1235
|
+
// Paragraph exists in revised and survives Reject All - insert into it.
|
|
1236
|
+
targetParagraph = revisedPara;
|
|
1237
|
+
// If this is the same paragraph we last processed, use lastProcessedRun
|
|
1238
|
+
if (ctx.lastParagraphIndex === unifiedPara) {
|
|
1239
|
+
insertAfterRun = ctx.lastProcessedRun;
|
|
1240
|
+
}
|
|
1241
|
+
// Otherwise, insert at the beginning of the paragraph (insertAfterRun = null)
|
|
1242
|
+
}
|
|
1243
|
+
else {
|
|
1244
|
+
// Paragraph is absent in revised OR will be removed on Reject All.
|
|
1245
|
+
// Route moved-from content into a created paragraph for reject fidelity.
|
|
1246
|
+
const createdPara = ctx.createdParagraphs.get(unifiedPara);
|
|
1247
|
+
if (createdPara) {
|
|
1248
|
+
targetParagraph = createdPara;
|
|
1249
|
+
insertAfterRun = ctx.createdParagraphLastRun.get(unifiedPara) ?? null;
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
// Need to create a new paragraph for this moved-from content
|
|
1253
|
+
const newPara = createEl('w:p');
|
|
1254
|
+
const boundaryMarkers = cloneParagraphBoundaryBookmarkMarkers(atom.sourceParagraphElement);
|
|
1255
|
+
for (const marker of [...boundaryMarkers.sourceLeading, ...boundaryMarkers.sourceTrailing]) {
|
|
1256
|
+
ctx.state.emittedSourceBookmarkMarkers.add(marker);
|
|
1257
|
+
}
|
|
1258
|
+
const leadingMarkers = filterEquivalentBookmarkMarkers(boundaryMarkers.leading, ctx.body, bookmarkSurvivalContext);
|
|
1259
|
+
const trailingMarkers = filterEquivalentBookmarkMarkers(boundaryMarkers.trailing, ctx.body, bookmarkSurvivalContext);
|
|
1260
|
+
// Preserve paragraph properties from the original paragraph for fidelity.
|
|
1261
|
+
const srcP = atom.sourceParagraphElement;
|
|
1262
|
+
const srcPPr = srcP ? findChildByTagName(srcP, 'w:pPr') : null;
|
|
1263
|
+
if (srcPPr) {
|
|
1264
|
+
const clonedPPr = srcPPr.cloneNode(true);
|
|
1265
|
+
newPara.appendChild(clonedPPr);
|
|
1266
|
+
}
|
|
1267
|
+
if (ctx.lastProcessedParagraph) {
|
|
1268
|
+
insertAfterElement(ctx.lastProcessedParagraph, newPara);
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
ctx.body.insertBefore(newPara, ctx.body.firstChild);
|
|
1272
|
+
}
|
|
1273
|
+
ctx.createdParagraphs.set(unifiedPara, newPara);
|
|
1274
|
+
const leadingTail = insertLeadingMarkers(newPara, leadingMarkers);
|
|
1275
|
+
if (leadingTail) {
|
|
1276
|
+
ctx.createdParagraphLastRun.set(unifiedPara, leadingTail);
|
|
1277
|
+
}
|
|
1278
|
+
if (trailingMarkers.length > 0) {
|
|
1279
|
+
ctx.createdParagraphTrailingBookmarks.set(unifiedPara, trailingMarkers);
|
|
1280
|
+
}
|
|
1281
|
+
targetParagraph = newPara;
|
|
1282
|
+
insertAfterRun = leadingTail;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
// Fall back to last processed paragraph if we couldn't determine target
|
|
1287
|
+
if (!targetParagraph) {
|
|
1288
|
+
targetParagraph = ctx.lastProcessedParagraph ??
|
|
1289
|
+
childElements(ctx.body).find(c => c.tagName === 'w:p');
|
|
1290
|
+
}
|
|
1291
|
+
if (!targetParagraph) {
|
|
1292
|
+
warn('inPlaceModifier', 'Cannot insert moved-from content: no target paragraph found', {
|
|
1293
|
+
atomText: atom.contentElement?.textContent,
|
|
1294
|
+
});
|
|
1295
|
+
return {};
|
|
1296
|
+
}
|
|
1297
|
+
const moveFrom = insertMoveFromRun(atom, atom.moveName || 'move1', insertAfterRun, targetParagraph, ctx.author, ctx.dateStr, ctx.state, bookmarkSurvivalContext);
|
|
1298
|
+
if (moveFrom) {
|
|
1299
|
+
// Track last run in created paragraphs
|
|
1300
|
+
if (unifiedPara !== undefined && ctx.createdParagraphs.has(unifiedPara)) {
|
|
1301
|
+
ctx.createdParagraphLastRun.set(unifiedPara, moveFrom);
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
newLastRun: moveFrom,
|
|
1305
|
+
newLastParagraph: targetParagraph,
|
|
1306
|
+
newLastParagraphIndex: atom.paragraphIndex,
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return {};
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Handle MovedDestination atoms - wrap with <w:moveTo>.
|
|
1314
|
+
* MovedDestination atoms have sourceRunElement in the REVISED tree.
|
|
1315
|
+
*/
|
|
1316
|
+
function handleMovedDestination(atom, ctx) {
|
|
1317
|
+
const runs = getAtomRuns(atom);
|
|
1318
|
+
if (runs.length > 0) {
|
|
1319
|
+
for (const run of runs) {
|
|
1320
|
+
wrapAsMoveTo(run, atom.moveName || 'move1', ctx.author, ctx.dateStr, ctx.state);
|
|
1321
|
+
}
|
|
1322
|
+
const endRun = getAtomRunAtBoundary(atom, 'end') ?? runs[runs.length - 1];
|
|
1323
|
+
const insertionPoint = getRunInsertionAnchor(endRun);
|
|
1324
|
+
return {
|
|
1325
|
+
newLastRun: insertionPoint,
|
|
1326
|
+
newLastParagraph: atom.sourceParagraphElement ?? ctx.lastProcessedParagraph,
|
|
1327
|
+
newLastParagraphIndex: atom.paragraphIndex,
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
return {};
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Handle FormatChanged atoms - add <w:rPrChange>.
|
|
1334
|
+
* FormatChanged atoms have sourceRunElement in the REVISED tree.
|
|
1335
|
+
*/
|
|
1336
|
+
function handleFormatChanged(atom, ctx) {
|
|
1337
|
+
const run = getAtomRunAtBoundary(atom, 'start');
|
|
1338
|
+
if (run && atom.formatChange?.oldRunProperties) {
|
|
1339
|
+
addFormatChange(run, atom.formatChange.oldRunProperties, ctx.author, ctx.dateStr, ctx.state);
|
|
1340
|
+
const insertionPoint = getRunInsertionAnchor(getAtomRunAtBoundary(atom, 'end') ?? run);
|
|
1341
|
+
return {
|
|
1342
|
+
newLastRun: insertionPoint,
|
|
1343
|
+
newLastParagraph: atom.sourceParagraphElement ?? ctx.lastProcessedParagraph,
|
|
1344
|
+
newLastParagraphIndex: atom.paragraphIndex,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
return {};
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Handle Equal/Unknown atoms - just track position.
|
|
1351
|
+
*
|
|
1352
|
+
* IMPORTANT: For inplace mode, we must track positions in the REVISED tree.
|
|
1353
|
+
* - Non-empty Equal atoms come from the revised tree (sourceRunElement/sourceParagraphElement point to revised)
|
|
1354
|
+
* - Empty paragraph Equal atoms come from the ORIGINAL tree (see createMergedAtomList)
|
|
1355
|
+
*
|
|
1356
|
+
* For empty paragraphs, we need to look up the corresponding revised paragraph
|
|
1357
|
+
* from unifiedParaToElement, not use the atom's sourceParagraphElement (which is from original tree).
|
|
1358
|
+
*
|
|
1359
|
+
* CRITICAL: When the paragraph index changes, we MUST reset newLastRun to null.
|
|
1360
|
+
* This ensures that subsequent content is not incorrectly inserted after a run
|
|
1361
|
+
* from a previous paragraph. See the "Gross Asset Value" bug fix.
|
|
1362
|
+
*/
|
|
1363
|
+
function handleEqual(atom, ctx) {
|
|
1364
|
+
// For non-empty atoms, sourceRunElement points to revised tree - safe to use directly
|
|
1365
|
+
const run = getAtomRunAtBoundary(atom, 'end');
|
|
1366
|
+
if (run) {
|
|
1367
|
+
const insertionPoint = getRunInsertionAnchor(run);
|
|
1368
|
+
return {
|
|
1369
|
+
newLastRun: insertionPoint,
|
|
1370
|
+
newLastParagraph: atom.sourceParagraphElement ?? ctx.lastProcessedParagraph,
|
|
1371
|
+
newLastParagraphIndex: atom.paragraphIndex,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
// For empty paragraphs (no sourceRunElement), the atom comes from the ORIGINAL tree!
|
|
1375
|
+
// We must NOT use atom.sourceParagraphElement for position tracking in inplace mode.
|
|
1376
|
+
// Instead, look up the corresponding REVISED paragraph from unifiedParaToElement.
|
|
1377
|
+
if (atom.paragraphIndex !== undefined) {
|
|
1378
|
+
// Look up the revised paragraph for this unified paragraph index
|
|
1379
|
+
const revisedParagraph = ctx.unifiedParaToElement.get(atom.paragraphIndex);
|
|
1380
|
+
// IMPORTANT: When we move to a new paragraph (empty or not), we MUST reset
|
|
1381
|
+
// lastProcessedRun to null. Otherwise, subsequent inserts might use a stale
|
|
1382
|
+
// run from a previous paragraph, causing content to be inserted in the wrong place.
|
|
1383
|
+
// Setting newLastRun to null explicitly resets it.
|
|
1384
|
+
return {
|
|
1385
|
+
newLastRun: null, // Reset - we're in a new paragraph with no runs yet
|
|
1386
|
+
// Use the revised paragraph (not the original's sourceParagraphElement!)
|
|
1387
|
+
newLastParagraph: revisedParagraph ?? ctx.lastProcessedParagraph,
|
|
1388
|
+
newLastParagraphIndex: atom.paragraphIndex,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
return {};
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Strategy map for handling atoms by correlation status.
|
|
1395
|
+
* This pattern makes it easy to add new status types without modifying processAtoms.
|
|
1396
|
+
*/
|
|
1397
|
+
const ATOM_HANDLERS = {
|
|
1398
|
+
[CorrelationStatus.Inserted]: handleInserted,
|
|
1399
|
+
[CorrelationStatus.Deleted]: handleDeleted,
|
|
1400
|
+
[CorrelationStatus.MovedSource]: handleMovedSource,
|
|
1401
|
+
[CorrelationStatus.MovedDestination]: handleMovedDestination,
|
|
1402
|
+
[CorrelationStatus.FormatChanged]: handleFormatChanged,
|
|
1403
|
+
[CorrelationStatus.Equal]: handleEqual,
|
|
1404
|
+
[CorrelationStatus.Unknown]: handleEqual,
|
|
1405
|
+
};
|
|
1406
|
+
/**
|
|
1407
|
+
* Process atoms and apply track changes to the revised AST.
|
|
1408
|
+
*
|
|
1409
|
+
* Uses a strategy pattern with registered handlers for each correlation status,
|
|
1410
|
+
* making it easy to add new status types without modifying this function.
|
|
1411
|
+
*/
|
|
1412
|
+
function processAtoms(mergedAtoms, _originalAtoms, revisedAtoms, author, dateStr, state, revisedRoot) {
|
|
1413
|
+
const bodyElements = Array.from(revisedRoot.getElementsByTagName('w:body'));
|
|
1414
|
+
const body = bodyElements[0];
|
|
1415
|
+
if (!body) {
|
|
1416
|
+
warn('inPlaceModifier', 'Cannot process atoms: no w:body element found');
|
|
1417
|
+
// Return a minimal context to avoid callers having to handle undefined.
|
|
1418
|
+
return {
|
|
1419
|
+
author,
|
|
1420
|
+
dateStr,
|
|
1421
|
+
state,
|
|
1422
|
+
body: revisedRoot,
|
|
1423
|
+
lastProcessedRun: null,
|
|
1424
|
+
lastProcessedParagraph: null,
|
|
1425
|
+
lastParagraphIndex: undefined,
|
|
1426
|
+
unifiedParaToElement: new Map(),
|
|
1427
|
+
revisedParagraphToUnifiedIndex: new Map(),
|
|
1428
|
+
fullyInsertedParagraphIndices: new Set(),
|
|
1429
|
+
createdParagraphs: new Map(),
|
|
1430
|
+
createdParagraphLastRun: new Map(),
|
|
1431
|
+
createdParagraphTrailingBookmarks: new Map(),
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
// Build map from unified paragraph index to revised paragraph element.
|
|
1435
|
+
// This tells us which paragraphs exist in the revised document.
|
|
1436
|
+
// Revised atoms have their paragraphIndex already set to unified indices
|
|
1437
|
+
// after assignUnifiedParagraphIndices was called.
|
|
1438
|
+
const unifiedParaToElement = new Map();
|
|
1439
|
+
const revisedParagraphToUnifiedIndex = new Map();
|
|
1440
|
+
for (const atom of revisedAtoms) {
|
|
1441
|
+
if (atom.paragraphIndex !== undefined && atom.sourceParagraphElement) {
|
|
1442
|
+
if (!unifiedParaToElement.has(atom.paragraphIndex)) {
|
|
1443
|
+
unifiedParaToElement.set(atom.paragraphIndex, atom.sourceParagraphElement);
|
|
1444
|
+
}
|
|
1445
|
+
if (!revisedParagraphToUnifiedIndex.has(atom.sourceParagraphElement)) {
|
|
1446
|
+
revisedParagraphToUnifiedIndex.set(atom.sourceParagraphElement, atom.paragraphIndex);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
const atomsByPara = new Map();
|
|
1451
|
+
for (const atom of mergedAtoms) {
|
|
1452
|
+
if (atom.paragraphIndex === undefined)
|
|
1453
|
+
continue;
|
|
1454
|
+
const existing = atomsByPara.get(atom.paragraphIndex) ?? [];
|
|
1455
|
+
existing.push(atom);
|
|
1456
|
+
atomsByPara.set(atom.paragraphIndex, existing);
|
|
1457
|
+
}
|
|
1458
|
+
const fullyInsertedParagraphIndices = new Set();
|
|
1459
|
+
for (const [paraIdx, atoms] of atomsByPara.entries()) {
|
|
1460
|
+
if (isEntireParagraphAtomsWithStatus(atoms, CorrelationStatus.Inserted)) {
|
|
1461
|
+
fullyInsertedParagraphIndices.add(paraIdx);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
// Initialize processing context with position tracking
|
|
1465
|
+
const ctx = {
|
|
1466
|
+
author,
|
|
1467
|
+
dateStr,
|
|
1468
|
+
state,
|
|
1469
|
+
body,
|
|
1470
|
+
lastProcessedRun: null,
|
|
1471
|
+
lastProcessedParagraph: null,
|
|
1472
|
+
lastParagraphIndex: undefined,
|
|
1473
|
+
unifiedParaToElement,
|
|
1474
|
+
revisedParagraphToUnifiedIndex,
|
|
1475
|
+
fullyInsertedParagraphIndices,
|
|
1476
|
+
createdParagraphs: new Map(),
|
|
1477
|
+
createdParagraphLastRun: new Map(),
|
|
1478
|
+
createdParagraphTrailingBookmarks: new Map(),
|
|
1479
|
+
};
|
|
1480
|
+
for (const atom of mergedAtoms) {
|
|
1481
|
+
const handler = ATOM_HANDLERS[atom.correlationStatus];
|
|
1482
|
+
const result = handler(atom, ctx);
|
|
1483
|
+
// Update position tracking based on handler result
|
|
1484
|
+
if (result.newLastRun !== undefined) {
|
|
1485
|
+
ctx.lastProcessedRun = result.newLastRun;
|
|
1486
|
+
}
|
|
1487
|
+
if (result.newLastParagraph !== undefined) {
|
|
1488
|
+
ctx.lastProcessedParagraph = result.newLastParagraph;
|
|
1489
|
+
}
|
|
1490
|
+
if (result.newLastParagraphIndex !== undefined) {
|
|
1491
|
+
ctx.lastParagraphIndex = result.newLastParagraphIndex;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
finalizeCreatedParagraphTrailingBookmarks(ctx);
|
|
1495
|
+
return ctx;
|
|
1496
|
+
}
|
|
1497
|
+
function finalizeCreatedParagraphTrailingBookmarks(ctx) {
|
|
1498
|
+
for (const [paraIdx, markers] of ctx.createdParagraphTrailingBookmarks.entries()) {
|
|
1499
|
+
if (markers.length === 0)
|
|
1500
|
+
continue;
|
|
1501
|
+
const paragraph = ctx.createdParagraphs.get(paraIdx);
|
|
1502
|
+
if (!paragraph)
|
|
1503
|
+
continue;
|
|
1504
|
+
let anchor = ctx.createdParagraphLastRun.get(paraIdx) ?? null;
|
|
1505
|
+
if (!anchor) {
|
|
1506
|
+
const pPr = findChildByTagName(paragraph, 'w:pPr');
|
|
1507
|
+
const kids = childElements(paragraph);
|
|
1508
|
+
const leadingBookmark = [...kids]
|
|
1509
|
+
.reverse()
|
|
1510
|
+
.find((c) => c.tagName === 'w:bookmarkStart') ?? null;
|
|
1511
|
+
anchor = leadingBookmark ?? pPr;
|
|
1512
|
+
}
|
|
1513
|
+
if (!anchor) {
|
|
1514
|
+
for (const marker of markers) {
|
|
1515
|
+
paragraph.appendChild(marker);
|
|
1516
|
+
}
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
let current = anchor;
|
|
1520
|
+
for (const marker of markers) {
|
|
1521
|
+
insertAfterElement(current, marker);
|
|
1522
|
+
current = marker;
|
|
1523
|
+
}
|
|
1524
|
+
ctx.createdParagraphLastRun.set(paraIdx, current);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Apply whole-paragraph revision markers (w:pPr/w:rPr) based on merged atoms.
|
|
1529
|
+
*
|
|
1530
|
+
* This intentionally runs as a post-pass so the inplace algorithm can keep its
|
|
1531
|
+
* fine-grained run edits while still enforcing Word/Aspose paragraph invariants.
|
|
1532
|
+
*/
|
|
1533
|
+
function applyWholeParagraphRevisionMarkers(mergedAtoms, ctx) {
|
|
1534
|
+
const atomsByPara = new Map();
|
|
1535
|
+
for (const atom of mergedAtoms) {
|
|
1536
|
+
if (atom.paragraphIndex === undefined)
|
|
1537
|
+
continue;
|
|
1538
|
+
const list = atomsByPara.get(atom.paragraphIndex) ?? [];
|
|
1539
|
+
list.push(atom);
|
|
1540
|
+
atomsByPara.set(atom.paragraphIndex, list);
|
|
1541
|
+
}
|
|
1542
|
+
for (const [paraIdx, atoms] of atomsByPara.entries()) {
|
|
1543
|
+
if (isEntireParagraphAtomsWithStatus(atoms, CorrelationStatus.Inserted)) {
|
|
1544
|
+
const para = ctx.unifiedParaToElement.get(paraIdx);
|
|
1545
|
+
if (para) {
|
|
1546
|
+
wrapParagraphAsInserted(para, ctx.author, ctx.dateStr, ctx.state);
|
|
1547
|
+
}
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
if (isEntireParagraphAtomsWithStatus(atoms, CorrelationStatus.Deleted)) {
|
|
1551
|
+
const para = ctx.createdParagraphs.get(paraIdx) ?? ctx.unifiedParaToElement.get(paraIdx);
|
|
1552
|
+
if (para) {
|
|
1553
|
+
wrapParagraphAsDeleted(para, ctx.author, ctx.dateStr, ctx.state);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Merge adjacent sibling track-change wrappers (<w:ins>/<w:del>) to reduce
|
|
1560
|
+
* Word UI fragmentation (one accept/reject per word/run).
|
|
1561
|
+
*
|
|
1562
|
+
* We only merge wrappers that share the same author+date to avoid conflating
|
|
1563
|
+
* distinct revisions.
|
|
1564
|
+
*/
|
|
1565
|
+
function mergeAdjacentTrackChangeSiblings(root) {
|
|
1566
|
+
function traverse(node) {
|
|
1567
|
+
const children = childElements(node);
|
|
1568
|
+
if (children.length < 2) {
|
|
1569
|
+
// Still recurse into children
|
|
1570
|
+
for (const child of childElements(node))
|
|
1571
|
+
traverse(child);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
for (let i = 0; i < children.length - 1;) {
|
|
1575
|
+
const a = children[i];
|
|
1576
|
+
const b = children[i + 1];
|
|
1577
|
+
if (a.tagName === b.tagName && TRACK_CHANGE_WRAPPERS.has(a.tagName) &&
|
|
1578
|
+
a.getAttribute('w:author') === b.getAttribute('w:author') &&
|
|
1579
|
+
a.getAttribute('w:date') === b.getAttribute('w:date')) {
|
|
1580
|
+
// Absorb b's children into a
|
|
1581
|
+
while (b.firstChild)
|
|
1582
|
+
a.appendChild(b.firstChild);
|
|
1583
|
+
node.removeChild(b);
|
|
1584
|
+
// Remove b from our local children snapshot and don't increment i —
|
|
1585
|
+
// recheck a with its new next sibling.
|
|
1586
|
+
children.splice(i + 1, 1);
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
i++;
|
|
1590
|
+
}
|
|
1591
|
+
// Recurse into current children (re-query after mutations)
|
|
1592
|
+
for (const child of childElements(node)) {
|
|
1593
|
+
traverse(child);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
traverse(root);
|
|
1597
|
+
}
|
|
1598
|
+
// Re-export for convenience
|
|
1599
|
+
export { createRevisionIdState };
|
|
1600
|
+
//# sourceMappingURL=inPlaceModifier.js.map
|