@ansonlai/docx-redline-js 0.9.0 → 0.11.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/ARCHITECTURE.md +691 -669
- package/CHANGELOG.md +67 -1
- package/README.md +39 -8
- package/core/word-xml.js +160 -118
- package/dist/docx-redline-js.esm.js +528 -312
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +94 -94
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +528 -312
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +528 -312
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +64 -5
- package/docs/SKILL_AUTHORING.md +5 -1
- package/docs/TESTING.md +10 -1
- package/docs/schemas/document-operations.schema.json +1 -1
- package/engine/oxml-engine.js +748 -736
- package/engine/table-mode.js +7 -4
- package/index.d.ts +29 -2
- package/index.js +3 -0
- package/node/cli.js +993 -992
- package/package.json +1 -1
- package/pipeline/content-analysis.js +2 -1
- package/pipeline/markdown-processor.js +5 -2
- package/pipeline/structured-content.js +2 -1
- package/services/comment-parts-merge.js +124 -0
- package/services/comment-replies.js +1 -1
- package/services/document-operation-applier.js +1 -0
- package/services/document-operation-contract.js +1 -0
- package/services/document-operation-mutations.js +3362 -3346
- package/services/inspection-comparison.js +82 -0
- package/services/operation-heuristics.js +1 -1
- package/services/operation-preflight.js +42 -33
- package/services/revision-fidelity-verifier.js +5 -19
- package/services/standalone-docx-plumbing.js +38 -22
- package/services/table-reconciliation.js +11 -6
package/engine/oxml-engine.js
CHANGED
|
@@ -1,736 +1,748 @@
|
|
|
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 { ingestOoxml, detectNumberingContext } from '../pipeline/ingestion.js';
|
|
11
|
-
import { executeListGeneration } from '../pipeline/list-generation.js';
|
|
12
|
-
import { analyzeStructuredContent } from '../pipeline/structured-content.js';
|
|
13
|
-
import { wrapInDocumentFragment } from '../pipeline/serialization.js';
|
|
14
|
-
import {
|
|
15
|
-
getElementsByTagNSOrTag,
|
|
16
|
-
getXmlParseError
|
|
17
|
-
} from '../core/xml-query.js';
|
|
18
|
-
import { createSerializer, parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
|
|
19
|
-
import { log, error } from '../adapters/logger.js';
|
|
20
|
-
import { buildTextSpansFromParagraphs, extractFormattingFromOoxml } from './format-extraction.js';
|
|
21
|
-
import {
|
|
22
|
-
applyFormatRemovalAsSurgicalReplacement,
|
|
23
|
-
applyFormatOnlyChangesSurgical
|
|
24
|
-
} from './format-application.js';
|
|
25
|
-
import { buildParagraphInfos, findMatchingParagraphInfo, getContainingParagraph } from './format-paragraph-targeting.js';
|
|
26
|
-
import { detectTableCellContext, serializeParagraphOnly } from './table-cell-context.js';
|
|
27
|
-
import { applySurgicalMode } from './surgical-mode.js';
|
|
28
|
-
import { applyReconstructionMode } from './reconstruction-mode.js';
|
|
29
|
-
import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
|
|
30
|
-
import { getDefaultAuthor } from '../adapters/config.js';
|
|
31
|
-
import { containsTrackedChanges, getTrackedChangeAuthors, withOoxmlSourceType } from '../core/word-xml.js';
|
|
32
|
-
import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
|
|
33
|
-
import {
|
|
34
|
-
NS_W,
|
|
35
|
-
RevisionIdAllocator,
|
|
36
|
-
seedRevisionIdsFromDocument
|
|
37
|
-
} from '../core/types.js';
|
|
38
|
-
import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml } from '../services/revision-comment-management.js';
|
|
39
|
-
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
40
|
-
import { getCurrentParagraphProperties } from '../core/current-properties.js';
|
|
41
|
-
import { getDocumentParagraphs } from './format-extraction.js';
|
|
42
|
-
import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
|
|
43
|
-
import { NumberingService } from '../services/numbering-service.js';
|
|
44
|
-
import { recordRouteSelection } from './route-selection.js';
|
|
45
|
-
import { restoreParagraphMarkInserted } from './paragraph-mark-revisions.js';
|
|
46
|
-
import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
|
|
47
|
-
|
|
48
|
-
function getCommentIdsInOoxml(node) {
|
|
49
|
-
const ids = new Set();
|
|
50
|
-
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
51
|
-
for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
|
|
52
|
-
const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
|
|
53
|
-
if (id !== '') ids.add(id);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function directWordChild(node, localName) {
|
|
60
|
-
return Array.from(node?.childNodes || []).find(child => (
|
|
61
|
-
child?.nodeType === 1
|
|
62
|
-
&& child.namespaceURI === NS_W
|
|
63
|
-
&& child.localName === localName
|
|
64
|
-
)) || null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function insertedParagraphMarkMetadata(paragraph, author) {
|
|
68
|
-
const marker = directWordChild(directWordChild(directWordChild(paragraph, 'pPr'), 'rPr'), 'ins');
|
|
69
|
-
if (!marker) return null;
|
|
70
|
-
const markerAuthor = marker.getAttribute('w:author') || marker.getAttributeNS(NS_W, 'author') || '';
|
|
71
|
-
if (markerAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase()) return null;
|
|
72
|
-
return {
|
|
73
|
-
id: marker.getAttribute('w:id') || marker.getAttributeNS(NS_W, 'id') || '',
|
|
74
|
-
author: markerAuthor,
|
|
75
|
-
date: marker.getAttribute('w:date') || marker.getAttributeNS(NS_W, 'date') || ''
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function emptyParagraphBaseline(paragraph, serializer) {
|
|
80
|
-
const clone = paragraph.cloneNode(false);
|
|
81
|
-
const pPr = directWordChild(paragraph, 'pPr');
|
|
82
|
-
if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
|
|
83
|
-
return serializer.serializeToString(clone);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function restoreInsertedParagraphMark(oxml, metadata) {
|
|
87
|
-
if (!metadata || typeof oxml !== 'string' || !oxml.trim()) return oxml;
|
|
88
|
-
const parsed = parseOoxmlSafe(oxml, 'text/xml');
|
|
89
|
-
if (!parsed.doc || parsed.error) return oxml;
|
|
90
|
-
const paragraph = parsed.doc.documentElement?.localName === 'p'
|
|
91
|
-
? parsed.doc.documentElement
|
|
92
|
-
: getDocumentParagraphs(parsed.doc)[0];
|
|
93
|
-
if (!paragraph) return oxml;
|
|
94
|
-
|
|
95
|
-
restoreParagraphMarkInserted(paragraph, metadata);
|
|
96
|
-
return serializeXml(parsed.doc);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Applies redline track changes to OOXML by modifying the DOM in-place.
|
|
101
|
-
*
|
|
102
|
-
* @param {string} oxml - Original OOXML string
|
|
103
|
-
* @param {string} originalText - Original plain text
|
|
104
|
-
* @param {string} modifiedText - New text (may contain markdown)
|
|
105
|
-
* @param {Object} [options={}] - Options
|
|
106
|
-
* @param {string} [options.author='AI'] - Author for track changes
|
|
107
|
-
* @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
|
|
108
|
-
* @param {'merge-same-author'|'slice-cross-author'|'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='merge-same-author'] - Policy for source OOXML with tracked changes
|
|
109
|
-
* @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
|
|
110
|
-
* @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
|
|
111
|
-
* @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
|
|
112
|
-
*/
|
|
113
|
-
export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
114
|
-
const inputOoxml = oxml;
|
|
115
|
-
let workingOoxml = oxml;
|
|
116
|
-
originalText = typeof originalText === 'string' ? originalText : String(originalText ?? '');
|
|
117
|
-
modifiedText = typeof modifiedText === 'string' ? modifiedText : String(modifiedText ?? '');
|
|
118
|
-
const generateRedlines = options.generateRedlines ?? true;
|
|
119
|
-
const author = options.author || getDefaultAuthor();
|
|
120
|
-
const serializer = createSerializer();
|
|
121
|
-
let parseWarnings = [];
|
|
122
|
-
const operationWarnings = [];
|
|
123
|
-
let normalizedExistingRevisions = false;
|
|
124
|
-
let preservedInsertedParagraphMark = null;
|
|
125
|
-
|
|
126
|
-
const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
|
|
127
|
-
const finalize = result => {
|
|
128
|
-
const withStatus = { ...result };
|
|
129
|
-
if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
|
|
130
|
-
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
131
|
-
withStatus.oxml = workingOoxml;
|
|
132
|
-
withStatus.hasChanges = true;
|
|
133
|
-
withStatus.warnings = [
|
|
134
|
-
...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
|
|
135
|
-
'Previous revisions by the same author were reverted to baseline.'
|
|
136
|
-
];
|
|
137
|
-
} else if (keepNormalizedNoOp) {
|
|
138
|
-
withStatus.oxml = workingOoxml;
|
|
139
|
-
withStatus.hasChanges = true;
|
|
140
|
-
withStatus.warnings = [
|
|
141
|
-
...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
|
|
142
|
-
'Existing revisions were accepted before redlining.'
|
|
143
|
-
];
|
|
144
|
-
} else {
|
|
145
|
-
withStatus.oxml = inputOoxml;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
const warnings = [...parseWarnings, ...operationWarnings, ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : [])];
|
|
149
|
-
if (warnings.length > 0) {
|
|
150
|
-
withStatus.warnings = [...new Set(warnings)];
|
|
151
|
-
}
|
|
152
|
-
if (!withStatus.status) {
|
|
153
|
-
withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
|
|
154
|
-
}
|
|
155
|
-
if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === 'string') {
|
|
156
|
-
withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
|
|
157
|
-
}
|
|
158
|
-
return withOoxmlSourceType(withStatus);
|
|
159
|
-
};
|
|
160
|
-
const finalizeUnchanged = () => {
|
|
161
|
-
if (normalizedExistingRevisions && keepNormalizedNoOp) {
|
|
162
|
-
return finalize({
|
|
163
|
-
oxml: workingOoxml,
|
|
164
|
-
hasChanges: true,
|
|
165
|
-
warnings: ['Existing revisions were accepted before redlining.']
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
return finalize({ oxml: inputOoxml, hasChanges: false });
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const parsed = parseOoxmlSafe(inputOoxml, 'text/xml');
|
|
172
|
-
parseWarnings = parsed.warnings;
|
|
173
|
-
let xmlDoc = parsed.doc;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
* @
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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 { ingestOoxml, detectNumberingContext } from '../pipeline/ingestion.js';
|
|
11
|
+
import { executeListGeneration } from '../pipeline/list-generation.js';
|
|
12
|
+
import { analyzeStructuredContent } from '../pipeline/structured-content.js';
|
|
13
|
+
import { wrapInDocumentFragment } from '../pipeline/serialization.js';
|
|
14
|
+
import {
|
|
15
|
+
getElementsByTagNSOrTag,
|
|
16
|
+
getXmlParseError
|
|
17
|
+
} from '../core/xml-query.js';
|
|
18
|
+
import { createSerializer, parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
|
|
19
|
+
import { log, error } from '../adapters/logger.js';
|
|
20
|
+
import { buildTextSpansFromParagraphs, extractFormattingFromOoxml } from './format-extraction.js';
|
|
21
|
+
import {
|
|
22
|
+
applyFormatRemovalAsSurgicalReplacement,
|
|
23
|
+
applyFormatOnlyChangesSurgical
|
|
24
|
+
} from './format-application.js';
|
|
25
|
+
import { buildParagraphInfos, findMatchingParagraphInfo, getContainingParagraph } from './format-paragraph-targeting.js';
|
|
26
|
+
import { detectTableCellContext, serializeParagraphOnly } from './table-cell-context.js';
|
|
27
|
+
import { applySurgicalMode } from './surgical-mode.js';
|
|
28
|
+
import { applyReconstructionMode } from './reconstruction-mode.js';
|
|
29
|
+
import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
|
|
30
|
+
import { getDefaultAuthor } from '../adapters/config.js';
|
|
31
|
+
import { containsTrackedChanges, getTrackedChangeAuthors, hasUnattributedTrackedChange, resolveExistingRevisionsPolicy, withOoxmlSourceType } from '../core/word-xml.js';
|
|
32
|
+
import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
|
|
33
|
+
import {
|
|
34
|
+
NS_W,
|
|
35
|
+
RevisionIdAllocator,
|
|
36
|
+
seedRevisionIdsFromDocument
|
|
37
|
+
} from '../core/types.js';
|
|
38
|
+
import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml } from '../services/revision-comment-management.js';
|
|
39
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
40
|
+
import { getCurrentParagraphProperties } from '../core/current-properties.js';
|
|
41
|
+
import { getDocumentParagraphs } from './format-extraction.js';
|
|
42
|
+
import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
|
|
43
|
+
import { NumberingService } from '../services/numbering-service.js';
|
|
44
|
+
import { recordRouteSelection } from './route-selection.js';
|
|
45
|
+
import { restoreParagraphMarkInserted } from './paragraph-mark-revisions.js';
|
|
46
|
+
import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
|
|
47
|
+
|
|
48
|
+
function getCommentIdsInOoxml(node) {
|
|
49
|
+
const ids = new Set();
|
|
50
|
+
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
51
|
+
for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
|
|
52
|
+
const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
|
|
53
|
+
if (id !== '') ids.add(id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function directWordChild(node, localName) {
|
|
60
|
+
return Array.from(node?.childNodes || []).find(child => (
|
|
61
|
+
child?.nodeType === 1
|
|
62
|
+
&& child.namespaceURI === NS_W
|
|
63
|
+
&& child.localName === localName
|
|
64
|
+
)) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function insertedParagraphMarkMetadata(paragraph, author) {
|
|
68
|
+
const marker = directWordChild(directWordChild(directWordChild(paragraph, 'pPr'), 'rPr'), 'ins');
|
|
69
|
+
if (!marker) return null;
|
|
70
|
+
const markerAuthor = marker.getAttribute('w:author') || marker.getAttributeNS(NS_W, 'author') || '';
|
|
71
|
+
if (markerAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase()) return null;
|
|
72
|
+
return {
|
|
73
|
+
id: marker.getAttribute('w:id') || marker.getAttributeNS(NS_W, 'id') || '',
|
|
74
|
+
author: markerAuthor,
|
|
75
|
+
date: marker.getAttribute('w:date') || marker.getAttributeNS(NS_W, 'date') || ''
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function emptyParagraphBaseline(paragraph, serializer) {
|
|
80
|
+
const clone = paragraph.cloneNode(false);
|
|
81
|
+
const pPr = directWordChild(paragraph, 'pPr');
|
|
82
|
+
if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
|
|
83
|
+
return serializer.serializeToString(clone);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function restoreInsertedParagraphMark(oxml, metadata) {
|
|
87
|
+
if (!metadata || typeof oxml !== 'string' || !oxml.trim()) return oxml;
|
|
88
|
+
const parsed = parseOoxmlSafe(oxml, 'text/xml');
|
|
89
|
+
if (!parsed.doc || parsed.error) return oxml;
|
|
90
|
+
const paragraph = parsed.doc.documentElement?.localName === 'p'
|
|
91
|
+
? parsed.doc.documentElement
|
|
92
|
+
: getDocumentParagraphs(parsed.doc)[0];
|
|
93
|
+
if (!paragraph) return oxml;
|
|
94
|
+
|
|
95
|
+
restoreParagraphMarkInserted(paragraph, metadata);
|
|
96
|
+
return serializeXml(parsed.doc);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Applies redline track changes to OOXML by modifying the DOM in-place.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} oxml - Original OOXML string
|
|
103
|
+
* @param {string} originalText - Original plain text
|
|
104
|
+
* @param {string} modifiedText - New text (may contain markdown)
|
|
105
|
+
* @param {Object} [options={}] - Options
|
|
106
|
+
* @param {string} [options.author='AI'] - Author for track changes
|
|
107
|
+
* @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
|
|
108
|
+
* @param {'merge-same-author'|'slice-cross-author'|'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='merge-same-author'] - Policy for source OOXML with tracked changes
|
|
109
|
+
* @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
|
|
110
|
+
* @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
|
|
111
|
+
* @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
|
|
112
|
+
*/
|
|
113
|
+
export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
114
|
+
const inputOoxml = oxml;
|
|
115
|
+
let workingOoxml = oxml;
|
|
116
|
+
originalText = typeof originalText === 'string' ? originalText : String(originalText ?? '');
|
|
117
|
+
modifiedText = typeof modifiedText === 'string' ? modifiedText : String(modifiedText ?? '');
|
|
118
|
+
const generateRedlines = options.generateRedlines ?? true;
|
|
119
|
+
const author = options.author || getDefaultAuthor();
|
|
120
|
+
const serializer = createSerializer();
|
|
121
|
+
let parseWarnings = [];
|
|
122
|
+
const operationWarnings = [];
|
|
123
|
+
let normalizedExistingRevisions = false;
|
|
124
|
+
let preservedInsertedParagraphMark = null;
|
|
125
|
+
let existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
|
|
126
|
+
const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
|
|
127
|
+
const finalize = result => {
|
|
128
|
+
const withStatus = { ...result };
|
|
129
|
+
if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
|
|
130
|
+
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
131
|
+
withStatus.oxml = workingOoxml;
|
|
132
|
+
withStatus.hasChanges = true;
|
|
133
|
+
withStatus.warnings = [
|
|
134
|
+
...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
|
|
135
|
+
'Previous revisions by the same author were reverted to baseline.'
|
|
136
|
+
];
|
|
137
|
+
} else if (keepNormalizedNoOp) {
|
|
138
|
+
withStatus.oxml = workingOoxml;
|
|
139
|
+
withStatus.hasChanges = true;
|
|
140
|
+
withStatus.warnings = [
|
|
141
|
+
...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
|
|
142
|
+
'Existing revisions were accepted before redlining.'
|
|
143
|
+
];
|
|
144
|
+
} else {
|
|
145
|
+
withStatus.oxml = inputOoxml;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const warnings = [...parseWarnings, ...operationWarnings, ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : [])];
|
|
149
|
+
if (warnings.length > 0) {
|
|
150
|
+
withStatus.warnings = [...new Set(warnings)];
|
|
151
|
+
}
|
|
152
|
+
if (!withStatus.status) {
|
|
153
|
+
withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
|
|
154
|
+
}
|
|
155
|
+
if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === 'string') {
|
|
156
|
+
withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
|
|
157
|
+
}
|
|
158
|
+
return withOoxmlSourceType(withStatus);
|
|
159
|
+
};
|
|
160
|
+
const finalizeUnchanged = () => {
|
|
161
|
+
if (normalizedExistingRevisions && keepNormalizedNoOp) {
|
|
162
|
+
return finalize({
|
|
163
|
+
oxml: workingOoxml,
|
|
164
|
+
hasChanges: true,
|
|
165
|
+
warnings: ['Existing revisions were accepted before redlining.']
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return finalize({ oxml: inputOoxml, hasChanges: false });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const parsed = parseOoxmlSafe(inputOoxml, 'text/xml');
|
|
172
|
+
parseWarnings = parsed.warnings;
|
|
173
|
+
let xmlDoc = parsed.doc;
|
|
174
|
+
if (existingRevisionsPolicy === 'auto') {
|
|
175
|
+
existingRevisionsPolicy = resolveExistingRevisionsPolicy('auto', xmlDoc, author);
|
|
176
|
+
options = { ...options, existingRevisions: existingRevisionsPolicy };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const parseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
|
|
180
|
+
if (parsed.error || parseError) {
|
|
181
|
+
const message = parsed.error?.message || parseError?.textContent || 'Could not parse OOXML input.';
|
|
182
|
+
error('[OxmlEngine] XML parse error:', message);
|
|
183
|
+
return finalize({
|
|
184
|
+
oxml: inputOoxml,
|
|
185
|
+
hasChanges: false,
|
|
186
|
+
status: 'error',
|
|
187
|
+
error: { code: 'PARSE_ERROR', message }
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
|
|
191
|
+
? options._revisionIdAllocator
|
|
192
|
+
: new RevisionIdAllocator();
|
|
193
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
194
|
+
|
|
195
|
+
const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
|
|
196
|
+
? [xmlDoc.documentElement]
|
|
197
|
+
: getDocumentParagraphs(xmlDoc);
|
|
198
|
+
if (inputParagraphs.length === 1 && modifiedText.length > 0) {
|
|
199
|
+
const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
|
|
200
|
+
if (resurrectionTarget.matches) {
|
|
201
|
+
const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
|
|
202
|
+
return finalize({
|
|
203
|
+
oxml: inputOoxml,
|
|
204
|
+
hasChanges: false,
|
|
205
|
+
status: 'error',
|
|
206
|
+
error: {
|
|
207
|
+
code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
|
|
208
|
+
message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
|
|
209
|
+
ownerAuthor
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (containsTrackedChanges(xmlDoc)) {
|
|
216
|
+
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
217
|
+
const authors = getTrackedChangeAuthors(xmlDoc);
|
|
218
|
+
const currentAuthor = String(author || '').trim().toLowerCase();
|
|
219
|
+
const isSameAuthor = authors.length > 0
|
|
220
|
+
&& !hasUnattributedTrackedChange(xmlDoc)
|
|
221
|
+
&& authors.every(a => a.trim().toLowerCase() === currentAuthor);
|
|
222
|
+
if (isSameAuthor) {
|
|
223
|
+
const commentIds = getCommentIdsInOoxml(xmlDoc);
|
|
224
|
+
if (commentIds.length > 0) {
|
|
225
|
+
return finalize({
|
|
226
|
+
oxml: inputOoxml,
|
|
227
|
+
hasChanges: false,
|
|
228
|
+
status: 'error',
|
|
229
|
+
error: {
|
|
230
|
+
code: 'COMMENTED_CONTENT_MERGE',
|
|
231
|
+
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
232
|
+
commentIds
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
|
|
237
|
+
const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
|
|
238
|
+
preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
|
|
239
|
+
const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
|
|
240
|
+
if (rejected.status === 'error') return finalize(rejected);
|
|
241
|
+
workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
|
|
242
|
+
? emptyParagraphBaseline(soleParagraph, serializer)
|
|
243
|
+
: rejected.oxml;
|
|
244
|
+
normalizedExistingRevisions = true;
|
|
245
|
+
const rejectedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
|
|
246
|
+
parseWarnings.push(...rejectedParsed.warnings);
|
|
247
|
+
xmlDoc = rejectedParsed.doc;
|
|
248
|
+
const rejectedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
|
|
249
|
+
if (rejectedParsed.error || rejectedParseError) {
|
|
250
|
+
const message = rejectedParsed.error?.message || rejectedParseError?.textContent || 'Could not parse OOXML after rejecting same-author revisions.';
|
|
251
|
+
error('[OxmlEngine] XML parse error after rejecting same-author revisions:', message);
|
|
252
|
+
return finalize({
|
|
253
|
+
oxml: inputOoxml,
|
|
254
|
+
hasChanges: false,
|
|
255
|
+
status: 'error',
|
|
256
|
+
error: {
|
|
257
|
+
code: 'PARSE_ERROR',
|
|
258
|
+
message
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (containsTrackedChanges(xmlDoc)) {
|
|
263
|
+
return finalize({
|
|
264
|
+
oxml: inputOoxml,
|
|
265
|
+
hasChanges: false,
|
|
266
|
+
status: 'error',
|
|
267
|
+
error: {
|
|
268
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
269
|
+
message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
274
|
+
|
|
275
|
+
// Re-derive baseline text from the restored baseline OOXML:
|
|
276
|
+
const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
|
|
277
|
+
? [xmlDoc.documentElement]
|
|
278
|
+
: getDocumentParagraphs(xmlDoc);
|
|
279
|
+
const baselineText = paragraphsInDoc.length > 0
|
|
280
|
+
? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
|
|
281
|
+
: '';
|
|
282
|
+
originalText = baselineText;
|
|
283
|
+
} else if (existingRevisionsPolicy === 'merge-same-author') {
|
|
284
|
+
log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
|
|
285
|
+
return finalize({
|
|
286
|
+
oxml: inputOoxml,
|
|
287
|
+
hasChanges: false,
|
|
288
|
+
status: 'error',
|
|
289
|
+
error: {
|
|
290
|
+
code: 'EXISTING_REVISIONS',
|
|
291
|
+
message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
|
|
292
|
+
revisionAuthors: authors,
|
|
293
|
+
currentPolicy: existingRevisionsPolicy
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
} else {
|
|
297
|
+
const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
|
|
298
|
+
return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
|
|
299
|
+
});
|
|
300
|
+
if (hasMoveRevision) {
|
|
301
|
+
return finalize({
|
|
302
|
+
oxml: inputOoxml,
|
|
303
|
+
hasChanges: false,
|
|
304
|
+
status: 'error',
|
|
305
|
+
error: {
|
|
306
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
307
|
+
message: 'Cross-author slicing does not support pending move revisions.'
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
|
|
312
|
+
}
|
|
313
|
+
} else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
|
|
314
|
+
log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
|
|
315
|
+
const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
|
|
316
|
+
if (accepted.status === 'error') return finalize(accepted);
|
|
317
|
+
workingOoxml = accepted.oxml;
|
|
318
|
+
normalizedExistingRevisions = true;
|
|
319
|
+
const acceptedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
|
|
320
|
+
parseWarnings.push(...acceptedParsed.warnings);
|
|
321
|
+
xmlDoc = acceptedParsed.doc;
|
|
322
|
+
const acceptedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
|
|
323
|
+
if (acceptedParsed.error || acceptedParseError) {
|
|
324
|
+
const message = acceptedParsed.error?.message || acceptedParseError?.textContent || 'Could not parse OOXML after accepting existing revisions.';
|
|
325
|
+
error('[OxmlEngine] XML parse error after accepting existing revisions:', message);
|
|
326
|
+
return finalize({
|
|
327
|
+
oxml: inputOoxml,
|
|
328
|
+
hasChanges: false,
|
|
329
|
+
status: 'error',
|
|
330
|
+
error: {
|
|
331
|
+
code: 'PARSE_ERROR',
|
|
332
|
+
message
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
337
|
+
} else {
|
|
338
|
+
log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
|
|
339
|
+
return finalize({
|
|
340
|
+
oxml: inputOoxml,
|
|
341
|
+
hasChanges: false,
|
|
342
|
+
status: 'error',
|
|
343
|
+
error: {
|
|
344
|
+
code: 'EXISTING_REVISIONS',
|
|
345
|
+
message: 'Input OOXML contains existing tracked changes and existingRevisions is "reject-input".'
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
|
|
352
|
+
if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
|
|
353
|
+
log('[OxmlEngine] Isolating table-cell paragraph before diff');
|
|
354
|
+
const isolatedOxml = serializeParagraphOnly(xmlDoc, initialTableCellContext.targetParagraph, serializer);
|
|
355
|
+
const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
|
|
356
|
+
...options,
|
|
357
|
+
_isolatedTableCell: true
|
|
358
|
+
});
|
|
359
|
+
if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
|
|
360
|
+
return finalizeUnchanged();
|
|
361
|
+
}
|
|
362
|
+
return isolatedResult;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let sanitizedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
|
|
366
|
+
if (sanitizedText !== modifiedText) {
|
|
367
|
+
operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
|
|
368
|
+
}
|
|
369
|
+
let structuredAnalysis = null;
|
|
370
|
+
if (options.structuredContent !== false && options.literalTextEdit !== true) {
|
|
371
|
+
structuredAnalysis = analyzeStructuredContent(sanitizedText);
|
|
372
|
+
if (!structuredAnalysis.valid) {
|
|
373
|
+
if (options.explicitStructuredContent === true) {
|
|
374
|
+
const message = structuredAnalysis.issues.map(issue => `${issue.code}: ${issue.message}`).join(' ');
|
|
375
|
+
return finalize({
|
|
376
|
+
oxml: inputOoxml,
|
|
377
|
+
hasChanges: false,
|
|
378
|
+
status: 'error',
|
|
379
|
+
error: { code: 'STRUCTURED_CONTENT_INVALID', message },
|
|
380
|
+
warnings: structuredAnalysis.issues.map(issue => issue.message)
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
structuredAnalysis = null;
|
|
384
|
+
} else if (structuredAnalysis.requiresStructuredContent) {
|
|
385
|
+
sanitizedText = structuredAnalysis.normalizedMarkdown;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// A caller's structuredContent: false is the documented plain-text contract:
|
|
389
|
+
// inline Markdown markers (`__`, `_`, `**`) stay literal text. Internal calls
|
|
390
|
+
// that pass structuredContent: false still want inline Markdown, so only the
|
|
391
|
+
// operation applier sets this flag.
|
|
392
|
+
const { cleanText: cleanModifiedText, formatHints } = options.plainTextContent === true
|
|
393
|
+
? { cleanText: sanitizedText, formatHints: [] }
|
|
394
|
+
: preprocessMarkdown(sanitizedText);
|
|
395
|
+
|
|
396
|
+
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
397
|
+
const hasLiteralTextChanges = existingRevisionsPolicy === 'slice-cross-author'
|
|
398
|
+
? cleanModifiedText !== originalText
|
|
399
|
+
: cleanModifiedText.trim() !== originalText.trim();
|
|
400
|
+
// Markdown list markers can equal manually typed ones ("A. ..." lines), so
|
|
401
|
+
// an explicit structured-content request that turns plain paragraphs or
|
|
402
|
+
// soft-break lines into a list is a change even when the text is identical.
|
|
403
|
+
const hasListStructureChange = !hasLiteralTextChanges
|
|
404
|
+
&& options.explicitStructuredContent === true
|
|
405
|
+
&& isListTargetLoose(cleanModifiedText)
|
|
406
|
+
&& !sourceMatchesListShape(paragraphs, cleanModifiedText);
|
|
407
|
+
const hasTextChanges = hasLiteralTextChanges || hasListStructureChange;
|
|
408
|
+
const hasFormatHints = formatHints.length > 0;
|
|
409
|
+
const hasExistingFormatting = existingFormatHints.length > 0;
|
|
410
|
+
// Formatting spans carry only w:t text. Target matching also needs w:br,
|
|
411
|
+
// w:cr and w:tab so host text with soft line breaks (Word's Paragraph.text
|
|
412
|
+
// reports them as "\v") still matches its source paragraph.
|
|
413
|
+
const { textSpans: targetSpans } = buildTextSpansFromParagraphs(paragraphs);
|
|
414
|
+
const visibleText = targetSpans.map(span => textSpanVisibleText(span)).join('');
|
|
415
|
+
const targetFound = originalText.includes('\n') || originalText.includes('\r')
|
|
416
|
+
? originalText
|
|
417
|
+
.split(/\r?\n/)
|
|
418
|
+
.map(normalizeTargetText)
|
|
419
|
+
.filter(Boolean)
|
|
420
|
+
.every(line => paragraphs.some(paragraph => {
|
|
421
|
+
const paragraphText = targetSpans
|
|
422
|
+
.filter(span => span.paragraph === paragraph)
|
|
423
|
+
.map(textSpanVisibleText)
|
|
424
|
+
.join('');
|
|
425
|
+
return normalizeTargetText(paragraphText).includes(line);
|
|
426
|
+
}))
|
|
427
|
+
: visibleText.includes(originalText.trim())
|
|
428
|
+
|| visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
|
|
429
|
+
|| normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
|
|
430
|
+
if (
|
|
431
|
+
hasTextChanges
|
|
432
|
+
&& typeof originalText === 'string'
|
|
433
|
+
&& originalText.trim()
|
|
434
|
+
&& !targetFound
|
|
435
|
+
) {
|
|
436
|
+
log('[OxmlEngine] Target text not found in OOXML');
|
|
437
|
+
return finalize({
|
|
438
|
+
oxml: inputOoxml,
|
|
439
|
+
hasChanges: false,
|
|
440
|
+
status: 'error',
|
|
441
|
+
error: {
|
|
442
|
+
code: 'TARGET_NOT_FOUND',
|
|
443
|
+
message: 'Original text was not found in the supplied OOXML.'
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
let paragraphInfos = null;
|
|
448
|
+
const getParagraphInfos = () => {
|
|
449
|
+
if (!paragraphInfos) {
|
|
450
|
+
paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
|
|
451
|
+
}
|
|
452
|
+
return paragraphInfos;
|
|
453
|
+
};
|
|
454
|
+
const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
|
|
455
|
+
const formatResult = applyFormatOnlyChangesSurgical(
|
|
456
|
+
xmlDoc,
|
|
457
|
+
originalText,
|
|
458
|
+
formatHints,
|
|
459
|
+
serializer,
|
|
460
|
+
author,
|
|
461
|
+
generateRedlines,
|
|
462
|
+
precomputedContext
|
|
463
|
+
);
|
|
464
|
+
if (!formatResult.useNativeApi) {
|
|
465
|
+
return formatResult;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
|
|
469
|
+
return applyReconstructionMode(
|
|
470
|
+
xmlDoc,
|
|
471
|
+
originalText,
|
|
472
|
+
cleanModifiedText,
|
|
473
|
+
serializer,
|
|
474
|
+
author,
|
|
475
|
+
formatHints,
|
|
476
|
+
generateRedlines
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
|
|
481
|
+
|
|
482
|
+
const needsFormatRemoval = options.removeFormatting === true
|
|
483
|
+
&& !hasTextChanges
|
|
484
|
+
&& !hasFormatHints
|
|
485
|
+
&& hasExistingFormatting;
|
|
486
|
+
|
|
487
|
+
if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
|
|
488
|
+
recordRouteSelection(options, 'noChange');
|
|
489
|
+
log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
|
|
490
|
+
return finalizeUnchanged();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
|
|
494
|
+
log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
|
|
495
|
+
return finalizeUnchanged();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (needsFormatRemoval) {
|
|
499
|
+
recordRouteSelection(options, 'formatOnly', { removeFormatting: true });
|
|
500
|
+
log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
|
|
501
|
+
|
|
502
|
+
const tableCellCtx = initialTableCellContext;
|
|
503
|
+
let targetParagraph = tableCellCtx.targetParagraph || null;
|
|
504
|
+
|
|
505
|
+
if (!targetParagraph) {
|
|
506
|
+
const matchedInfo = findMatchingParagraphInfo(getParagraphInfos(), originalText);
|
|
507
|
+
if (matchedInfo) {
|
|
508
|
+
targetParagraph = matchedInfo.paragraph;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
let filteredHints = existingFormatHints;
|
|
513
|
+
if (targetParagraph) {
|
|
514
|
+
filteredHints = existingFormatHints.filter(hint => {
|
|
515
|
+
const hintParagraph = getContainingParagraph(hint.run);
|
|
516
|
+
return hintParagraph === targetParagraph;
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const removalResult = applyFormatRemovalAsSurgicalReplacement(
|
|
521
|
+
xmlDoc,
|
|
522
|
+
textSpans,
|
|
523
|
+
filteredHints,
|
|
524
|
+
serializer,
|
|
525
|
+
author,
|
|
526
|
+
generateRedlines
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
if (tableCellCtx.hasTableWrapper && targetParagraph) {
|
|
530
|
+
return finalize({
|
|
531
|
+
oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
|
|
532
|
+
hasChanges: removalResult.hasChanges
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return finalize(removalResult);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!hasTextChanges && hasFormatHints) {
|
|
540
|
+
recordRouteSelection(options, 'formatOnly', { removeFormatting: false });
|
|
541
|
+
log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
|
|
542
|
+
|
|
543
|
+
const tableCellCtx = initialTableCellContext;
|
|
544
|
+
const precomputedFormatContext = {
|
|
545
|
+
textSpans,
|
|
546
|
+
paragraphs,
|
|
547
|
+
paragraphInfos: getParagraphInfos()
|
|
548
|
+
};
|
|
549
|
+
if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
|
|
550
|
+
log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
|
|
551
|
+
|
|
552
|
+
const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
|
|
553
|
+
|
|
554
|
+
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
|
|
555
|
+
return finalize({
|
|
556
|
+
oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
|
|
557
|
+
hasChanges: formatResult.hasChanges
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
|
|
565
|
+
const hasTables = tables.length > 0;
|
|
566
|
+
const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
|
|
567
|
+
// Localized replacements are literal text edits: marker-like lines ("A. ..." separated by
|
|
568
|
+
// soft breaks) must not be re-interpreted as a request to build a numbered list.
|
|
569
|
+
const isTargetList = options.literalTextEdit !== true && isListTargetLoose(cleanModifiedText);
|
|
570
|
+
const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
|
|
571
|
+
const tableCellContext = initialTableCellContext;
|
|
572
|
+
|
|
573
|
+
const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
|
|
574
|
+
log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
if (isMarkdownTable && !hasTables) {
|
|
578
|
+
recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
|
|
579
|
+
log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
|
|
580
|
+
return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines, { plainTextContent: options.plainTextContent === true }));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (hasTables && isMarkdownTable) {
|
|
584
|
+
recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
|
|
585
|
+
return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines, { plainTextContent: options.plainTextContent === true }));
|
|
586
|
+
}
|
|
587
|
+
if (usesSurgicalTextMode) {
|
|
588
|
+
recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
|
|
589
|
+
const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
|
|
590
|
+
? tableCellContext.targetParagraph
|
|
591
|
+
: null;
|
|
592
|
+
if (surgicalTarget) {
|
|
593
|
+
log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const result = applySurgicalMode(
|
|
597
|
+
xmlDoc,
|
|
598
|
+
originalText,
|
|
599
|
+
cleanModifiedText,
|
|
600
|
+
serializer,
|
|
601
|
+
author,
|
|
602
|
+
formatHints,
|
|
603
|
+
generateRedlines,
|
|
604
|
+
surgicalTarget,
|
|
605
|
+
{},
|
|
606
|
+
options
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
|
|
610
|
+
return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
614
|
+
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
|
|
615
|
+
return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
|
|
616
|
+
}
|
|
617
|
+
return finalize(result);
|
|
618
|
+
}
|
|
619
|
+
if (isTargetList || isStructuredContent) {
|
|
620
|
+
const sourceParagraphs = getElementsByTagNSOrTag(xmlDoc, NS_W, 'p');
|
|
621
|
+
const useDirectListGeneration = sourceParagraphs.length === 1;
|
|
622
|
+
recordRouteSelection(options, useDirectListGeneration ? 'listDirect' : 'listCompatibilityPipeline', {
|
|
623
|
+
sourceParagraphCount: sourceParagraphs.length
|
|
624
|
+
});
|
|
625
|
+
log(`[OxmlEngine] 🎯 Using ${useDirectListGeneration ? 'direct list generation' : 'compatibility pipeline'} for list reconciliation`);
|
|
626
|
+
let result;
|
|
627
|
+
if (useDirectListGeneration) {
|
|
628
|
+
const ingested = ingestOoxml(workingOoxml, { xmlDoc });
|
|
629
|
+
const numberingContext = detectNumberingContext(sourceParagraphs[0]);
|
|
630
|
+
result = await executeListGeneration({
|
|
631
|
+
cleanText: cleanModifiedText,
|
|
632
|
+
numberingContext,
|
|
633
|
+
originalRunModel: ingested.runModel,
|
|
634
|
+
originalText: ingested.acceptedText,
|
|
635
|
+
generateRedlines,
|
|
636
|
+
author,
|
|
637
|
+
font: options.font || null,
|
|
638
|
+
revisionIdAllocator,
|
|
639
|
+
numberingService: new NumberingService()
|
|
640
|
+
});
|
|
641
|
+
} else {
|
|
642
|
+
const pipeline = new ReconciliationPipeline({
|
|
643
|
+
author,
|
|
644
|
+
generateRedlines,
|
|
645
|
+
revisionIdAllocator
|
|
646
|
+
});
|
|
647
|
+
result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
|
|
651
|
+
return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
|
|
655
|
+
const includeNumbering = result.includeNumbering === true;
|
|
656
|
+
log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
|
|
657
|
+
const wrapped = wrapInDocumentFragment(result.ooxml, {
|
|
658
|
+
includeNumbering,
|
|
659
|
+
numberingXml: result.numberingXml
|
|
660
|
+
});
|
|
661
|
+
log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
|
|
662
|
+
return finalize({
|
|
663
|
+
oxml: wrapped,
|
|
664
|
+
hasChanges: true,
|
|
665
|
+
...(Array.isArray(result.warnings) ? { warnings: result.warnings } : {})
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
return finalizeUnchanged();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
recordRouteSelection(options, 'reconstruction');
|
|
672
|
+
return finalize(applyReconstructionMode(
|
|
673
|
+
xmlDoc,
|
|
674
|
+
originalText,
|
|
675
|
+
cleanModifiedText,
|
|
676
|
+
serializer,
|
|
677
|
+
author,
|
|
678
|
+
formatHints,
|
|
679
|
+
generateRedlines,
|
|
680
|
+
{},
|
|
681
|
+
options
|
|
682
|
+
));
|
|
683
|
+
} catch (caught) {
|
|
684
|
+
if (isDiffTokenLimitError(caught)) {
|
|
685
|
+
return finalize({
|
|
686
|
+
oxml: inputOoxml,
|
|
687
|
+
hasChanges: false,
|
|
688
|
+
status: 'error',
|
|
689
|
+
error: { code: caught.code, message: caught.message }
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
throw caught;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function sourceMatchesListShape(paragraphs, listText) {
|
|
697
|
+
const itemCount = String(listText).split(/\r?\n/).filter(line => line.trim()).length;
|
|
698
|
+
return paragraphs.length === itemCount && paragraphs.every(paragraph => (
|
|
699
|
+
// Current numPr only; a pPrChange snapshot does not make the source a list.
|
|
700
|
+
getCurrentParagraphProperties(paragraph).numPr != null
|
|
701
|
+
));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function normalizeTargetText(text) {
|
|
705
|
+
return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function textSpanVisibleText(span) {
|
|
709
|
+
const node = span?.textElement;
|
|
710
|
+
const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
|
|
711
|
+
if (localName === 'tab') return '\t';
|
|
712
|
+
if (localName === 'br' || localName === 'cr') return '\n';
|
|
713
|
+
if (localName === 'noBreakHyphen') return '\u2011';
|
|
714
|
+
return node?.textContent || '';
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Sanitizes AI response text by removing common prefixes.
|
|
719
|
+
*
|
|
720
|
+
* @param {string} text - AI response text
|
|
721
|
+
* @returns {string}
|
|
722
|
+
*/
|
|
723
|
+
export function sanitizeAiResponse(text) {
|
|
724
|
+
return String(text ?? '').replace(
|
|
725
|
+
/^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
|
|
726
|
+
''
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Parses OOXML into a DOM document.
|
|
732
|
+
*
|
|
733
|
+
* @param {string} ooxmlString - OOXML text
|
|
734
|
+
* @returns {Document}
|
|
735
|
+
*/
|
|
736
|
+
export function parseOoxml(ooxmlString) {
|
|
737
|
+
return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Serializes a DOM document to OOXML text.
|
|
742
|
+
*
|
|
743
|
+
* @param {Node} doc - XML document/node
|
|
744
|
+
* @returns {string}
|
|
745
|
+
*/
|
|
746
|
+
export function serializeOoxml(doc) {
|
|
747
|
+
return serializeXml(doc);
|
|
748
|
+
}
|