@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,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting application orchestration utilities.
|
|
3
|
+
*
|
|
4
|
+
* Applies format-only changes and surgical formatting synchronization over
|
|
5
|
+
* existing OOXML runs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mergeFormats } from '../pipeline/markdown-processor.js';
|
|
9
|
+
import { applyFormatOverridesToRPr, extractFormatFromRPr } from './rpr-helpers.js';
|
|
10
|
+
import { snapshotAndAttachRPrChange, injectFormattingToRPr } from './run-builders.js';
|
|
11
|
+
import { getDocumentParagraphs, buildTextSpansFromParagraphs } from './format-extraction.js';
|
|
12
|
+
import { buildParagraphInfos, findTargetParagraphInfo } from './format-paragraph-targeting.js';
|
|
13
|
+
import { splitSpansAtBoundaries, applyFormatHintsToSpansRobust } from './format-span-application.js';
|
|
14
|
+
import { getRevisionTimestamp } from '../core/types.js';
|
|
15
|
+
import { warn, log } from '../adapters/logger.js';
|
|
16
|
+
import { getFirstElementByTag } from '../core/xml-query.js';
|
|
17
|
+
import { getDefaultAuthor } from '../adapters/config.js';
|
|
18
|
+
|
|
19
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
20
|
+
|
|
21
|
+
function isWordElement(node, localName) {
|
|
22
|
+
if (!node || node.nodeType !== 1) return false;
|
|
23
|
+
if (node.namespaceURI === NS_W && node.localName === localName) return true;
|
|
24
|
+
const nodeName = String(node.nodeName || '');
|
|
25
|
+
return nodeName === `w:${localName}` || nodeName === localName;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizePrecomputedFormatContext(precomputedContext) {
|
|
29
|
+
if (Array.isArray(precomputedContext)) {
|
|
30
|
+
return {
|
|
31
|
+
textSpans: precomputedContext,
|
|
32
|
+
paragraphs: null,
|
|
33
|
+
paragraphInfos: null
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!precomputedContext || typeof precomputedContext !== 'object') {
|
|
38
|
+
return {
|
|
39
|
+
textSpans: null,
|
|
40
|
+
paragraphs: null,
|
|
41
|
+
paragraphInfos: null
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
textSpans: Array.isArray(precomputedContext.textSpans) ? precomputedContext.textSpans : null,
|
|
47
|
+
paragraphs: Array.isArray(precomputedContext.paragraphs) ? precomputedContext.paragraphs : null,
|
|
48
|
+
paragraphInfos: Array.isArray(precomputedContext.paragraphInfos) ? precomputedContext.paragraphInfos : null
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Removes existing core formatting via `w:rPrChange` snapshots and explicit overrides.
|
|
54
|
+
*
|
|
55
|
+
* @param {Document} xmlDoc - XML document
|
|
56
|
+
* @param {Array} textSpans - Text spans (unused, kept for compatibility)
|
|
57
|
+
* @param {Array} existingFormatHints - Existing formatting hints
|
|
58
|
+
* @param {XMLSerializer} serializer - Serializer instance
|
|
59
|
+
* @param {string} author - Change author
|
|
60
|
+
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
61
|
+
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
62
|
+
*/
|
|
63
|
+
export function applyFormatRemovalAsSurgicalReplacement(xmlDoc, textSpans, existingFormatHints, serializer, author, generateRedlines = true) {
|
|
64
|
+
void textSpans;
|
|
65
|
+
let hasAnyChanges = false;
|
|
66
|
+
const processedRuns = new Set();
|
|
67
|
+
const dateStr = getRevisionTimestamp();
|
|
68
|
+
|
|
69
|
+
log(`[OxmlEngine] Surgical format removal: ${existingFormatHints.length} hints to process (using w:rPrChange)`);
|
|
70
|
+
|
|
71
|
+
for (const hint of existingFormatHints) {
|
|
72
|
+
const run = hint.run;
|
|
73
|
+
if (processedRuns.has(run)) continue;
|
|
74
|
+
processedRuns.add(run);
|
|
75
|
+
if (!run.parentNode) continue;
|
|
76
|
+
|
|
77
|
+
log('[OxmlEngine] Processing run for surgical format removal, format:', hint.format);
|
|
78
|
+
|
|
79
|
+
let rPr = getFirstElementByTag(run, 'w:rPr');
|
|
80
|
+
if (!rPr) {
|
|
81
|
+
rPr = xmlDoc.createElement('w:rPr');
|
|
82
|
+
run.insertBefore(rPr, run.firstChild);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (generateRedlines) {
|
|
86
|
+
snapshotAndAttachRPrChange(xmlDoc, rPr, author || getDefaultAuthor(), dateStr);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
applyFormatOverridesToRPr(xmlDoc, rPr, hint.format);
|
|
90
|
+
hasAnyChanges = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (hasAnyChanges) {
|
|
94
|
+
log('[OxmlEngine] Surgical format removal completed successfully (Pure Format Mode)');
|
|
95
|
+
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
log('[OxmlEngine] No format changes were applied');
|
|
99
|
+
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Applies format additions by synchronizing run-level target state after boundary splitting.
|
|
104
|
+
*
|
|
105
|
+
* @param {Document} xmlDoc - XML document
|
|
106
|
+
* @param {Array} textSpans - Extracted text spans
|
|
107
|
+
* @param {Array} formatHints - Format hints
|
|
108
|
+
* @param {XMLSerializer} serializer - Serializer instance
|
|
109
|
+
* @param {string} author - Change author
|
|
110
|
+
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
111
|
+
* @returns {{ oxml: string, hasChanges: boolean }}
|
|
112
|
+
*/
|
|
113
|
+
export function applyFormatAdditionsAsSurgicalReplacement(xmlDoc, textSpans, formatHints, serializer, author, generateRedlines = true) {
|
|
114
|
+
let hasAnyChanges = false;
|
|
115
|
+
const processedRuns = new Set();
|
|
116
|
+
|
|
117
|
+
if (!textSpans || textSpans.length === 0) {
|
|
118
|
+
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: false };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const boundaries = [];
|
|
122
|
+
for (const hint of formatHints) {
|
|
123
|
+
boundaries.push(hint.start, hint.end);
|
|
124
|
+
}
|
|
125
|
+
const currentSpans = splitSpansAtBoundaries(xmlDoc, textSpans, boundaries);
|
|
126
|
+
const orderedSpans = currentSpans
|
|
127
|
+
.slice()
|
|
128
|
+
.sort((a, b) => a.charStart - b.charStart || a.charEnd - b.charEnd);
|
|
129
|
+
const getOverlappingHints = createFormatHintOverlapLookup(formatHints);
|
|
130
|
+
|
|
131
|
+
for (const span of orderedSpans) {
|
|
132
|
+
if (!span || !span.textElement || !isWordElement(span.textElement, 't')) continue;
|
|
133
|
+
|
|
134
|
+
const applicableHints = getOverlappingHints(span.charStart, span.charEnd);
|
|
135
|
+
if (applicableHints.length === 0) continue;
|
|
136
|
+
|
|
137
|
+
const mergedDesiredFormat = mergeFormats(...applicableHints.map(h => h.format));
|
|
138
|
+
const desiredFormat = {
|
|
139
|
+
bold: !!mergedDesiredFormat.bold,
|
|
140
|
+
italic: !!mergedDesiredFormat.italic,
|
|
141
|
+
underline: !!mergedDesiredFormat.underline,
|
|
142
|
+
strikethrough: !!mergedDesiredFormat.strikethrough
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const existingFormatRaw = span.format || extractFormatFromRPr(span.rPr);
|
|
146
|
+
const existingFormat = {
|
|
147
|
+
bold: !!existingFormatRaw.bold,
|
|
148
|
+
italic: !!existingFormatRaw.italic,
|
|
149
|
+
underline: !!existingFormatRaw.underline,
|
|
150
|
+
strikethrough: !!existingFormatRaw.strikethrough
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const formatsToCheck = ['bold', 'italic', 'underline', 'strikethrough'];
|
|
154
|
+
const needsSync = formatsToCheck.some(f => desiredFormat[f] !== existingFormat[f]);
|
|
155
|
+
if (!needsSync) continue;
|
|
156
|
+
|
|
157
|
+
if (processedRuns.has(span.runElement)) continue;
|
|
158
|
+
processedRuns.add(span.runElement);
|
|
159
|
+
|
|
160
|
+
const textContent = span.textElement.textContent || '';
|
|
161
|
+
if (!textContent) continue;
|
|
162
|
+
|
|
163
|
+
const parentNode = span.runElement.parentNode;
|
|
164
|
+
if (!parentNode) continue;
|
|
165
|
+
|
|
166
|
+
const run = span.runElement;
|
|
167
|
+
const baseRPr = getFirstElementByTag(run, 'w:rPr');
|
|
168
|
+
const syncedRPr = injectFormattingToRPr(
|
|
169
|
+
xmlDoc,
|
|
170
|
+
baseRPr,
|
|
171
|
+
desiredFormat,
|
|
172
|
+
author || getDefaultAuthor(),
|
|
173
|
+
generateRedlines
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
let rPr = baseRPr;
|
|
177
|
+
if (!rPr) {
|
|
178
|
+
run.insertBefore(syncedRPr, run.firstChild);
|
|
179
|
+
} else {
|
|
180
|
+
while (rPr.firstChild) {
|
|
181
|
+
rPr.removeChild(rPr.firstChild);
|
|
182
|
+
}
|
|
183
|
+
Array.from(syncedRPr.childNodes).forEach(child => rPr.appendChild(child));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
hasAnyChanges = true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: hasAnyChanges };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Applies formatting changes to existing text without modifying content.
|
|
194
|
+
* Used when markdown formatting is applied to unchanged text.
|
|
195
|
+
*
|
|
196
|
+
* @param {Document} xmlDoc - XML document
|
|
197
|
+
* @param {string} originalText - Original plain text
|
|
198
|
+
* @param {Array} formatHints - Format hints
|
|
199
|
+
* @param {XMLSerializer} serializer - Serializer instance
|
|
200
|
+
* @param {string} author - Change author
|
|
201
|
+
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
202
|
+
* @param {Array|Object|null} [precomputedContext=null] - Optional precomputed format context
|
|
203
|
+
* @returns {{ oxml?: string, hasChanges: boolean, useNativeApi?: boolean, formatHints?: Array, originalText?: string }}
|
|
204
|
+
*/
|
|
205
|
+
export function applyFormatOnlyChanges(xmlDoc, originalText, formatHints, serializer, author, generateRedlines = true, precomputedContext = null) {
|
|
206
|
+
const precomputed = normalizePrecomputedFormatContext(precomputedContext);
|
|
207
|
+
const allParagraphs = precomputed.paragraphs || getDocumentParagraphs(xmlDoc);
|
|
208
|
+
|
|
209
|
+
let textSpans = precomputed.textSpans || [];
|
|
210
|
+
if (!precomputed.textSpans) {
|
|
211
|
+
({ textSpans } = buildTextSpansFromParagraphs(allParagraphs));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!textSpans || textSpans.length === 0) {
|
|
215
|
+
warn('[OxmlEngine] No spans available for format-only change; requesting caller fallback strategy');
|
|
216
|
+
return {
|
|
217
|
+
hasChanges: true,
|
|
218
|
+
useNativeApi: true,
|
|
219
|
+
formatHints,
|
|
220
|
+
originalText
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!formatHints || formatHints.length === 0) {
|
|
225
|
+
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: false };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const paragraphInfos = precomputed.paragraphInfos || buildParagraphInfos(xmlDoc, allParagraphs, textSpans);
|
|
229
|
+
const { targetInfo, matchOffset } = findTargetParagraphInfo(paragraphInfos, originalText);
|
|
230
|
+
|
|
231
|
+
if (!targetInfo || !targetInfo.spans || targetInfo.spans.length === 0) {
|
|
232
|
+
warn('[OxmlEngine] Unable to pinpoint target paragraph for format-only change; requesting caller fallback strategy');
|
|
233
|
+
return {
|
|
234
|
+
hasChanges: true,
|
|
235
|
+
useNativeApi: true,
|
|
236
|
+
formatHints,
|
|
237
|
+
originalText
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const baseOffset = targetInfo.spans[0].charStart;
|
|
242
|
+
const localizedSpans = targetInfo.spans.map(span => ({
|
|
243
|
+
...span,
|
|
244
|
+
charStart: span.charStart - baseOffset,
|
|
245
|
+
charEnd: span.charEnd - baseOffset
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
const adjustedHints = formatHints.map(hint => ({
|
|
249
|
+
...hint,
|
|
250
|
+
start: hint.start + matchOffset,
|
|
251
|
+
end: hint.end + matchOffset
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
applyFormatHintsToSpansRobust(xmlDoc, localizedSpans, adjustedHints, author, generateRedlines);
|
|
255
|
+
|
|
256
|
+
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: true };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Surgical variant of format-only changes.
|
|
261
|
+
*
|
|
262
|
+
* @param {Document} xmlDoc - XML document
|
|
263
|
+
* @param {string} originalText - Original plain text
|
|
264
|
+
* @param {Array} formatHints - Format hints
|
|
265
|
+
* @param {XMLSerializer} serializer - Serializer instance
|
|
266
|
+
* @param {string} author - Change author
|
|
267
|
+
* @param {boolean} [generateRedlines=true] - Track change toggle
|
|
268
|
+
* @param {Array|Object|null} [precomputedContext=null] - Optional precomputed format context
|
|
269
|
+
* @returns {{ oxml?: string, hasChanges: boolean, useNativeApi?: boolean, formatHints?: Array, originalText?: string }}
|
|
270
|
+
*/
|
|
271
|
+
export function applyFormatOnlyChangesSurgical(xmlDoc, originalText, formatHints, serializer, author, generateRedlines = true, precomputedContext = null) {
|
|
272
|
+
const precomputed = normalizePrecomputedFormatContext(precomputedContext);
|
|
273
|
+
const allParagraphs = precomputed.paragraphs || getDocumentParagraphs(xmlDoc);
|
|
274
|
+
|
|
275
|
+
let textSpans = precomputed.textSpans || [];
|
|
276
|
+
if (!precomputed.textSpans) {
|
|
277
|
+
({ textSpans } = buildTextSpansFromParagraphs(allParagraphs));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!textSpans || textSpans.length === 0) {
|
|
281
|
+
warn('[OxmlEngine] No spans available for surgical format-only change; requesting caller fallback strategy');
|
|
282
|
+
return {
|
|
283
|
+
hasChanges: true,
|
|
284
|
+
useNativeApi: true,
|
|
285
|
+
formatHints,
|
|
286
|
+
originalText
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!formatHints || formatHints.length === 0) {
|
|
291
|
+
return { oxml: serializer.serializeToString(xmlDoc), hasChanges: false };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const paragraphInfos = precomputed.paragraphInfos || buildParagraphInfos(xmlDoc, allParagraphs, textSpans);
|
|
295
|
+
const { targetInfo, matchOffset } = findTargetParagraphInfo(paragraphInfos, originalText);
|
|
296
|
+
|
|
297
|
+
if (!targetInfo || !targetInfo.spans || targetInfo.spans.length === 0) {
|
|
298
|
+
warn('[OxmlEngine] Unable to pinpoint target paragraph for surgical format-only change; requesting caller fallback strategy');
|
|
299
|
+
return {
|
|
300
|
+
hasChanges: true,
|
|
301
|
+
useNativeApi: true,
|
|
302
|
+
formatHints,
|
|
303
|
+
originalText
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const baseOffset = targetInfo.spans[0].charStart;
|
|
308
|
+
const localizedSpans = targetInfo.spans.map(span => ({
|
|
309
|
+
...span,
|
|
310
|
+
charStart: span.charStart - baseOffset,
|
|
311
|
+
charEnd: span.charEnd - baseOffset
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
const adjustedHints = formatHints.map(hint => ({
|
|
315
|
+
...hint,
|
|
316
|
+
start: hint.start + matchOffset,
|
|
317
|
+
end: hint.end + matchOffset
|
|
318
|
+
}));
|
|
319
|
+
|
|
320
|
+
return applyFormatAdditionsAsSurgicalReplacement(
|
|
321
|
+
xmlDoc,
|
|
322
|
+
localizedSpans,
|
|
323
|
+
adjustedHints,
|
|
324
|
+
serializer,
|
|
325
|
+
author,
|
|
326
|
+
generateRedlines
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Builds a sweep-line overlap lookup for format hints.
|
|
332
|
+
*
|
|
333
|
+
* @param {Array} formatHints - Format hints
|
|
334
|
+
* @returns {(start:number, end:number)=>Array}
|
|
335
|
+
*/
|
|
336
|
+
function createFormatHintOverlapLookup(formatHints) {
|
|
337
|
+
const sortedHints = (formatHints || [])
|
|
338
|
+
.slice()
|
|
339
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
340
|
+
|
|
341
|
+
const activeHints = [];
|
|
342
|
+
let nextHintIndex = 0;
|
|
343
|
+
|
|
344
|
+
return (start, end) => {
|
|
345
|
+
while (nextHintIndex < sortedHints.length && sortedHints[nextHintIndex].start < end) {
|
|
346
|
+
activeHints.push(sortedHints[nextHintIndex]);
|
|
347
|
+
nextHintIndex++;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (let i = activeHints.length - 1; i >= 0; i--) {
|
|
351
|
+
if (activeHints[i].end <= start) {
|
|
352
|
+
activeHints.splice(i, 1);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return activeHints.filter(hint => hint.start < end && hint.end > start);
|
|
357
|
+
};
|
|
358
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML format extraction utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides shared paragraph filtering, text-span extraction, and run-format
|
|
5
|
+
* extraction for formatting-aware reconciliation flows.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { extractFormatFromRPr } from './rpr-helpers.js';
|
|
9
|
+
import { advanceOffsetForParagraphBoundary } from '../core/paragraph-offset-policy.js';
|
|
10
|
+
import { log } from '../adapters/logger.js';
|
|
11
|
+
import { getElementsByTag, getElementsByTagNS, getFirstElementByTag } from '../core/xml-query.js';
|
|
12
|
+
|
|
13
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
14
|
+
|
|
15
|
+
function isWordElement(node, localName) {
|
|
16
|
+
if (!node || node.nodeType !== 1) return false;
|
|
17
|
+
if (node.namespaceURI === NS_W && node.localName === localName) return true;
|
|
18
|
+
const nodeName = String(node.nodeName || '');
|
|
19
|
+
return nodeName === `w:${localName}` || nodeName === localName;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isExcludedRevisionContainer(node) {
|
|
23
|
+
if (!node || node.nodeType !== 1 || node.namespaceURI !== NS_W) return false;
|
|
24
|
+
return node.localName === 'del' || node.localName === 'moveFrom';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function collectParagraphRuns(paragraph) {
|
|
28
|
+
const runs = [];
|
|
29
|
+
const stack = Array.from(paragraph?.childNodes || []).reverse();
|
|
30
|
+
|
|
31
|
+
while (stack.length > 0) {
|
|
32
|
+
const node = stack.pop();
|
|
33
|
+
if (!node || node.nodeType !== 1) continue;
|
|
34
|
+
if (isExcludedRevisionContainer(node)) continue;
|
|
35
|
+
|
|
36
|
+
if (isWordElement(node, 'r')) {
|
|
37
|
+
runs.push(node);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const children = Array.from(node.childNodes || []);
|
|
42
|
+
for (let i = children.length - 1; i >= 0; i -= 1) {
|
|
43
|
+
stack.push(children[i]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return runs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns only document body paragraphs, excluding comments/footnotes/endnotes.
|
|
52
|
+
*
|
|
53
|
+
* @param {Document} xmlDoc - XML document
|
|
54
|
+
* @returns {Element[]}
|
|
55
|
+
*/
|
|
56
|
+
export function getDocumentParagraphs(xmlDoc) {
|
|
57
|
+
const excludedContainers = new Set(['comment', 'footnote', 'endnote']);
|
|
58
|
+
const allParagraphs = getElementsByTagNS(xmlDoc, '*', 'p');
|
|
59
|
+
|
|
60
|
+
return allParagraphs.filter(p => {
|
|
61
|
+
let node = p.parentNode;
|
|
62
|
+
while (node && node.nodeName) {
|
|
63
|
+
const localName = String(node.localName || '').toLowerCase();
|
|
64
|
+
if (excludedContainers.has(localName)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
node = node.parentNode;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds linear text spans from paragraph elements.
|
|
75
|
+
*
|
|
76
|
+
* @param {Element[]} paragraphs - Paragraph nodes
|
|
77
|
+
* @returns {{ textSpans: Array, charOffset: number }}
|
|
78
|
+
*/
|
|
79
|
+
export function buildTextSpansFromParagraphs(paragraphs) {
|
|
80
|
+
const textSpans = [];
|
|
81
|
+
let charOffset = 0;
|
|
82
|
+
|
|
83
|
+
for (let pIndex = 0; pIndex < paragraphs.length; pIndex++) {
|
|
84
|
+
const p = paragraphs[pIndex];
|
|
85
|
+
const runs = collectParagraphRuns(p);
|
|
86
|
+
for (const run of runs) {
|
|
87
|
+
const rPr = getFirstElementByTag(run, 'w:rPr');
|
|
88
|
+
Array.from(run.childNodes || []).forEach(rc => {
|
|
89
|
+
if (isWordElement(rc, 't')) {
|
|
90
|
+
const text = rc.textContent || '';
|
|
91
|
+
if (text.length > 0) {
|
|
92
|
+
textSpans.push({
|
|
93
|
+
charStart: charOffset,
|
|
94
|
+
charEnd: charOffset + text.length,
|
|
95
|
+
textElement: rc,
|
|
96
|
+
runElement: run,
|
|
97
|
+
paragraph: p,
|
|
98
|
+
container: run.parentNode,
|
|
99
|
+
rPr
|
|
100
|
+
});
|
|
101
|
+
charOffset += text.length;
|
|
102
|
+
}
|
|
103
|
+
} else if (isWordElement(rc, 'br') || isWordElement(rc, 'cr') || isWordElement(rc, 'tab') || isWordElement(rc, 'noBreakHyphen')) {
|
|
104
|
+
textSpans.push({
|
|
105
|
+
charStart: charOffset,
|
|
106
|
+
charEnd: charOffset + 1,
|
|
107
|
+
textElement: rc,
|
|
108
|
+
runElement: run,
|
|
109
|
+
paragraph: p,
|
|
110
|
+
container: run.parentNode,
|
|
111
|
+
rPr
|
|
112
|
+
});
|
|
113
|
+
charOffset += 1;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
charOffset = advanceOffsetForParagraphBoundary(charOffset, pIndex, paragraphs.length);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { textSpans, charOffset };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Processes a single run element and appends extracted spans/hints.
|
|
125
|
+
*
|
|
126
|
+
* @param {Element} run - `w:r` element
|
|
127
|
+
* @param {Element} paragraph - Parent paragraph
|
|
128
|
+
* @param {number} charOffset - Start offset
|
|
129
|
+
* @param {Array} textSpans - Span collection (mutated)
|
|
130
|
+
* @param {Array} formatHints - Format hint collection (mutated)
|
|
131
|
+
* @param {Object|null} pFormat - Paragraph-level run format defaults
|
|
132
|
+
* @returns {number}
|
|
133
|
+
*/
|
|
134
|
+
export function processRunForFormatting(run, paragraph, charOffset, textSpans, formatHints, pFormat = null) {
|
|
135
|
+
let rPr = null;
|
|
136
|
+
for (const child of Array.from(run.childNodes)) {
|
|
137
|
+
if (isWordElement(child, 'rPr')) {
|
|
138
|
+
rPr = child;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const format = extractFormatFromRPr(rPr);
|
|
144
|
+
|
|
145
|
+
if (pFormat) {
|
|
146
|
+
if (pFormat.bold && !format.bold) format.bold = true;
|
|
147
|
+
if (pFormat.italic && !format.italic) format.italic = true;
|
|
148
|
+
if (pFormat.underline && !format.underline) format.underline = true;
|
|
149
|
+
if (pFormat.strikethrough && !format.strikethrough) format.strikethrough = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
format.hasFormatting = format.bold || format.italic || format.underline || format.strikethrough;
|
|
153
|
+
|
|
154
|
+
let currentOffset = charOffset;
|
|
155
|
+
for (const child of Array.from(run.childNodes)) {
|
|
156
|
+
if (isWordElement(child, 't')) {
|
|
157
|
+
const text = child.textContent || '';
|
|
158
|
+
if (text.length > 0) {
|
|
159
|
+
const start = currentOffset;
|
|
160
|
+
const end = currentOffset + text.length;
|
|
161
|
+
|
|
162
|
+
textSpans.push({
|
|
163
|
+
charStart: start,
|
|
164
|
+
charEnd: end,
|
|
165
|
+
textElement: child,
|
|
166
|
+
runElement: run,
|
|
167
|
+
paragraph,
|
|
168
|
+
rPr,
|
|
169
|
+
format: { ...format }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (format.hasFormatting) {
|
|
173
|
+
formatHints.push({
|
|
174
|
+
start,
|
|
175
|
+
end,
|
|
176
|
+
format: { ...format },
|
|
177
|
+
run,
|
|
178
|
+
rPr
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
currentOffset = end;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return currentOffset;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Extracts existing formatting hints from OOXML paragraph runs.
|
|
192
|
+
*
|
|
193
|
+
* @param {Document} xmlDoc - XML document
|
|
194
|
+
* @returns {{ existingFormatHints: Array, textSpans: Array, paragraphs: Element[] }}
|
|
195
|
+
*/
|
|
196
|
+
export function extractFormattingFromOoxml(xmlDoc) {
|
|
197
|
+
const existingFormatHints = [];
|
|
198
|
+
const textSpans = [];
|
|
199
|
+
let charOffset = 0;
|
|
200
|
+
|
|
201
|
+
const paragraphs = getDocumentParagraphs(xmlDoc);
|
|
202
|
+
|
|
203
|
+
for (let pIndex = 0; pIndex < paragraphs.length; pIndex++) {
|
|
204
|
+
const p = paragraphs[pIndex];
|
|
205
|
+
let pRPr = null;
|
|
206
|
+
for (const child of Array.from(p.childNodes)) {
|
|
207
|
+
if (isWordElement(child, 'pPr')) {
|
|
208
|
+
for (const pChild of Array.from(child.childNodes)) {
|
|
209
|
+
if (isWordElement(pChild, 'rPr')) {
|
|
210
|
+
pRPr = pChild;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const pFormat = extractFormatFromRPr(pRPr);
|
|
219
|
+
if (pFormat.hasFormatting) {
|
|
220
|
+
log(`[OxmlEngine] Found paragraph-level formatting: ${JSON.stringify(pFormat)}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const runs = collectParagraphRuns(p);
|
|
224
|
+
for (const run of runs) {
|
|
225
|
+
charOffset = processRunForFormatting(run, p, charOffset, textSpans, existingFormatHints, pFormat);
|
|
226
|
+
}
|
|
227
|
+
charOffset = advanceOffsetForParagraphBoundary(charOffset, pIndex, paragraphs.length);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
log(`[OxmlEngine] Extracted ${textSpans.length} text spans, ${existingFormatHints.length} format hints`);
|
|
231
|
+
return { existingFormatHints, textSpans, paragraphs };
|
|
232
|
+
}
|