@ansonlai/docx-redline-js 0.4.0 → 0.5.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/AGENTS.md +646 -288
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/redline-validation.js +11 -5
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +3145 -505
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +88 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +508 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +407 -50
- package/engine/surgical-mode.js +142 -6
- package/engine/surgical-run-splitting.js +103 -0
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +322 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +123 -102
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +513 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +345 -0
- package/services/document-operation-mutations.js +1749 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +390 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +77 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
package/engine/surgical-mode.js
CHANGED
|
@@ -15,6 +15,78 @@ import {
|
|
|
15
15
|
reconcileFormattingForTextSpan
|
|
16
16
|
} from './surgical-diff-application.js';
|
|
17
17
|
import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
18
|
+
import { createReplacementRevisionEvent } from '../core/types.js';
|
|
19
|
+
|
|
20
|
+
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
|
|
21
|
+
const spans = [];
|
|
22
|
+
forEachOverlappingSpan(spanIndex, startPos, endPos, span => spans.push(span));
|
|
23
|
+
if (spans.length === 0) return { safe: false };
|
|
24
|
+
|
|
25
|
+
const firstRun = spans[0].runElement;
|
|
26
|
+
const parent = firstRun?.parentNode;
|
|
27
|
+
if (!parent) return { safe: false };
|
|
28
|
+
|
|
29
|
+
// All overlapping runs must share the exact same parent element
|
|
30
|
+
const sameParent = spans.every(s => s.runElement?.parentNode === parent);
|
|
31
|
+
if (!sameParent) return { safe: false, structuralBoundary: true };
|
|
32
|
+
|
|
33
|
+
// Parent container itself cannot be an existing revision or unsupported container
|
|
34
|
+
const parentLocal = (parent.localName || parent.nodeName.replace(/^.*:/, ''));
|
|
35
|
+
if (
|
|
36
|
+
['hyperlink', 'sdt', 'del', 'moveFrom', 'moveTo'].includes(parentLocal)
|
|
37
|
+
|| (parentLocal === 'ins' && !allowInsertionCarrier)
|
|
38
|
+
) {
|
|
39
|
+
return { safe: false, structuralBoundary: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const structuralTags = new Set([
|
|
43
|
+
'hyperlink', 'fldSimple', 'sdt',
|
|
44
|
+
'commentRangeStart', 'commentRangeEnd', 'commentReference',
|
|
45
|
+
'bookmarkStart', 'bookmarkEnd',
|
|
46
|
+
'moveFrom', 'moveTo', 'ins', 'del'
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
for (const span of spans) {
|
|
50
|
+
const run = span.runElement;
|
|
51
|
+
for (const child of Array.from(run.childNodes || [])) {
|
|
52
|
+
if (child.nodeType === 1) {
|
|
53
|
+
const tag = child.localName || child.nodeName.replace(/^.*:/, '');
|
|
54
|
+
if (structuralTags.has(tag) || tag === 'fldChar') {
|
|
55
|
+
return { safe: false, structuralBoundary: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const lastRun = spans[spans.length - 1].runElement;
|
|
62
|
+
let curr = firstRun;
|
|
63
|
+
while (curr && curr !== lastRun) {
|
|
64
|
+
if (curr !== firstRun) {
|
|
65
|
+
const tag = curr.localName || curr.nodeName.replace(/^.*:/, '');
|
|
66
|
+
if (structuralTags.has(tag)) {
|
|
67
|
+
return { safe: false, structuralBoundary: true };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
curr = curr.nextSibling;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasStructuralDescendant(node) {
|
|
74
|
+
if (!node || node.nodeType !== 1) return false;
|
|
75
|
+
const tag = node.localName || node.nodeName.replace(/^.*:/, '');
|
|
76
|
+
if (structuralTags.has(tag) || tag === 'fldChar') return true;
|
|
77
|
+
for (const child of Array.from(node.childNodes || [])) {
|
|
78
|
+
if (child.nodeType === 1 && hasStructuralDescendant(child)) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Inspect immediate adjacent siblings of the deleted range
|
|
84
|
+
if (hasStructuralDescendant(firstRun.previousSibling) || hasStructuralDescendant(lastRun.nextSibling)) {
|
|
85
|
+
return { safe: false, structuralBoundary: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { safe: true };
|
|
89
|
+
}
|
|
18
90
|
|
|
19
91
|
/**
|
|
20
92
|
* Applies surgical mode reconciliation.
|
|
@@ -28,9 +100,10 @@ import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
|
28
100
|
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
29
101
|
* @param {Element|null} [targetParagraph=null] - Optional scope paragraph
|
|
30
102
|
* @param {{ diffTimeoutSeconds?: number }} [diffOptions={}] - Diff configuration
|
|
31
|
-
* @
|
|
103
|
+
* @param {Object} [options={}] - Additional reconciliation options (e.g. pairReplacements)
|
|
104
|
+
* @returns {{ oxml: string, hasChanges: boolean, warnings?: string[], sourceType?: 'package'|'document'|'fragment' }}
|
|
32
105
|
*/
|
|
33
|
-
export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}) {
|
|
106
|
+
export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null, diffOptions = {}, options = {}) {
|
|
34
107
|
void originalText;
|
|
35
108
|
|
|
36
109
|
const allParagraphs = targetParagraph
|
|
@@ -40,12 +113,15 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
40
113
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
41
114
|
const diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
|
|
42
115
|
const spanIndex = buildSpanIndex(textSpans);
|
|
116
|
+
const pairReplacements = options.pairReplacements === true;
|
|
117
|
+
const warnings = [];
|
|
43
118
|
|
|
44
119
|
let originalPos = 0;
|
|
45
120
|
let newPos = 0;
|
|
46
121
|
let hasChanges = false;
|
|
47
122
|
|
|
48
|
-
for (
|
|
123
|
+
for (let i = 0; i < diffs.length; i++) {
|
|
124
|
+
const [op, text] = diffs[i];
|
|
49
125
|
if (op === 0) {
|
|
50
126
|
const len = text.length;
|
|
51
127
|
const startPos = originalPos;
|
|
@@ -67,14 +143,70 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
67
143
|
originalPos += len;
|
|
68
144
|
newPos += len;
|
|
69
145
|
} else if (op === -1) {
|
|
70
|
-
|
|
146
|
+
const hasNextInsert = (i + 1 < diffs.length) && (diffs[i + 1][0] === 1);
|
|
147
|
+
let paired = false;
|
|
148
|
+
let delMetadata = null;
|
|
149
|
+
let insMetadata = null;
|
|
150
|
+
|
|
151
|
+
if (pairReplacements && generateRedlines && hasNextInsert) {
|
|
152
|
+
const nextText = diffs[i + 1][1];
|
|
153
|
+
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
154
|
+
if (textWithoutNewlines.trim().length > 0) {
|
|
155
|
+
const checkResult = checkSafeAdjacencyForPairing(
|
|
156
|
+
spanIndex,
|
|
157
|
+
originalPos,
|
|
158
|
+
originalPos + text.length,
|
|
159
|
+
options?.existingRevisions === 'slice-cross-author'
|
|
160
|
+
);
|
|
161
|
+
if (checkResult.safe) {
|
|
162
|
+
const event = createReplacementRevisionEvent(author, xmlDoc);
|
|
163
|
+
delMetadata = { id: event.deletionId, author: event.author, date: event.date };
|
|
164
|
+
insMetadata = { id: event.insertionId, author: event.author, date: event.date };
|
|
165
|
+
paired = true;
|
|
166
|
+
} else if (checkResult.structuralBoundary) {
|
|
167
|
+
warnings.push('PAIRING_SKIPPED_STRUCTURAL_BOUNDARY');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, author, generateRedlines, delMetadata)) {
|
|
71
173
|
hasChanges = true;
|
|
72
174
|
}
|
|
73
175
|
originalPos += text.length;
|
|
176
|
+
|
|
177
|
+
if (paired) {
|
|
178
|
+
i++;
|
|
179
|
+
const [, nextText] = diffs[i];
|
|
180
|
+
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
181
|
+
if (textWithoutNewlines.trim().length > 0) {
|
|
182
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
183
|
+
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
184
|
+
return withOoxmlSourceType({
|
|
185
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
186
|
+
hasChanges: false,
|
|
187
|
+
status: 'error',
|
|
188
|
+
error: insertResult.error
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (insertResult === true) {
|
|
192
|
+
hasChanges = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
newPos += nextText.length;
|
|
196
|
+
}
|
|
74
197
|
} else if (op === 1) {
|
|
75
198
|
const textWithoutNewlines = text.replace(/\n/g, ' ');
|
|
76
199
|
if (textWithoutNewlines.trim().length > 0) {
|
|
77
|
-
|
|
200
|
+
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
201
|
+
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
202
|
+
return withOoxmlSourceType({
|
|
203
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
204
|
+
hasChanges: false,
|
|
205
|
+
status: 'error',
|
|
206
|
+
error: insertResult.error
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (insertResult === true) {
|
|
78
210
|
hasChanges = true;
|
|
79
211
|
}
|
|
80
212
|
}
|
|
@@ -82,5 +214,9 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
82
214
|
}
|
|
83
215
|
}
|
|
84
216
|
|
|
85
|
-
return withOoxmlSourceType({
|
|
217
|
+
return withOoxmlSourceType({
|
|
218
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
219
|
+
hasChanges,
|
|
220
|
+
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
221
|
+
});
|
|
86
222
|
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { createWordElement, isWordElement } from '../core/word-xml.js';
|
|
2
|
+
import {
|
|
3
|
+
NS_W,
|
|
4
|
+
RevisionIdAllocator,
|
|
5
|
+
createRevisionIdAllocator,
|
|
6
|
+
getRevisionIdAllocatorForDocument
|
|
7
|
+
} from '../core/types.js';
|
|
8
|
+
import { refreshRunPropertyChangeIds } from '../core/revision-cloning.js';
|
|
2
9
|
import { getRunChildText, isTextLikeRunChild } from './surgical-spans.js';
|
|
3
10
|
|
|
11
|
+
const TRACK_CHANGE_CARRIERS = new Set(['ins']);
|
|
12
|
+
|
|
4
13
|
export function getRunContentPieces(runElement) {
|
|
5
14
|
const pieces = [];
|
|
6
15
|
let offset = 0;
|
|
@@ -62,6 +71,100 @@ export function insertRunPiecesBefore(xmlDoc, parent, referenceNode, pieces, rPr
|
|
|
62
71
|
return run;
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Splits a run-level tracked-change carrier at an accepted-view character
|
|
76
|
+
* offset without mutating the source carrier. The original revision ID stays
|
|
77
|
+
* with the leading fragment; an interior trailing fragment receives a fresh,
|
|
78
|
+
* document-scoped ID while all other carrier metadata remains unchanged.
|
|
79
|
+
*
|
|
80
|
+
* @param {Document} xmlDoc
|
|
81
|
+
* @param {Element} carrierElement
|
|
82
|
+
* @param {number} splitOffset
|
|
83
|
+
* @param {RevisionIdAllocator|null} [allocator=null]
|
|
84
|
+
* @returns {{ leftCarrier: Element|null, rightCarrier: Element|null }}
|
|
85
|
+
*/
|
|
86
|
+
export function splitTrackChangeCarrier(xmlDoc, carrierElement, splitOffset, allocator = null) {
|
|
87
|
+
const carrierName = getLocalName(carrierElement);
|
|
88
|
+
if (!TRACK_CHANGE_CARRIERS.has(carrierName)) {
|
|
89
|
+
throw new TypeError('splitTrackChangeCarrier requires a w:ins carrier.');
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isInteger(splitOffset) || splitOffset < 0) {
|
|
92
|
+
throw new RangeError('splitOffset must be a non-negative integer.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const children = Array.from(carrierElement.childNodes || []);
|
|
96
|
+
const totalLength = children.reduce((length, child) => {
|
|
97
|
+
return length + (isWordElement(child, 'r') ? getRunTextLength(getRunContentPieces(child)) : 0);
|
|
98
|
+
}, 0);
|
|
99
|
+
if (splitOffset > totalLength) {
|
|
100
|
+
throw new RangeError(`splitOffset ${splitOffset} exceeds carrier text length ${totalLength}.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (splitOffset === 0) {
|
|
104
|
+
return { leftCarrier: null, rightCarrier: carrierElement.cloneNode(true) };
|
|
105
|
+
}
|
|
106
|
+
if (splitOffset === totalLength) {
|
|
107
|
+
return { leftCarrier: carrierElement.cloneNode(true), rightCarrier: null };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const leftCarrier = carrierElement.cloneNode(false);
|
|
111
|
+
const rightCarrier = carrierElement.cloneNode(false);
|
|
112
|
+
let offset = 0;
|
|
113
|
+
|
|
114
|
+
for (const child of children) {
|
|
115
|
+
if (!isWordElement(child, 'r')) {
|
|
116
|
+
const destination = offset <= splitOffset ? leftCarrier : rightCarrier;
|
|
117
|
+
destination.appendChild(child.cloneNode(true));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const pieces = getRunContentPieces(child);
|
|
122
|
+
const runLength = getRunTextLength(pieces);
|
|
123
|
+
const runEnd = offset + runLength;
|
|
124
|
+
|
|
125
|
+
if (runEnd <= splitOffset) {
|
|
126
|
+
leftCarrier.appendChild(child.cloneNode(true));
|
|
127
|
+
} else if (offset >= splitOffset) {
|
|
128
|
+
rightCarrier.appendChild(child.cloneNode(true));
|
|
129
|
+
} else {
|
|
130
|
+
const localOffset = splitOffset - offset;
|
|
131
|
+
const rPr = Array.from(child.childNodes || []).find(node => isWordElement(node, 'rPr')) || null;
|
|
132
|
+
const leftPieces = sliceRunPieces(xmlDoc, pieces, 0, localOffset, false);
|
|
133
|
+
const rightPieces = sliceRunPieces(xmlDoc, pieces, localOffset, runLength, false);
|
|
134
|
+
leftCarrier.appendChild(createRunFromPieces(xmlDoc, leftPieces, rPr));
|
|
135
|
+
const rightRun = createRunFromPieces(xmlDoc, rightPieces, rPr);
|
|
136
|
+
refreshRunPropertyChangeIds(rightRun, resolveAllocator(xmlDoc, allocator));
|
|
137
|
+
rightCarrier.appendChild(rightRun);
|
|
138
|
+
}
|
|
139
|
+
offset = runEnd;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resolvedAllocator = resolveAllocator(xmlDoc, allocator);
|
|
143
|
+
const nextId = resolvedAllocator.next();
|
|
144
|
+
setWordAttribute(rightCarrier, 'id', String(nextId));
|
|
145
|
+
resolvedAllocator._receiptCollector?.recordRevision(nextId, carrierName);
|
|
146
|
+
|
|
147
|
+
return { leftCarrier, rightCarrier };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveAllocator(xmlDoc, allocator) {
|
|
151
|
+
return allocator instanceof RevisionIdAllocator
|
|
152
|
+
? allocator
|
|
153
|
+
: (getRevisionIdAllocatorForDocument(xmlDoc) || createRevisionIdAllocator(xmlDoc));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setWordAttribute(element, localName, value) {
|
|
157
|
+
if (typeof element.setAttributeNS === 'function') {
|
|
158
|
+
element.setAttributeNS(NS_W, `w:${localName}`, value);
|
|
159
|
+
} else {
|
|
160
|
+
element.setAttribute(`w:${localName}`, value);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getLocalName(element) {
|
|
165
|
+
return String(element?.localName || element?.nodeName || '').replace(/^.*:/, '');
|
|
166
|
+
}
|
|
167
|
+
|
|
65
168
|
function cloneRunPiece(xmlDoc, sourceNode, text, asDeletedText) {
|
|
66
169
|
if (asDeletedText) {
|
|
67
170
|
const delText = createWordElement(xmlDoc, 'w:delText');
|
package/engine/surgical-spans.js
CHANGED
|
@@ -7,6 +7,7 @@ export function getRunChildText(child) {
|
|
|
7
7
|
if (isWordElement(child, 'br') || isWordElement(child, 'cr')) return '\n';
|
|
8
8
|
if (isWordElement(child, 'tab')) return '\t';
|
|
9
9
|
if (isWordElement(child, 'noBreakHyphen')) return '\u2011';
|
|
10
|
+
if (isWordElement(child, 'softHyphen')) return '\u00ad';
|
|
10
11
|
return '';
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -15,7 +16,8 @@ export function isTextLikeRunChild(child) {
|
|
|
15
16
|
|| isWordElement(child, 'br')
|
|
16
17
|
|| isWordElement(child, 'cr')
|
|
17
18
|
|| isWordElement(child, 'tab')
|
|
18
|
-
|| isWordElement(child, 'noBreakHyphen')
|
|
19
|
+
|| isWordElement(child, 'noBreakHyphen')
|
|
20
|
+
|| isWordElement(child, 'softHyphen');
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export function buildSurgicalTextSpans(paragraphs) {
|
|
@@ -34,6 +36,27 @@ export function buildSurgicalTextSpans(paragraphs) {
|
|
|
34
36
|
fullText += processRunElement(hc, paragraph, container, fullText.length, textSpans).text;
|
|
35
37
|
}
|
|
36
38
|
}
|
|
39
|
+
} else if (isWordElement(child, 'ins')) {
|
|
40
|
+
for (let ic = child.firstChild; ic; ic = ic.nextSibling) {
|
|
41
|
+
if (isWordElement(ic, 'r')) {
|
|
42
|
+
fullText += processRunElement(ic, paragraph, container, fullText.length, textSpans).text;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} else if (isWordElement(child, 'sdt')) {
|
|
46
|
+
const sdtContent = Array.from(child.childNodes || []).find(n => isWordElement(n, 'sdtContent'));
|
|
47
|
+
if (sdtContent) {
|
|
48
|
+
for (let sc = sdtContent.firstChild; sc; sc = sc.nextSibling) {
|
|
49
|
+
if (isWordElement(sc, 'r')) {
|
|
50
|
+
fullText += processRunElement(sc, paragraph, container, fullText.length, textSpans).text;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} else if (isWordElement(child, 'smartTag')) {
|
|
55
|
+
for (let st = child.firstChild; st; st = st.nextSibling) {
|
|
56
|
+
if (isWordElement(st, 'r')) {
|
|
57
|
+
fullText += processRunElement(st, paragraph, container, fullText.length, textSpans).text;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
37
60
|
}
|
|
38
61
|
}
|
|
39
62
|
|
|
@@ -167,3 +190,31 @@ function lowerBound(values, target) {
|
|
|
167
190
|
|
|
168
191
|
return left;
|
|
169
192
|
}
|
|
193
|
+
|
|
194
|
+
export function describeInsertionBoundary(spanIndex, pos, fallbackParagraph = null) {
|
|
195
|
+
if (!spanIndex || !spanIndex.spans || spanIndex.spans.length === 0) {
|
|
196
|
+
return {
|
|
197
|
+
leftSpan: null,
|
|
198
|
+
rightSpan: null,
|
|
199
|
+
containingSpan: null,
|
|
200
|
+
isInterior: false,
|
|
201
|
+
fallbackParagraph
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const containingSpan = findContainingSpan(spanIndex, pos);
|
|
206
|
+
const isInterior = containingSpan !== null && pos > containingSpan.charStart && pos < containingSpan.charEnd;
|
|
207
|
+
|
|
208
|
+
// leftSpan ends at or before pos
|
|
209
|
+
const leftSpan = findFirstSpanEndingAt(spanIndex, pos) || (pos > 0 ? findLastSpanEndingBeforeOrAt(spanIndex, pos) : null);
|
|
210
|
+
// rightSpan starts at or after pos
|
|
211
|
+
const rightSpan = spanIndex.spans.find(s => s.charStart === pos) || (pos === 0 ? spanIndex.spans[0] : null);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
leftSpan,
|
|
215
|
+
rightSpan,
|
|
216
|
+
containingSpan,
|
|
217
|
+
isInterior,
|
|
218
|
+
fallbackParagraph
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -11,6 +11,7 @@ import { buildParagraphOnlyPackage } from '../services/package-builder.js';
|
|
|
11
11
|
import { getElementsByTagNSOrTag } from '../core/xml-query.js';
|
|
12
12
|
import { NS_W } from '../core/types.js';
|
|
13
13
|
import { isWordElement } from '../core/word-xml.js';
|
|
14
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
14
15
|
|
|
15
16
|
const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
16
17
|
|
|
@@ -69,12 +70,8 @@ export function detectTableCellContext(xmlDoc, originalText, options = {}) {
|
|
|
69
70
|
if (originalText && originalText.trim()) {
|
|
70
71
|
const normalizedTarget = originalText.trim();
|
|
71
72
|
if (!targetParagraph) {
|
|
72
|
-
for (const p of paragraphsInCells) {
|
|
73
|
-
const
|
|
74
|
-
let paragraphText = '';
|
|
75
|
-
for (const t of textNodes) {
|
|
76
|
-
paragraphText += t.textContent || '';
|
|
77
|
-
}
|
|
73
|
+
for (const p of paragraphsInCells) {
|
|
74
|
+
const paragraphText = extractCanonicalParagraphText(p);
|
|
78
75
|
|
|
79
76
|
if (paragraphText.trim() === normalizedTarget) {
|
|
80
77
|
targetParagraph = p;
|
package/engine/table-mode.js
CHANGED
|
@@ -165,7 +165,7 @@ export function applyTextToTableTransformation(xmlDoc, modifiedText, serializer,
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
const del = createWordElement(workingDoc, 'w:del');
|
|
168
|
-
const metadata = createRevisionMetadata(author, workingDoc);
|
|
168
|
+
const metadata = createRevisionMetadata(author, workingDoc, 'del');
|
|
169
169
|
del.setAttribute('w:id', String(metadata.id));
|
|
170
170
|
del.setAttribute('w:author', metadata.author);
|
|
171
171
|
del.setAttribute('w:date', metadata.date);
|