@ansonlai/docx-redline-js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +176 -0
- package/ARCHITECTURE.md +121 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/adapters/config.js +43 -0
- package/adapters/logger.js +89 -0
- package/adapters/xml-adapter.js +74 -0
- package/core/list-targeting.js +398 -0
- package/core/ooxml-identifiers.js +15 -0
- package/core/paragraph-offset-policy.js +50 -0
- package/core/paragraph-targeting.js +501 -0
- package/core/table-targeting.js +233 -0
- package/core/types.js +204 -0
- package/core/xml-query.js +99 -0
- package/dist/docx-redline-js.esm.js +8801 -0
- package/dist/docx-redline-js.esm.js.map +7 -0
- package/dist/docx-redline-js.esm.min.js +195 -0
- package/dist/docx-redline-js.esm.min.js.map +7 -0
- package/engine/format-application.js +358 -0
- package/engine/format-extraction.js +232 -0
- package/engine/format-paragraph-targeting.js +208 -0
- package/engine/format-span-application.js +178 -0
- package/engine/formatting-removal.js +330 -0
- package/engine/oxml-engine.js +279 -0
- package/engine/reconstruction-mapper.js +270 -0
- package/engine/reconstruction-mode.js +38 -0
- package/engine/reconstruction-writer.js +276 -0
- package/engine/rpr-helpers.js +194 -0
- package/engine/run-builders.js +235 -0
- package/engine/surgical-mode.js +520 -0
- package/engine/table-cell-context.js +151 -0
- package/engine/table-mode.js +172 -0
- package/index.js +308 -0
- package/orchestration/list-markdown.js +141 -0
- package/orchestration/list-parsing.js +73 -0
- package/orchestration/list-structural-fallback.js +530 -0
- package/orchestration/redline-operation-converter.js +141 -0
- package/orchestration/route-plan.js +160 -0
- package/package.json +76 -0
- package/pipeline/content-analysis.js +107 -0
- package/pipeline/diff-engine.js +204 -0
- package/pipeline/ingestion-export.js +255 -0
- package/pipeline/ingestion-paragraph.js +351 -0
- package/pipeline/ingestion-table.js +169 -0
- package/pipeline/ingestion-xml.js +39 -0
- package/pipeline/ingestion.js +8 -0
- package/pipeline/list-generation.js +280 -0
- package/pipeline/list-markers.js +77 -0
- package/pipeline/markdown-processor.js +160 -0
- package/pipeline/patching.js +408 -0
- package/pipeline/pipeline.js +326 -0
- package/pipeline/serialization.js +395 -0
- package/services/browser-demo-prompt-context.js +345 -0
- package/services/comment-builders.js +60 -0
- package/services/comment-engine.js +248 -0
- package/services/comment-locator.js +197 -0
- package/services/comment-package.js +113 -0
- package/services/numbering-helpers.js +416 -0
- package/services/numbering-service.js +290 -0
- package/services/package-builder.js +147 -0
- package/services/standalone-docx-plumbing.js +443 -0
- package/services/standalone-operation-runner.js +1169 -0
- package/services/table-reconciliation.js +344 -0
- package/standalone.js +5 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surgical reconciliation mode.
|
|
3
|
+
*
|
|
4
|
+
* This mode performs in-place run-level edits and preserves existing structure,
|
|
5
|
+
* making it safe for tables and other complex OOXML containers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
9
|
+
import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
10
|
+
import { appendParagraphBoundary } from '../core/paragraph-offset-policy.js';
|
|
11
|
+
import { NS_W, getNextRevisionId } from '../core/types.js';
|
|
12
|
+
import { createSerializer } from '../adapters/xml-adapter.js';
|
|
13
|
+
import { getDocumentParagraphs } from './format-extraction.js';
|
|
14
|
+
import { buildOverrideRPrXml } from './rpr-helpers.js';
|
|
15
|
+
import { getFirstElementByTag } from '../core/xml-query.js';
|
|
16
|
+
import { getDefaultAuthor } from '../adapters/config.js';
|
|
17
|
+
import {
|
|
18
|
+
createTrackChange,
|
|
19
|
+
createTextRun,
|
|
20
|
+
createFormattedRuns,
|
|
21
|
+
createTextRunWithRPrElement,
|
|
22
|
+
injectFormattingToRPr
|
|
23
|
+
} from './run-builders.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds minimal OOXML for a surgical text replacement with track changes.
|
|
27
|
+
*
|
|
28
|
+
* @param {Document} xmlDoc - XML document
|
|
29
|
+
* @param {Element} originalRun - Original run
|
|
30
|
+
* @param {string} textContent - Replacement text
|
|
31
|
+
* @param {string} author - Author name
|
|
32
|
+
* @param {string} dateStr - ISO date
|
|
33
|
+
* @param {Object} formatToRemove - Format flags to force off
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
function buildSurgicalReplacementOoxml(xmlDoc, originalRun, textContent, author, dateStr, formatToRemove) {
|
|
37
|
+
const authorName = author || getDefaultAuthor();
|
|
38
|
+
const delId = getNextRevisionId();
|
|
39
|
+
const insId = getNextRevisionId();
|
|
40
|
+
|
|
41
|
+
const serializer = createSerializer();
|
|
42
|
+
|
|
43
|
+
let rPrXml = '';
|
|
44
|
+
const rPr = getFirstElementByTag(originalRun, 'w:rPr');
|
|
45
|
+
if (rPr) {
|
|
46
|
+
rPrXml = serializer.serializeToString(rPr);
|
|
47
|
+
rPrXml = rPrXml.replace(/\s+xmlns:[^=]+="[^"]*"/g, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const unformattedRPrXml = buildOverrideRPrXml(xmlDoc, originalRun, formatToRemove, serializer);
|
|
51
|
+
|
|
52
|
+
const escapedText = textContent
|
|
53
|
+
.replace(/&/g, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>');
|
|
56
|
+
|
|
57
|
+
const delFragment = `<w:del xmlns:w="${NS_W}" w:id="${delId}" w:author="${authorName}" w:date="${dateStr}">` +
|
|
58
|
+
`<w:r>${rPrXml}<w:delText xml:space="preserve">${escapedText}</w:delText></w:r>` +
|
|
59
|
+
`</w:del>`;
|
|
60
|
+
|
|
61
|
+
const insFragment = `<w:ins xmlns:w="${NS_W}" w:id="${insId}" w:author="${authorName}" w:date="${dateStr}">` +
|
|
62
|
+
`<w:r>${unformattedRPrXml}<w:t xml:space="preserve">${escapedText}</w:t></w:r>` +
|
|
63
|
+
`</w:ins>`;
|
|
64
|
+
|
|
65
|
+
return `${delFragment}${insFragment}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Builds minimal OOXML for an unformatted run (without track wrappers).
|
|
70
|
+
*
|
|
71
|
+
* @param {Document} xmlDoc - XML document
|
|
72
|
+
* @param {Element} originalRun - Original run
|
|
73
|
+
* @param {string} textContent - Replacement text
|
|
74
|
+
* @param {Object} formatToRemove - Format flags to force off
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function buildUnformattedRunOoxml(xmlDoc, originalRun, textContent, formatToRemove) {
|
|
78
|
+
const serializer = createSerializer();
|
|
79
|
+
const unformattedRPrXml = buildOverrideRPrXml(xmlDoc, originalRun, formatToRemove, serializer);
|
|
80
|
+
|
|
81
|
+
const escapedText = textContent
|
|
82
|
+
.replace(/&/g, '&')
|
|
83
|
+
.replace(/</g, '<')
|
|
84
|
+
.replace(/>/g, '>');
|
|
85
|
+
|
|
86
|
+
return `<w:r xmlns:w="${NS_W}">${unformattedRPrXml}<w:t xml:space="preserve">${escapedText}</w:t></w:r>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Applies surgical mode reconciliation.
|
|
91
|
+
*
|
|
92
|
+
* @param {Document} xmlDoc - XML document
|
|
93
|
+
* @param {string} originalText - Original text
|
|
94
|
+
* @param {string} modifiedText - Modified text
|
|
95
|
+
* @param {XMLSerializer} serializer - Serializer instance
|
|
96
|
+
* @param {string} author - Author name
|
|
97
|
+
* @param {Array} formatHints - Format hints
|
|
98
|
+
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
99
|
+
* @param {Element|null} [targetParagraph=null] - Optional scope paragraph
|
|
100
|
+
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
101
|
+
*/
|
|
102
|
+
export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer, author, formatHints, generateRedlines = true, targetParagraph = null) {
|
|
103
|
+
let fullText = '';
|
|
104
|
+
const textSpans = [];
|
|
105
|
+
|
|
106
|
+
const allParagraphs = targetParagraph
|
|
107
|
+
? [targetParagraph]
|
|
108
|
+
: getDocumentParagraphs(xmlDoc);
|
|
109
|
+
|
|
110
|
+
allParagraphs.forEach((p, pIndex) => {
|
|
111
|
+
const container = p.parentNode;
|
|
112
|
+
|
|
113
|
+
for (let child = p.firstChild; child; child = child.nextSibling) {
|
|
114
|
+
if (child.nodeName === 'w:r') {
|
|
115
|
+
const runResult = processRunElement(child, p, container, fullText.length, textSpans);
|
|
116
|
+
fullText += runResult.text;
|
|
117
|
+
} else if (child.nodeName === 'w:hyperlink') {
|
|
118
|
+
for (let hc = child.firstChild; hc; hc = hc.nextSibling) {
|
|
119
|
+
if (hc.nodeName === 'w:r') {
|
|
120
|
+
const runResult = processRunElement(hc, p, container, fullText.length, textSpans);
|
|
121
|
+
fullText += runResult.text;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fullText = appendParagraphBoundary(fullText, pIndex, allParagraphs.length);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const diffs = computeWordDiffs(fullText, modifiedText);
|
|
131
|
+
const spanIndex = buildSpanIndex(textSpans);
|
|
132
|
+
|
|
133
|
+
let originalPos = 0;
|
|
134
|
+
let newPos = 0;
|
|
135
|
+
const processedSpans = new Set();
|
|
136
|
+
|
|
137
|
+
for (const [op, text] of diffs) {
|
|
138
|
+
if (op === 0) {
|
|
139
|
+
const len = text.length;
|
|
140
|
+
const startPos = originalPos;
|
|
141
|
+
const endPos = originalPos + len;
|
|
142
|
+
|
|
143
|
+
forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
|
|
144
|
+
const overlapStartOriginal = Math.max(span.charStart, startPos);
|
|
145
|
+
const overlapEndOriginal = Math.min(span.charEnd, endPos);
|
|
146
|
+
const segmentLen = overlapEndOriginal - overlapStartOriginal;
|
|
147
|
+
const relativeOffset = overlapStartOriginal - startPos;
|
|
148
|
+
const overlapStartNew = newPos + relativeOffset;
|
|
149
|
+
const overlapEndNew = overlapStartNew + segmentLen;
|
|
150
|
+
const applicableHints = getApplicableFormatHints(formatHints, overlapStartNew, overlapEndNew);
|
|
151
|
+
reconcileFormattingForTextSpan(xmlDoc, span, overlapStartOriginal, overlapEndOriginal, applicableHints, author, generateRedlines);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
originalPos += len;
|
|
155
|
+
newPos += len;
|
|
156
|
+
} else if (op === -1) {
|
|
157
|
+
processDelete(xmlDoc, spanIndex, originalPos, originalPos + text.length, processedSpans, author, generateRedlines);
|
|
158
|
+
originalPos += text.length;
|
|
159
|
+
} else if (op === 1) {
|
|
160
|
+
const textWithoutNewlines = text.replace(/\n/g, ' ');
|
|
161
|
+
if (textWithoutNewlines.trim().length > 0) {
|
|
162
|
+
processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, processedSpans, author, formatHints, newPos, generateRedlines);
|
|
163
|
+
}
|
|
164
|
+
newPos += text.length;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Reconciles formatting for a text span segment.
|
|
173
|
+
*
|
|
174
|
+
* @param {Document} xmlDoc - XML document
|
|
175
|
+
* @param {Object} span - Text span
|
|
176
|
+
* @param {number} start - Absolute start
|
|
177
|
+
* @param {number} end - Absolute end
|
|
178
|
+
* @param {Array} applicableHints - Applicable format hints
|
|
179
|
+
* @param {string} author - Author name
|
|
180
|
+
* @param {boolean} generateRedlines - Track change toggle
|
|
181
|
+
*/
|
|
182
|
+
function reconcileFormattingForTextSpan(xmlDoc, span, start, end, applicableHints, author, generateRedlines) {
|
|
183
|
+
const desiredFormat = {};
|
|
184
|
+
if (applicableHints.length > 0) {
|
|
185
|
+
applicableHints.forEach(h => Object.assign(desiredFormat, h.format));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const rPr = span.rPr;
|
|
189
|
+
const hasElement = (tagName) => {
|
|
190
|
+
if (!rPr) return false;
|
|
191
|
+
for (let node = rPr.firstChild; node; node = node.nextSibling) {
|
|
192
|
+
if (node.nodeName === tagName) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const existingFormat = {
|
|
200
|
+
bold: hasElement('w:b'),
|
|
201
|
+
italic: hasElement('w:i'),
|
|
202
|
+
underline: hasElement('w:u'),
|
|
203
|
+
strikethrough: hasElement('w:strike')
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
|
|
207
|
+
const changesNeeded = formatsToCheck.some(f => !!desiredFormat[f] !== existingFormat[f]);
|
|
208
|
+
|
|
209
|
+
if (!changesNeeded) return;
|
|
210
|
+
|
|
211
|
+
const parent = span.runElement.parentNode;
|
|
212
|
+
if (!parent) return;
|
|
213
|
+
|
|
214
|
+
const fullText = span.textElement.textContent || '';
|
|
215
|
+
const runStart = span.charStart;
|
|
216
|
+
|
|
217
|
+
const localStart = start - runStart;
|
|
218
|
+
const localEnd = end - runStart;
|
|
219
|
+
|
|
220
|
+
const beforeText = fullText.substring(0, localStart);
|
|
221
|
+
const affectedText = fullText.substring(localStart, localEnd);
|
|
222
|
+
const afterText = fullText.substring(localEnd);
|
|
223
|
+
|
|
224
|
+
if (beforeText.length > 0) {
|
|
225
|
+
const beforeRun = createTextRun(xmlDoc, beforeText, rPr, false);
|
|
226
|
+
parent.insertBefore(beforeRun, span.runElement);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const newRPr = injectFormattingToRPr(xmlDoc, rPr, desiredFormat, author, generateRedlines);
|
|
230
|
+
const newRun = createTextRunWithRPrElement(xmlDoc, affectedText, newRPr, false);
|
|
231
|
+
parent.insertBefore(newRun, span.runElement);
|
|
232
|
+
|
|
233
|
+
if (afterText.length > 0) {
|
|
234
|
+
const afterRun = createTextRun(xmlDoc, afterText, rPr, false);
|
|
235
|
+
parent.insertBefore(afterRun, span.runElement);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
parent.removeChild(span.runElement);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Processes a run and appends text span metadata.
|
|
243
|
+
*
|
|
244
|
+
* @param {Element} r - Run element
|
|
245
|
+
* @param {Element} p - Paragraph element
|
|
246
|
+
* @param {Element} container - Parent container
|
|
247
|
+
* @param {number} currentOffset - Absolute offset
|
|
248
|
+
* @param {Array} textSpans - Span collection
|
|
249
|
+
* @returns {{ nextOffset: number, text: string }}
|
|
250
|
+
*/
|
|
251
|
+
function processRunElement(r, p, container, currentOffset, textSpans) {
|
|
252
|
+
const rPr = getFirstElementByTag(r, 'w:rPr');
|
|
253
|
+
let localOffset = currentOffset;
|
|
254
|
+
const textParts = [];
|
|
255
|
+
|
|
256
|
+
for (let rc = r.firstChild; rc; rc = rc.nextSibling) {
|
|
257
|
+
if (rc.nodeName === 'w:t') {
|
|
258
|
+
const text = rc.textContent || '';
|
|
259
|
+
if (text.length > 0) {
|
|
260
|
+
textSpans.push({
|
|
261
|
+
charStart: localOffset,
|
|
262
|
+
charEnd: localOffset + text.length,
|
|
263
|
+
textElement: rc,
|
|
264
|
+
runElement: r,
|
|
265
|
+
paragraph: p,
|
|
266
|
+
container,
|
|
267
|
+
rPr
|
|
268
|
+
});
|
|
269
|
+
localOffset += text.length;
|
|
270
|
+
textParts.push(text);
|
|
271
|
+
}
|
|
272
|
+
} else if (rc.nodeName === 'w:br' || rc.nodeName === 'w:cr') {
|
|
273
|
+
textSpans.push({
|
|
274
|
+
charStart: localOffset,
|
|
275
|
+
charEnd: localOffset + 1,
|
|
276
|
+
textElement: rc,
|
|
277
|
+
runElement: r,
|
|
278
|
+
paragraph: p,
|
|
279
|
+
container,
|
|
280
|
+
rPr
|
|
281
|
+
});
|
|
282
|
+
localOffset += 1;
|
|
283
|
+
textParts.push('\n');
|
|
284
|
+
} else if (rc.nodeName === 'w:tab') {
|
|
285
|
+
textSpans.push({
|
|
286
|
+
charStart: localOffset,
|
|
287
|
+
charEnd: localOffset + 1,
|
|
288
|
+
textElement: rc,
|
|
289
|
+
runElement: r,
|
|
290
|
+
paragraph: p,
|
|
291
|
+
container,
|
|
292
|
+
rPr
|
|
293
|
+
});
|
|
294
|
+
localOffset += 1;
|
|
295
|
+
textParts.push('\t');
|
|
296
|
+
} else if (rc.nodeName === 'w:noBreakHyphen') {
|
|
297
|
+
textSpans.push({
|
|
298
|
+
charStart: localOffset,
|
|
299
|
+
charEnd: localOffset + 1,
|
|
300
|
+
textElement: rc,
|
|
301
|
+
runElement: r,
|
|
302
|
+
paragraph: p,
|
|
303
|
+
container,
|
|
304
|
+
rPr
|
|
305
|
+
});
|
|
306
|
+
localOffset += 1;
|
|
307
|
+
textParts.push('\u2011');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { nextOffset: localOffset, text: textParts.join('') };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Applies deletion over affected spans.
|
|
315
|
+
*
|
|
316
|
+
* @param {Document} xmlDoc - XML document
|
|
317
|
+
* @param {{ spans: Array, starts: number[], ends: number[] }} spanIndex - Indexed spans
|
|
318
|
+
* @param {number} startPos - Delete start
|
|
319
|
+
* @param {number} endPos - Delete end
|
|
320
|
+
* @param {Set<Node>} processedSpans - Processed marker set
|
|
321
|
+
* @param {string} author - Author name
|
|
322
|
+
* @param {boolean} generateRedlines - Track change toggle
|
|
323
|
+
*/
|
|
324
|
+
function processDelete(xmlDoc, spanIndex, startPos, endPos, processedSpans, author, generateRedlines) {
|
|
325
|
+
forEachOverlappingSpan(spanIndex, startPos, endPos, span => {
|
|
326
|
+
if (processedSpans.has(span.textElement)) return;
|
|
327
|
+
|
|
328
|
+
const deleteStart = Math.max(0, startPos - span.charStart);
|
|
329
|
+
const deleteEnd = Math.min(span.charEnd - span.charStart, endPos - span.charStart);
|
|
330
|
+
|
|
331
|
+
const originalText = span.textElement.textContent || '';
|
|
332
|
+
const beforeText = originalText.substring(0, deleteStart);
|
|
333
|
+
const deletedText = originalText.substring(deleteStart, deleteEnd);
|
|
334
|
+
const afterText = originalText.substring(deleteEnd);
|
|
335
|
+
|
|
336
|
+
if (deletedText.length === 0) return;
|
|
337
|
+
|
|
338
|
+
const parent = span.runElement.parentNode;
|
|
339
|
+
if (!parent) return;
|
|
340
|
+
|
|
341
|
+
if (beforeText.length === 0 && afterText.length === 0) {
|
|
342
|
+
const delRun = createTextRun(xmlDoc, deletedText, span.rPr, true);
|
|
343
|
+
if (generateRedlines) {
|
|
344
|
+
const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author);
|
|
345
|
+
parent.insertBefore(delWrapper, span.runElement);
|
|
346
|
+
}
|
|
347
|
+
parent.removeChild(span.runElement);
|
|
348
|
+
} else {
|
|
349
|
+
const oldRun = span.runElement;
|
|
350
|
+
|
|
351
|
+
if (beforeText.length > 0) {
|
|
352
|
+
const beforeRun = createTextRun(xmlDoc, beforeText, span.rPr, false);
|
|
353
|
+
parent.insertBefore(beforeRun, oldRun);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const delRun = createTextRun(xmlDoc, deletedText, span.rPr, true);
|
|
357
|
+
if (generateRedlines) {
|
|
358
|
+
const delWrapper = createTrackChange(xmlDoc, 'del', delRun, author);
|
|
359
|
+
parent.insertBefore(delWrapper, oldRun);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (afterText.length > 0) {
|
|
363
|
+
const afterRun = createTextRun(xmlDoc, afterText, span.rPr, false);
|
|
364
|
+
parent.insertBefore(afterRun, oldRun);
|
|
365
|
+
span.runElement = afterRun;
|
|
366
|
+
span.textElement = getFirstElementByTag(afterRun, 'w:t') || getFirstElementByTag(afterRun, 't');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
parent.removeChild(oldRun);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
processedSpans.add(span.textElement);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Inserts new text at an absolute position.
|
|
378
|
+
*
|
|
379
|
+
* @param {Document} xmlDoc - XML document
|
|
380
|
+
* @param {{ spans: Array, starts: number[], ends: number[] }} spanIndex - Indexed spans
|
|
381
|
+
* @param {number} pos - Insertion position
|
|
382
|
+
* @param {string} text - Text to insert
|
|
383
|
+
* @param {Set<Node>} processedSpans - Processed marker set (unused, kept for signature)
|
|
384
|
+
* @param {string} author - Author name
|
|
385
|
+
* @param {Array} [formatHints=[]] - Format hints
|
|
386
|
+
* @param {number} [insertOffset=0] - Offset in modified text
|
|
387
|
+
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
388
|
+
*/
|
|
389
|
+
function processInsert(xmlDoc, spanIndex, pos, text, processedSpans, author, formatHints = [], insertOffset = 0, generateRedlines = true) {
|
|
390
|
+
void processedSpans;
|
|
391
|
+
|
|
392
|
+
let targetSpan = findContainingSpan(spanIndex, pos);
|
|
393
|
+
|
|
394
|
+
if (!targetSpan && pos > 0) {
|
|
395
|
+
targetSpan = findFirstSpanEndingAt(spanIndex, pos);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!targetSpan && pos > 0) {
|
|
399
|
+
targetSpan = findLastSpanEndingBeforeOrAt(spanIndex, pos);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!targetSpan && spanIndex.spans.length > 0) {
|
|
403
|
+
targetSpan = spanIndex.spans[spanIndex.spans.length - 1];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (targetSpan) {
|
|
407
|
+
const applicableHints = getApplicableFormatHints(formatHints, insertOffset, insertOffset + text.length);
|
|
408
|
+
const baseRPr = targetSpan.rPr;
|
|
409
|
+
const parent = targetSpan.runElement.parentNode;
|
|
410
|
+
|
|
411
|
+
if (parent) {
|
|
412
|
+
const referenceNode = (pos === targetSpan.charStart) ? targetSpan.runElement : targetSpan.runElement.nextSibling;
|
|
413
|
+
|
|
414
|
+
if (applicableHints.length === 0) {
|
|
415
|
+
const insRun = createTextRun(xmlDoc, text, baseRPr, false);
|
|
416
|
+
if (generateRedlines) {
|
|
417
|
+
const insWrapper = createTrackChange(xmlDoc, 'ins', insRun, author);
|
|
418
|
+
parent.insertBefore(insWrapper, referenceNode);
|
|
419
|
+
} else {
|
|
420
|
+
parent.insertBefore(insRun, referenceNode);
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
const runs = createFormattedRuns(xmlDoc, text, baseRPr, applicableHints, insertOffset, author, generateRedlines);
|
|
424
|
+
|
|
425
|
+
if (generateRedlines) {
|
|
426
|
+
const insWrapper = createTrackChange(xmlDoc, 'ins', null, author);
|
|
427
|
+
runs.forEach(run => insWrapper.appendChild(run));
|
|
428
|
+
parent.insertBefore(insWrapper, referenceNode);
|
|
429
|
+
} else {
|
|
430
|
+
runs.forEach(run => parent.insertBefore(run, referenceNode));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function buildSpanIndex(textSpans) {
|
|
438
|
+
const spans = textSpans
|
|
439
|
+
.slice()
|
|
440
|
+
.sort((a, b) => a.charStart - b.charStart || a.charEnd - b.charEnd);
|
|
441
|
+
|
|
442
|
+
const starts = spans.map(span => span.charStart);
|
|
443
|
+
const ends = spans.map(span => span.charEnd);
|
|
444
|
+
|
|
445
|
+
return { spans, starts, ends };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function upperBound(values, target) {
|
|
449
|
+
let left = 0;
|
|
450
|
+
let right = values.length;
|
|
451
|
+
|
|
452
|
+
while (left < right) {
|
|
453
|
+
const middle = (left + right) >> 1;
|
|
454
|
+
if (values[middle] <= target) {
|
|
455
|
+
left = middle + 1;
|
|
456
|
+
} else {
|
|
457
|
+
right = middle;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return left;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function lowerBound(values, target) {
|
|
465
|
+
let left = 0;
|
|
466
|
+
let right = values.length;
|
|
467
|
+
|
|
468
|
+
while (left < right) {
|
|
469
|
+
const middle = (left + right) >> 1;
|
|
470
|
+
if (values[middle] < target) {
|
|
471
|
+
left = middle + 1;
|
|
472
|
+
} else {
|
|
473
|
+
right = middle;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return left;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function forEachOverlappingSpan(spanIndex, startPos, endPos, callback) {
|
|
481
|
+
if (endPos <= startPos || spanIndex.spans.length === 0) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let index = upperBound(spanIndex.ends, startPos);
|
|
486
|
+
while (index < spanIndex.spans.length) {
|
|
487
|
+
const span = spanIndex.spans[index];
|
|
488
|
+
if (span.charStart >= endPos) {
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
callback(span);
|
|
492
|
+
index++;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function findContainingSpan(spanIndex, pos) {
|
|
497
|
+
if (spanIndex.spans.length === 0) return null;
|
|
498
|
+
|
|
499
|
+
const index = upperBound(spanIndex.starts, pos) - 1;
|
|
500
|
+
if (index < 0) return null;
|
|
501
|
+
|
|
502
|
+
const span = spanIndex.spans[index];
|
|
503
|
+
return pos >= span.charStart && pos < span.charEnd ? span : null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function findFirstSpanEndingAt(spanIndex, pos) {
|
|
507
|
+
const index = lowerBound(spanIndex.ends, pos);
|
|
508
|
+
if (index < spanIndex.spans.length && spanIndex.ends[index] === pos) {
|
|
509
|
+
return spanIndex.spans[index];
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function findLastSpanEndingBeforeOrAt(spanIndex, pos) {
|
|
515
|
+
const index = upperBound(spanIndex.ends, pos) - 1;
|
|
516
|
+
if (index >= 0) {
|
|
517
|
+
return spanIndex.spans[index];
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table-cell context helpers.
|
|
3
|
+
*
|
|
4
|
+
* Handles detection of table-wrapped paragraph OOXML and extraction of only
|
|
5
|
+
* target paragraph content for safe `insertOoxml` replacement.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getDocumentParagraphs } from './format-extraction.js';
|
|
9
|
+
import { log } from '../adapters/logger.js';
|
|
10
|
+
import { buildParagraphOnlyPackage } from '../services/package-builder.js';
|
|
11
|
+
import { getElementsByTag } from '../core/xml-query.js';
|
|
12
|
+
|
|
13
|
+
const W14_NS = 'http://schemas.microsoft.com/office/word/2010/wordml';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detects whether the current XML is table-wrapped and resolves target paragraph context.
|
|
17
|
+
*
|
|
18
|
+
* @param {Document} xmlDoc - XML document
|
|
19
|
+
* @param {string} originalText - Source paragraph text
|
|
20
|
+
* @param {Object} [options={}] - Detection options
|
|
21
|
+
* @param {string|null} [options.targetParagraphId=null] - Preferred paragraph id (`w14:paraId`)
|
|
22
|
+
* @returns {{
|
|
23
|
+
* hasTableWrapper: boolean,
|
|
24
|
+
* isTableCellParagraph: boolean,
|
|
25
|
+
* targetParagraph?: Element|null,
|
|
26
|
+
* paragraphs: Element[],
|
|
27
|
+
* paragraph: Element|null,
|
|
28
|
+
* tableElement: Element|null
|
|
29
|
+
* }}
|
|
30
|
+
*/
|
|
31
|
+
export function detectTableCellContext(xmlDoc, originalText, options = {}) {
|
|
32
|
+
const { targetParagraphId = null } = options;
|
|
33
|
+
const tables = getElementsByTag(xmlDoc, 'w:tbl');
|
|
34
|
+
if (tables.length === 0) {
|
|
35
|
+
return { hasTableWrapper: false, isTableCellParagraph: false, paragraphs: [], paragraph: null, tableElement: null };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const allParagraphs = getDocumentParagraphs(xmlDoc);
|
|
39
|
+
const paragraphsInCells = allParagraphs.filter(p => {
|
|
40
|
+
let parent = p.parentNode;
|
|
41
|
+
while (parent) {
|
|
42
|
+
if (parent.nodeName === 'w:tc') return true;
|
|
43
|
+
parent = parent.parentNode;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
log(`[OxmlEngine] Table wrapper detected: ${tables.length} tables, ${paragraphsInCells.length} paragraphs in cells`);
|
|
49
|
+
|
|
50
|
+
let targetParagraph = null;
|
|
51
|
+
|
|
52
|
+
// Most reliable selector when available (avoids ambiguous duplicate text matches in tables).
|
|
53
|
+
if (targetParagraphId) {
|
|
54
|
+
const normalizedTargetId = String(targetParagraphId).toUpperCase();
|
|
55
|
+
targetParagraph = paragraphsInCells.find(p => {
|
|
56
|
+
const paragraphId = getParagraphId(p);
|
|
57
|
+
return paragraphId && paragraphId.toUpperCase() === normalizedTargetId;
|
|
58
|
+
}) || null;
|
|
59
|
+
|
|
60
|
+
if (targetParagraph) {
|
|
61
|
+
log(`[OxmlEngine] Found target paragraph by paraId: "${targetParagraphId}"`);
|
|
62
|
+
} else {
|
|
63
|
+
log(`[OxmlEngine] paraId "${targetParagraphId}" not found in wrapper, falling back to text match`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (originalText && originalText.trim()) {
|
|
68
|
+
const normalizedTarget = originalText.trim();
|
|
69
|
+
if (!targetParagraph) {
|
|
70
|
+
for (const p of paragraphsInCells) {
|
|
71
|
+
const textNodes = getElementsByTag(p, 'w:t');
|
|
72
|
+
let paragraphText = '';
|
|
73
|
+
for (const t of textNodes) {
|
|
74
|
+
paragraphText += t.textContent || '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (paragraphText.trim() === normalizedTarget) {
|
|
78
|
+
targetParagraph = p;
|
|
79
|
+
log(`[OxmlEngine] Found target paragraph by text match: "${normalizedTarget.substring(0, 30)}..."`);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
hasTableWrapper: true,
|
|
88
|
+
isTableCellParagraph: paragraphsInCells.length > 0,
|
|
89
|
+
targetParagraph,
|
|
90
|
+
paragraphs: paragraphsInCells,
|
|
91
|
+
paragraph: targetParagraph || paragraphsInCells[0] || null,
|
|
92
|
+
tableElement: tables[0]
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Serializes one or more paragraphs without surrounding table wrappers.
|
|
98
|
+
*
|
|
99
|
+
* @param {Document} xmlDoc - XML document (unused, kept for signature compatibility)
|
|
100
|
+
* @param {Element|Element[]} paragraphs - Paragraph or paragraph array
|
|
101
|
+
* @param {XMLSerializer} serializer - Serializer instance
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function serializeParagraphOnly(xmlDoc, paragraphs, serializer) {
|
|
105
|
+
const paragraphArray = Array.isArray(paragraphs) ? paragraphs : [paragraphs];
|
|
106
|
+
|
|
107
|
+
let combinedXml = '';
|
|
108
|
+
for (const p of paragraphArray) {
|
|
109
|
+
if (!p) continue;
|
|
110
|
+
let pXml = serializer.serializeToString(p);
|
|
111
|
+
pXml = pXml.replace(/\s+xmlns:w="[^"]*"/g, '');
|
|
112
|
+
pXml = pXml.replace(/\s+xmlns:r="[^"]*"/g, '');
|
|
113
|
+
pXml = pXml.replace(/\s+xmlns:wp="[^"]*"/g, '');
|
|
114
|
+
combinedXml += pXml;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
log(`[OxmlEngine] Stripping table wrapper, serializing ${paragraphArray.length} paragraphs`);
|
|
118
|
+
log(`[OxmlEngine] Paragraph XML preview: ${combinedXml.substring(0, 200)}...`);
|
|
119
|
+
|
|
120
|
+
return wrapParagraphInPackage(combinedXml);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Wraps paragraph XML in a complete OOXML package.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} paragraphXml - Paragraph-only XML
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function wrapParagraphInPackage(paragraphXml) {
|
|
130
|
+
return buildParagraphOnlyPackage(paragraphXml);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Reads the best available paragraph identity from OOXML attributes.
|
|
135
|
+
*
|
|
136
|
+
* @param {Element} paragraph - Paragraph element
|
|
137
|
+
* @returns {string|null}
|
|
138
|
+
*/
|
|
139
|
+
function getParagraphId(paragraph) {
|
|
140
|
+
if (!paragraph) return null;
|
|
141
|
+
|
|
142
|
+
const namespacedId = typeof paragraph.getAttributeNS === 'function'
|
|
143
|
+
? paragraph.getAttributeNS(W14_NS, 'paraId')
|
|
144
|
+
: null;
|
|
145
|
+
if (namespacedId) return namespacedId;
|
|
146
|
+
|
|
147
|
+
return paragraph.getAttribute('w14:paraId')
|
|
148
|
+
|| paragraph.getAttribute('w:paraId')
|
|
149
|
+
|| paragraph.getAttribute('paraId')
|
|
150
|
+
|| null;
|
|
151
|
+
}
|