@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,330 @@
1
+ /**
2
+ * OOXML Formatting Removal Utilities
3
+ *
4
+ * Provides functions to surgically remove formatting from OOXML runs
5
+ * while preserving the text content and other properties.
6
+ */
7
+
8
+ import { parseOoxml, serializeOoxml } from './oxml-engine.js';
9
+ import { getDefaultAuthor } from '../adapters/config.js';
10
+
11
+ /**
12
+ * Removes specific formatting properties from a run properties (w:rPr) element.
13
+ * This allows surgical removal of bold, italic, underline, color, etc. from OOXML.
14
+ *
15
+ * @param {Element} rPr - The w:rPr element to modify
16
+ * @param {string[]} formatTypes - Array of format types to remove: ['bold', 'italic', 'underline', 'strikethrough', 'color', 'highlight', 'fontSize', 'fontFamily', 'all']
17
+ * @returns {Element|null} Modified rPr element, or null if all formatting removed
18
+ */
19
+ export function removeFormattingFromRPr(rPr, formatTypes = ['all']) {
20
+ if (!rPr) return null;
21
+
22
+ const rPrClone = rPr.cloneNode(true);
23
+
24
+ if (formatTypes.includes('all')) {
25
+ // Remove all character formatting properties
26
+ const toRemove = ['w:b', 'w:i', 'w:u', 'w:strike', 'w:dstrike', 'w:color',
27
+ 'w:sz', 'w:szCs', 'w:rFonts', 'w:highlight', 'w:vertAlign',
28
+ 'w:spacing', 'w:w', 'w:kern', 'w:position'];
29
+ toRemove.forEach(tag => {
30
+ // Handle both namespaced and non-namespaced versions
31
+ const elements = rPrClone.querySelectorAll(`${tag}, ${tag.replace('w:', '')}`);
32
+ elements.forEach(el => el.remove());
33
+ });
34
+ } else {
35
+ // Remove specific properties
36
+ const tagMap = {
37
+ 'bold': 'w:b',
38
+ 'italic': 'w:i',
39
+ 'underline': 'w:u',
40
+ 'strikethrough': 'w:strike',
41
+ 'doubleStrike': 'w:dstrike',
42
+ 'color': 'w:color',
43
+ 'highlight': 'w:highlight',
44
+ 'fontSize': 'w:sz',
45
+ 'fontSizeCs': 'w:szCs', // Complex script font size
46
+ 'fontFamily': 'w:rFonts',
47
+ 'superscript': 'w:vertAlign',
48
+ 'subscript': 'w:vertAlign'
49
+ };
50
+
51
+ formatTypes.forEach(type => {
52
+ const tag = tagMap[type];
53
+ if (tag) {
54
+ // Handle both namespaced and non-namespaced versions
55
+ const elements = rPrClone.querySelectorAll(`${tag}, ${tag.replace('w:', '')}`);
56
+ elements.forEach(el => el.remove());
57
+ }
58
+ });
59
+ }
60
+
61
+ // Return null if rPr is now empty (no children)
62
+ return rPrClone.children.length > 0 ? rPrClone : null;
63
+ }
64
+
65
+ /**
66
+ * Applies formatting removal to OOXML containing the specified text.
67
+ * Searches for text in runs and removes specified formatting properties.
68
+ *
69
+ * @param {string} ooxmlString - OOXML string (paragraph or larger structure)
70
+ * @param {string} targetText - Text to find and remove formatting from
71
+ * @param {string[]} formatTypes - Array of format types to remove
72
+ * @returns {string} Modified OOXML string
73
+ */
74
+ export function applyFormattingRemovalToOoxml(ooxmlString, targetText, formatTypes) {
75
+ if (!targetText || !ooxmlString) return ooxmlString;
76
+
77
+ const doc = parseOoxml(ooxmlString);
78
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
79
+
80
+ // Find all text runs
81
+ const runs = doc.getElementsByTagNameNS(NS_W, 'r');
82
+
83
+ // Also handle runs inside w:ins (insertions)
84
+ const insertions = doc.getElementsByTagNameNS(NS_W, 'ins');
85
+ const allRuns = [...Array.from(runs)];
86
+
87
+ for (const ins of insertions) {
88
+ const insideRuns = ins.getElementsByTagNameNS(NS_W, 'r');
89
+ allRuns.push(...Array.from(insideRuns));
90
+ }
91
+
92
+ for (const run of allRuns) {
93
+ // Extract text from this run
94
+ const textNodes = run.getElementsByTagNameNS(NS_W, 't');
95
+ const runText = Array.from(textNodes).map(t => t.textContent).join('');
96
+
97
+ // If this run contains the target text (or equals it)
98
+ if (runText.includes(targetText) || runText === targetText) {
99
+ // Find the rPr element
100
+ const rPrElements = run.getElementsByTagNameNS(NS_W, 'rPr');
101
+
102
+ if (rPrElements.length > 0) {
103
+ const rPr = rPrElements[0];
104
+ const newRPr = removeFormattingFromRPr(rPr, formatTypes);
105
+
106
+ if (newRPr) {
107
+ // Replace with modified rPr
108
+ if (rPr.parentNode) {
109
+ rPr.parentNode.replaceChild(newRPr, rPr);
110
+ }
111
+ } else {
112
+ // Remove entire rPr if empty
113
+ rPr.remove();
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ return serializeOoxml(doc);
120
+ }
121
+
122
+ // ==================== HIGHLIGHT INJECTION ====================
123
+
124
+ /**
125
+ * Word API color names → OOXML w:highlight values
126
+ */
127
+ const HIGHLIGHT_COLOR_MAP = {
128
+ 'yellow': 'yellow', 'green': 'green', 'cyan': 'cyan',
129
+ 'magenta': 'magenta', 'blue': 'blue', 'red': 'red',
130
+ 'darkblue': 'darkBlue', 'darkcyan': 'darkCyan',
131
+ 'darkgreen': 'darkGreen', 'darkmagenta': 'darkMagenta',
132
+ 'darkred': 'darkRed', 'darkyellow': 'darkYellow',
133
+ 'gray25': 'lightGray', 'gray50': 'darkGray',
134
+ 'black': 'black', 'white': 'white'
135
+ };
136
+
137
+ /**
138
+ * Injects a highlight color into a run properties (w:rPr) element.
139
+ * If rPr is null, creates a new rPr element with the highlight.
140
+ * Supports track changes via w:rPrChange.
141
+ *
142
+ * @param {Document} doc - The OOXML document (for creating new elements)
143
+ * @param {Element|null} rPr - The w:rPr element to modify (or null to create new)
144
+ * @param {string} color - Highlight color name (default: 'yellow')
145
+ * @param {Object} options - Options { generateRedlines: boolean, author: string }
146
+ * @returns {Element} Modified or new rPr element with highlight
147
+ */
148
+ export function injectHighlightIntoRPr(doc, rPr, color = 'yellow', options = {}) {
149
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
150
+ const ooxmlColor = HIGHLIGHT_COLOR_MAP[color.toLowerCase()] || 'yellow';
151
+ const generateRedlines = options?.generateRedlines ?? false;
152
+ const author = options?.author || getDefaultAuthor();
153
+
154
+ let rPrElement = rPr;
155
+ if (!rPrElement) {
156
+ // Create new rPr element
157
+ rPrElement = doc.createElementNS(NS_W, 'w:rPr');
158
+ } else {
159
+ rPrElement = rPr.cloneNode(true);
160
+ }
161
+
162
+ // Capture "previous" state for redlines BEFORE modification
163
+ // Clone the *original* rPr children before we touch them
164
+ let previousRPrState = null;
165
+ if (generateRedlines) {
166
+ previousRPrState = doc.createElementNS(NS_W, 'w:rPr');
167
+ Array.from(rPrElement.childNodes).forEach(child => {
168
+ // Don't include existing rPrChange in the "previous" state wrapper usually,
169
+ // but for simplicity we clone children. Word generally handles nested track changes poorly,
170
+ // so best to exclude rPrChange from the inner previous state.
171
+ if (child.nodeName !== 'w:rPrChange') {
172
+ previousRPrState.appendChild(child.cloneNode(true));
173
+ }
174
+ });
175
+ }
176
+
177
+ // --- APPLY CHANGE ---
178
+ // Remove any existing highlight
179
+ const existingHighlight = rPrElement.getElementsByTagNameNS(NS_W, 'highlight');
180
+ Array.from(existingHighlight).forEach(el => el.remove());
181
+
182
+ // Create and add new highlight element
183
+ const highlightEl = doc.createElementNS(NS_W, 'w:highlight');
184
+ highlightEl.setAttributeNS(NS_W, 'w:val', ooxmlColor);
185
+ rPrElement.appendChild(highlightEl);
186
+
187
+ // --- WRAP IN REDLINES IF ENABLED ---
188
+ if (generateRedlines && previousRPrState) {
189
+ const rPrChange = doc.createElementNS(NS_W, 'w:rPrChange');
190
+
191
+ // Attributes
192
+ rPrChange.setAttributeNS(NS_W, 'w:id', Math.floor(Math.random() * 9999999).toString());
193
+ rPrChange.setAttributeNS(NS_W, 'w:author', author);
194
+ rPrChange.setAttributeNS(NS_W, 'w:date', new Date().toISOString());
195
+
196
+ // Format: <w:rPrChange ...> <w:rPr>...previous...</w:rPr> </w:rPrChange>
197
+ rPrChange.appendChild(previousRPrState);
198
+
199
+ // Remove any EXISTING rPrChange to avoid duplicates or nested weirdness
200
+ const existingChange = rPrElement.getElementsByTagNameNS(NS_W, 'rPrChange');
201
+ Array.from(existingChange).forEach(el => el.remove());
202
+
203
+ // Append to rPr
204
+ rPrElement.appendChild(rPrChange);
205
+ }
206
+
207
+ return rPrElement;
208
+ }
209
+
210
+ /**
211
+ * Applies highlight formatting to OOXML runs containing the specified text.
212
+ * Performs surgical splitting of runs if the text is a substring.
213
+ *
214
+ * @param {string} ooxmlString - OOXML string (paragraph or package)
215
+ * @param {string} targetText - Text to find and highlight
216
+ * @param {string} color - Highlight color (default: 'yellow')
217
+ * @returns {string} Modified OOXML string with highlights applied
218
+ */
219
+ export function applyHighlightToOoxml(ooxmlString, targetText, color = 'yellow', options = {}) {
220
+ if (!targetText || !ooxmlString) return ooxmlString;
221
+
222
+ const doc = parseOoxml(ooxmlString);
223
+ const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
224
+
225
+ // Helper to get text from a run
226
+ const getRunText = (run) => {
227
+ const textNodes = run.getElementsByTagNameNS(NS_W, 't');
228
+ return Array.from(textNodes).map(t => t.textContent).join('');
229
+ };
230
+
231
+ // Find all runs recursively (this already includes runs inside w:ins)
232
+ const allRuns = Array.from(doc.getElementsByTagNameNS(NS_W, 'r'));
233
+
234
+ // Process runs
235
+ // Note: We need to be careful about mutating the DOM while iterating.
236
+ // However, since we split one run into multiple, we don't disturb the *order* of subsequent processed runs usually,
237
+ // but a safe approach is to process updates after identification, or break after first match if we assume 1 match per call.
238
+ // Given the task usually implies "highlight all occurrences" or "highlight this specific recurrence",
239
+ // but the current API is simple "textToFind". We'll assume "highlight all non-overlapping occurrences".
240
+
241
+ for (let i = 0; i < allRuns.length; i++) {
242
+ const run = allRuns[i];
243
+ const runText = getRunText(run);
244
+
245
+ if (!runText) continue;
246
+
247
+ const matchIndex = runText.indexOf(targetText);
248
+ if (matchIndex === -1) continue;
249
+
250
+ // --- SPLITTING LOGIC ---
251
+ // 1. Prefix (if match > 0)
252
+ // 2. Match (highlighted)
253
+ // 3. Suffix (if match + len < total len)
254
+
255
+ const parent = run.parentNode;
256
+ if (!parent) {
257
+ console.warn("[Highlight] Run parent is null; skipping. Likely already processed.");
258
+ continue;
259
+ }
260
+
261
+ const prefixText = runText.substring(0, matchIndex);
262
+ const matchText = runText.substring(matchIndex, matchIndex + targetText.length);
263
+ const suffixText = runText.substring(matchIndex + targetText.length);
264
+
265
+ // We replace the single 'run' with a fragment of 1-3 runs
266
+ const fragment = doc.createDocumentFragment();
267
+
268
+ // 1. Create Prefix Run
269
+ if (prefixText.length > 0) {
270
+ const prefixRun = run.cloneNode(true);
271
+ // Update text content
272
+ const tNodes = prefixRun.getElementsByTagNameNS(NS_W, 't');
273
+ // Simply remove all t nodes and add one with new text to avoid complexity of multiple t nodes
274
+ Array.from(tNodes).forEach(t => t.remove());
275
+ const newT = doc.createElementNS(NS_W, 'w:t');
276
+ // Preserve xml:space="preserve" if it existed, or just add it usually
277
+ newT.setAttribute('xml:space', 'preserve');
278
+ newT.textContent = prefixText;
279
+ prefixRun.appendChild(newT);
280
+ fragment.appendChild(prefixRun);
281
+ }
282
+
283
+ // 2. Create Match Run (With Highlight)
284
+ if (matchText.length > 0) {
285
+ const matchRun = run.cloneNode(true);
286
+ // Update text content
287
+ const tNodes = matchRun.getElementsByTagNameNS(NS_W, 't');
288
+ Array.from(tNodes).forEach(t => t.remove());
289
+ const newT = doc.createElementNS(NS_W, 'w:t');
290
+ newT.setAttribute('xml:space', 'preserve');
291
+ newT.textContent = matchText;
292
+ matchRun.appendChild(newT);
293
+
294
+ // Inject Highlight
295
+ const rPrElements = matchRun.getElementsByTagNameNS(NS_W, 'rPr');
296
+ const existingRPr = rPrElements.length > 0 ? rPrElements[0] : null;
297
+ const newRPr = injectHighlightIntoRPr(doc, existingRPr, color, options);
298
+
299
+ if (existingRPr) {
300
+ matchRun.replaceChild(newRPr, existingRPr);
301
+ } else {
302
+ matchRun.insertBefore(newRPr, matchRun.firstChild);
303
+ }
304
+ fragment.appendChild(matchRun);
305
+ }
306
+
307
+ // 3. Create Suffix Run
308
+ if (suffixText.length > 0) {
309
+ const suffixRun = run.cloneNode(true);
310
+ // Update text content
311
+ const tNodes = suffixRun.getElementsByTagNameNS(NS_W, 't');
312
+ Array.from(tNodes).forEach(t => t.remove());
313
+ const newT = doc.createElementNS(NS_W, 'w:t');
314
+ newT.setAttribute('xml:space', 'preserve');
315
+ newT.textContent = suffixText;
316
+ suffixRun.appendChild(newT);
317
+ fragment.appendChild(suffixRun);
318
+ }
319
+
320
+ // Replace original run
321
+ parent.replaceChild(fragment, run);
322
+
323
+ // IMPORTANT: If we had a suffix that *also* contained the text (e.g. "target target"),
324
+ // our simple loop won't catch it because we replaced the node 'run'.
325
+ // For now, we'll assume one match per run for simplicity, or we would need to recurse on the suffix.
326
+ // Given the short contexts usually, this is acceptable for v1 fix.
327
+ }
328
+
329
+ return serializeOoxml(doc);
330
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * OOXML Engine V5.1 - Hybrid Mode
3
+ *
4
+ * Router/orchestrator for formatting/text reconciliation modes.
5
+ */
6
+
7
+ import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
8
+ import { isListTargetLoose } from '../pipeline/list-markers.js';
9
+ import { ReconciliationPipeline } from '../pipeline/pipeline.js';
10
+ import { wrapInDocumentFragment } from '../pipeline/serialization.js';
11
+ import {
12
+ getElementsByTag,
13
+ getXmlParseError
14
+ } from '../core/xml-query.js';
15
+ import { createParser, createSerializer, parseXml, serializeXml } from '../adapters/xml-adapter.js';
16
+ import { log, error } from '../adapters/logger.js';
17
+ import { extractFormattingFromOoxml } from './format-extraction.js';
18
+ import {
19
+ applyFormatRemovalAsSurgicalReplacement,
20
+ applyFormatOnlyChangesSurgical
21
+ } from './format-application.js';
22
+ import { buildParagraphInfos, findMatchingParagraphInfo, getContainingParagraph } from './format-paragraph-targeting.js';
23
+ import { detectTableCellContext, serializeParagraphOnly } from './table-cell-context.js';
24
+ import { applySurgicalMode } from './surgical-mode.js';
25
+ import { applyReconstructionMode } from './reconstruction-mode.js';
26
+ import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
27
+ import { getDefaultAuthor } from '../adapters/config.js';
28
+
29
+ /**
30
+ * Applies redline track changes to OOXML by modifying the DOM in-place.
31
+ *
32
+ * @param {string} oxml - Original OOXML string
33
+ * @param {string} originalText - Original plain text
34
+ * @param {string} modifiedText - New text (may contain markdown)
35
+ * @param {Object} [options={}] - Options
36
+ * @param {string} [options.author='AI'] - Author for track changes
37
+ * @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
38
+ * @returns {Promise<{ oxml: string, hasChanges: boolean }>}
39
+ */
40
+ export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
41
+ const generateRedlines = options.generateRedlines ?? true;
42
+ const author = options.author || getDefaultAuthor();
43
+ const parser = createParser();
44
+ const serializer = createSerializer();
45
+ const noChanges = () => ({ oxml, hasChanges: false });
46
+
47
+ let xmlDoc;
48
+ try {
49
+ xmlDoc = parser.parseFromString(oxml, 'text/xml');
50
+ } catch (e) {
51
+ error('[OxmlEngine] Failed to parse OXML:', e);
52
+ return noChanges();
53
+ }
54
+
55
+ const parseError = getXmlParseError(xmlDoc);
56
+ if (parseError) {
57
+ error('[OxmlEngine] XML parse error:', parseError.textContent);
58
+ return noChanges();
59
+ }
60
+
61
+ const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
62
+ if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
63
+ log('[OxmlEngine] Isolating table-cell paragraph before diff');
64
+ const isolatedOxml = serializeParagraphOnly(xmlDoc, initialTableCellContext.targetParagraph, serializer);
65
+ return applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
66
+ ...options,
67
+ _isolatedTableCell: true
68
+ });
69
+ }
70
+
71
+ const sanitizedText = sanitizeAiResponse(modifiedText);
72
+ const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
73
+
74
+ const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
75
+ const hasFormatHints = formatHints.length > 0;
76
+
77
+ const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
78
+ const hasExistingFormatting = existingFormatHints.length > 0;
79
+ let paragraphInfos = null;
80
+ const getParagraphInfos = () => {
81
+ if (!paragraphInfos) {
82
+ paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
83
+ }
84
+ return paragraphInfos;
85
+ };
86
+ const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
87
+ const formatResult = applyFormatOnlyChangesSurgical(
88
+ xmlDoc,
89
+ originalText,
90
+ formatHints,
91
+ serializer,
92
+ author,
93
+ generateRedlines,
94
+ precomputedContext
95
+ );
96
+ if (!formatResult.useNativeApi) {
97
+ return formatResult;
98
+ }
99
+
100
+ log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
101
+ return applyReconstructionMode(
102
+ xmlDoc,
103
+ originalText,
104
+ cleanModifiedText,
105
+ serializer,
106
+ author,
107
+ formatHints,
108
+ generateRedlines
109
+ );
110
+ };
111
+
112
+ log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
113
+
114
+ const needsFormatRemoval = !hasTextChanges && !hasFormatHints && hasExistingFormatting;
115
+
116
+ if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
117
+ log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
118
+ return noChanges();
119
+ }
120
+
121
+ if (needsFormatRemoval) {
122
+ log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
123
+
124
+ const tableCellCtx = initialTableCellContext;
125
+ let targetParagraph = tableCellCtx.targetParagraph || null;
126
+
127
+ if (!targetParagraph) {
128
+ const matchedInfo = findMatchingParagraphInfo(getParagraphInfos(), originalText);
129
+ if (matchedInfo) {
130
+ targetParagraph = matchedInfo.paragraph;
131
+ }
132
+ }
133
+
134
+ let filteredHints = existingFormatHints;
135
+ if (targetParagraph) {
136
+ filteredHints = existingFormatHints.filter(hint => {
137
+ const hintParagraph = getContainingParagraph(hint.run);
138
+ return hintParagraph === targetParagraph;
139
+ });
140
+ }
141
+
142
+ const removalResult = applyFormatRemovalAsSurgicalReplacement(
143
+ xmlDoc,
144
+ textSpans,
145
+ filteredHints,
146
+ serializer,
147
+ author,
148
+ generateRedlines
149
+ );
150
+
151
+ if (tableCellCtx.hasTableWrapper && targetParagraph) {
152
+ return {
153
+ oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
154
+ hasChanges: removalResult.hasChanges
155
+ };
156
+ }
157
+
158
+ return removalResult;
159
+ }
160
+
161
+ if (!hasTextChanges && hasFormatHints) {
162
+ log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
163
+
164
+ const tableCellCtx = initialTableCellContext;
165
+ const precomputedFormatContext = {
166
+ textSpans,
167
+ paragraphs,
168
+ paragraphInfos: getParagraphInfos()
169
+ };
170
+ if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
171
+ log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
172
+
173
+ const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
174
+
175
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
176
+ return {
177
+ oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
178
+ hasChanges: formatResult.hasChanges
179
+ };
180
+ }
181
+
182
+ return applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
183
+ }
184
+
185
+ const tables = getElementsByTag(xmlDoc, 'w:tbl');
186
+ const hasTables = tables.length > 0;
187
+ const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
188
+ const isTargetList = isListTargetLoose(cleanModifiedText);
189
+ const tableCellContext = initialTableCellContext;
190
+
191
+ log(`[OxmlEngine] Mode: ${hasTables ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
192
+
193
+ if (isMarkdownTable && !hasTables) {
194
+ log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
195
+ return applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, parser, author, generateRedlines);
196
+ }
197
+
198
+ if (hasTables && isMarkdownTable) {
199
+ return applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, parser, author, generateRedlines);
200
+ }
201
+ if (hasTables) {
202
+ const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
203
+ ? tableCellContext.targetParagraph
204
+ : null;
205
+ if (surgicalTarget) {
206
+ log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
207
+ }
208
+
209
+ const result = applySurgicalMode(
210
+ xmlDoc,
211
+ originalText,
212
+ cleanModifiedText,
213
+ serializer,
214
+ author,
215
+ formatHints,
216
+ generateRedlines,
217
+ surgicalTarget
218
+ );
219
+
220
+ if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
221
+ log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
222
+ return { oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true };
223
+ }
224
+ return result;
225
+ }
226
+ if (isTargetList) {
227
+ log('[OxmlEngine] 🎯 Using reconciliation pipeline for list generation');
228
+ const pipeline = new ReconciliationPipeline({ author, generateRedlines });
229
+ const result = await pipeline.execute(oxml, modifiedText, { xmlDoc });
230
+
231
+ if (result.isValid && result.ooxml && result.ooxml !== oxml) {
232
+ log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${result.includeNumbering}`);
233
+ const wrapped = wrapInDocumentFragment(result.ooxml, {
234
+ includeNumbering: result.includeNumbering ?? true,
235
+ numberingXml: result.numberingXml
236
+ });
237
+ log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
238
+ return { oxml: wrapped, hasChanges: true };
239
+ }
240
+ return noChanges();
241
+ }
242
+
243
+ return applyReconstructionMode(xmlDoc, originalText, cleanModifiedText, serializer, author, formatHints, generateRedlines);
244
+ }
245
+
246
+ /**
247
+ * Sanitizes AI response text by removing common prefixes.
248
+ *
249
+ * @param {string} text - AI response text
250
+ * @returns {string}
251
+ */
252
+ export function sanitizeAiResponse(text) {
253
+ let cleaned = text;
254
+ cleaned = cleaned.replace(/^(Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)\s*/i, '');
255
+ cleaned = cleaned.replace(/\$\\text\{/g, '').replace(/\}\$/g, '');
256
+ cleaned = cleaned.replace(/\$([^0-9\n]+?)\$/g, '$1');
257
+ cleaned = cleaned.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
258
+ return cleaned;
259
+ }
260
+
261
+ /**
262
+ * Parses OOXML into a DOM document.
263
+ *
264
+ * @param {string} ooxmlString - OOXML text
265
+ * @returns {Document}
266
+ */
267
+ export function parseOoxml(ooxmlString) {
268
+ return parseXml(ooxmlString, 'application/xml');
269
+ }
270
+
271
+ /**
272
+ * Serializes a DOM document to OOXML text.
273
+ *
274
+ * @param {Node} doc - XML document/node
275
+ * @returns {string}
276
+ */
277
+ export function serializeOoxml(doc) {
278
+ return serializeXml(doc);
279
+ }