@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,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
|
+
}
|