@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.
Files changed (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. 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
+ }