@ansonlai/docx-redline-js 0.4.0 → 0.5.1
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 +646 -288
- package/ARCHITECTURE.md +215 -9
- package/CHANGELOG.md +319 -0
- package/README.md +604 -360
- package/adapters/config.js +45 -43
- package/bin/docx-redline.js +3 -0
- package/core/list-targeting.js +101 -110
- package/core/paragraph-targeting.js +501 -61
- package/core/paragraph-text.js +209 -0
- package/core/redline-validation.js +11 -5
- package/core/revision-cloning.js +38 -0
- package/core/types.js +64 -10
- package/core/word-xml.js +43 -15
- package/dist/docx-redline-js.esm.js +3145 -505
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +88 -76
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +342 -23
- package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +505 -0
- package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
- package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
- package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
- package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
- package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
- package/docs/schemas/document-operations.schema.json +109 -0
- package/docs/test-comparison-dashboard.html +4250 -7
- package/engine/formatting-removal.js +11 -2
- package/engine/oxml-engine.js +508 -336
- package/engine/reconstruction-mode.js +15 -14
- package/engine/reconstruction-writer.js +247 -142
- package/engine/route-selection.js +35 -0
- package/engine/rpr-helpers.js +334 -35
- package/engine/run-builders.js +239 -196
- package/engine/surgical-diff-application.js +407 -50
- package/engine/surgical-mode.js +142 -6
- package/engine/surgical-run-splitting.js +103 -0
- package/engine/surgical-spans.js +52 -1
- package/engine/table-cell-context.js +3 -6
- package/engine/table-mode.js +1 -1
- package/index.d.ts +234 -6
- package/index.js +24 -1
- package/node/cli.js +322 -0
- package/node/docx-document.js +302 -0
- package/node/index.d.ts +31 -0
- package/node/index.js +2 -0
- package/node/zip-archive.js +52 -0
- package/orchestration/list-markdown.js +10 -16
- package/orchestration/list-parsing.js +7 -12
- package/orchestration/list-structural-fallback.js +21 -10
- package/package.json +123 -102
- package/pipeline/content-analysis.js +12 -17
- package/pipeline/ingestion-export.js +3 -31
- package/pipeline/ingestion-paragraph.js +10 -5
- package/pipeline/list-generation.js +150 -55
- package/pipeline/list-markers.js +70 -3
- package/pipeline/serialization.js +4 -2
- package/pipeline/structured-content.js +160 -0
- package/scripts/apply_changes.mjs +27 -0
- package/scripts/benchmark-operation-session.mjs +137 -0
- package/scripts/benchmark-targeting-browser.html +74 -0
- package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
- package/scripts/benchmark-test-runner.mjs +59 -0
- package/scripts/build-test-dashboard.mjs +23 -0
- package/scripts/export-lane1-fixtures.mjs +380 -0
- package/scripts/export-reredline-stress-fixtures.mjs +317 -0
- package/scripts/export-validation-fixtures.mjs +1 -1
- package/scripts/extract_text.mjs +7 -0
- package/scripts/generate-cross-author-slicing-fixtures.ps1 +256 -0
- package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
- package/scripts/generate-test-dashboard.mjs +362 -11
- package/scripts/lib/word-coverage-catalogue.mjs +6 -2
- package/scripts/profile-route-selection.mjs +19 -0
- package/scripts/render-agenda-multilevel.mjs +0 -5
- package/scripts/render-multilevel-cases.mjs +0 -1
- package/scripts/run-tests.mjs +107 -35
- package/scripts/word-com-corpus-suite.ps1 +3 -0
- package/scripts/word-com-differential.ps1 +64 -4
- package/scripts/word-com-suite.ps1 +3 -0
- package/services/batch-operation-orchestrator.js +513 -0
- package/services/capture-engine.js +226 -0
- package/services/comment-builders.js +23 -6
- package/services/comment-engine.js +108 -47
- package/services/comment-locator.js +187 -82
- package/services/comment-replies.js +95 -0
- package/services/document-inspection.js +258 -0
- package/services/document-operation-applier.js +372 -0
- package/services/document-operation-contract.js +345 -0
- package/services/document-operation-mutations.js +1749 -0
- package/services/document-operation-session.js +258 -0
- package/services/numbering-service.js +14 -5
- package/services/operation-heuristics.js +173 -0
- package/services/operation-preflight.js +390 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +77 -5
- package/services/revision-token.js +290 -0
- package/services/standalone-docx-plumbing.js +123 -8
- package/services/standalone-operation-runner.d.ts +296 -0
- package/services/standalone-operation-runner.js +10 -1455
- package/services/table-reconciliation.js +15 -6
- package/docs/VALIDATION.md +0 -183
- package/docs/WORD-MANUAL-REVIEW.md +0 -138
- package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
- /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
package/engine/oxml-engine.js
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
import { preprocessMarkdown } from '../pipeline/markdown-processor.js';
|
|
8
8
|
import { isListTargetLoose } from '../pipeline/list-markers.js';
|
|
9
9
|
import { ReconciliationPipeline } from '../pipeline/pipeline.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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';
|
|
16
19
|
import { log, error } from '../adapters/logger.js';
|
|
17
20
|
import { extractFormattingFromOoxml } from './format-extraction.js';
|
|
18
21
|
import {
|
|
@@ -21,18 +24,33 @@ import {
|
|
|
21
24
|
} from './format-application.js';
|
|
22
25
|
import { buildParagraphInfos, findMatchingParagraphInfo, getContainingParagraph } from './format-paragraph-targeting.js';
|
|
23
26
|
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
|
-
import { containsTrackedChanges, withOoxmlSourceType } from '../core/word-xml.js';
|
|
29
|
-
import {
|
|
30
|
-
NS_W,
|
|
31
|
-
RevisionIdAllocator,
|
|
32
|
-
seedRevisionIdsFromDocument
|
|
33
|
-
} from '../core/types.js';
|
|
34
|
-
import { acceptTrackedChangesInOoxml } from '../services/revision-comment-management.js';
|
|
35
|
-
import {
|
|
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 {
|
|
33
|
+
NS_W,
|
|
34
|
+
RevisionIdAllocator,
|
|
35
|
+
seedRevisionIdsFromDocument
|
|
36
|
+
} from '../core/types.js';
|
|
37
|
+
import { acceptTrackedChangesInOoxml, rejectTrackedChangesInOoxml } from '../services/revision-comment-management.js';
|
|
38
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
39
|
+
import { getDocumentParagraphs } from './format-extraction.js';
|
|
40
|
+
import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
|
|
41
|
+
import { NumberingService } from '../services/numbering-service.js';
|
|
42
|
+
import { recordRouteSelection } from './route-selection.js';
|
|
43
|
+
|
|
44
|
+
function getCommentIdsInOoxml(node) {
|
|
45
|
+
const ids = new Set();
|
|
46
|
+
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
47
|
+
for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
|
|
48
|
+
const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
|
|
49
|
+
if (id !== '') ids.add(id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
53
|
+
}
|
|
36
54
|
|
|
37
55
|
/**
|
|
38
56
|
* Applies redline track changes to OOXML by modifying the DOM in-place.
|
|
@@ -40,229 +58,346 @@ import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
|
|
|
40
58
|
* @param {string} oxml - Original OOXML string
|
|
41
59
|
* @param {string} originalText - Original plain text
|
|
42
60
|
* @param {string} modifiedText - New text (may contain markdown)
|
|
43
|
-
* @param {Object} [options={}] - Options
|
|
44
|
-
* @param {string} [options.author='AI'] - Author for track changes
|
|
45
|
-
* @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
|
|
46
|
-
* @param {'reject-input'|'accept-all-first'|'accept-all-first-keep-normalized'} [options.existingRevisions='
|
|
47
|
-
* @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
|
|
48
|
-
* @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
|
|
49
|
-
* @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
|
|
50
|
-
*/
|
|
51
|
-
export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
52
|
-
const inputOoxml = oxml;
|
|
53
|
-
let workingOoxml = oxml;
|
|
54
|
-
originalText = typeof originalText === 'string' ? originalText : String(originalText ?? '');
|
|
55
|
-
modifiedText = typeof modifiedText === 'string' ? modifiedText : String(modifiedText ?? '');
|
|
56
|
-
const generateRedlines = options.generateRedlines ?? true;
|
|
57
|
-
const author = options.author || getDefaultAuthor();
|
|
58
|
-
const serializer = createSerializer();
|
|
59
|
-
let parseWarnings = [];
|
|
60
|
-
const operationWarnings = [];
|
|
61
|
-
let normalizedExistingRevisions = false;
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
withStatus.
|
|
69
|
-
withStatus.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
61
|
+
* @param {Object} [options={}] - Options
|
|
62
|
+
* @param {string} [options.author='AI'] - Author for track changes
|
|
63
|
+
* @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
|
|
64
|
+
* @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
|
|
65
|
+
* @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
|
|
66
|
+
* @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
|
|
67
|
+
* @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
|
|
68
|
+
*/
|
|
69
|
+
export async function applyRedlineToOxml(oxml, originalText, modifiedText, options = {}) {
|
|
70
|
+
const inputOoxml = oxml;
|
|
71
|
+
let workingOoxml = oxml;
|
|
72
|
+
originalText = typeof originalText === 'string' ? originalText : String(originalText ?? '');
|
|
73
|
+
modifiedText = typeof modifiedText === 'string' ? modifiedText : String(modifiedText ?? '');
|
|
74
|
+
const generateRedlines = options.generateRedlines ?? true;
|
|
75
|
+
const author = options.author || getDefaultAuthor();
|
|
76
|
+
const serializer = createSerializer();
|
|
77
|
+
let parseWarnings = [];
|
|
78
|
+
const operationWarnings = [];
|
|
79
|
+
let normalizedExistingRevisions = false;
|
|
80
|
+
const existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
|
|
81
|
+
const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
|
|
82
|
+
const finalize = result => {
|
|
83
|
+
const withStatus = { ...result };
|
|
84
|
+
if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
|
|
85
|
+
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
86
|
+
withStatus.oxml = workingOoxml;
|
|
87
|
+
withStatus.hasChanges = true;
|
|
88
|
+
withStatus.warnings = [
|
|
89
|
+
...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
|
|
90
|
+
'Previous revisions by the same author were reverted to baseline.'
|
|
91
|
+
];
|
|
92
|
+
} else if (keepNormalizedNoOp) {
|
|
93
|
+
withStatus.oxml = workingOoxml;
|
|
94
|
+
withStatus.hasChanges = true;
|
|
95
|
+
withStatus.warnings = [
|
|
96
|
+
...(Array.isArray(withStatus.warnings) ? withStatus.warnings : []),
|
|
97
|
+
'Existing revisions were accepted before redlining.'
|
|
98
|
+
];
|
|
99
|
+
} else {
|
|
100
|
+
withStatus.oxml = inputOoxml;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const warnings = [...parseWarnings, ...operationWarnings, ...(Array.isArray(withStatus.warnings) ? withStatus.warnings : [])];
|
|
104
|
+
if (warnings.length > 0) {
|
|
105
|
+
withStatus.warnings = [...new Set(warnings)];
|
|
106
|
+
}
|
|
107
|
+
if (!withStatus.status) {
|
|
108
|
+
withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
|
|
109
|
+
}
|
|
110
|
+
return withOoxmlSourceType(withStatus);
|
|
111
|
+
};
|
|
112
|
+
const finalizeUnchanged = () => {
|
|
113
|
+
if (normalizedExistingRevisions && keepNormalizedNoOp) {
|
|
114
|
+
return finalize({
|
|
115
|
+
oxml: workingOoxml,
|
|
116
|
+
hasChanges: true,
|
|
117
|
+
warnings: ['Existing revisions were accepted before redlining.']
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return finalize({ oxml: inputOoxml, hasChanges: false });
|
|
121
|
+
};
|
|
96
122
|
|
|
97
|
-
const parsed = parseOoxmlSafe(inputOoxml, 'text/xml');
|
|
98
|
-
parseWarnings = parsed.warnings;
|
|
99
|
-
let xmlDoc = parsed.doc;
|
|
100
|
-
|
|
101
|
-
const parseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
|
|
102
|
-
if (parsed.error || parseError) {
|
|
103
|
-
const message = parsed.error?.message || parseError?.textContent || 'Could not parse OOXML input.';
|
|
104
|
-
error('[OxmlEngine] XML parse error:', message);
|
|
105
|
-
return finalize({
|
|
106
|
-
oxml: inputOoxml,
|
|
107
|
-
hasChanges: false,
|
|
108
|
-
status: 'error',
|
|
109
|
-
error: { code: 'PARSE_ERROR', message }
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
|
|
113
|
-
? options._revisionIdAllocator
|
|
114
|
-
: new RevisionIdAllocator();
|
|
115
|
-
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
116
|
-
|
|
117
|
-
if (containsTrackedChanges(xmlDoc)) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
123
|
+
const parsed = parseOoxmlSafe(inputOoxml, 'text/xml');
|
|
124
|
+
parseWarnings = parsed.warnings;
|
|
125
|
+
let xmlDoc = parsed.doc;
|
|
126
|
+
|
|
127
|
+
const parseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
|
|
128
|
+
if (parsed.error || parseError) {
|
|
129
|
+
const message = parsed.error?.message || parseError?.textContent || 'Could not parse OOXML input.';
|
|
130
|
+
error('[OxmlEngine] XML parse error:', message);
|
|
131
|
+
return finalize({
|
|
132
|
+
oxml: inputOoxml,
|
|
133
|
+
hasChanges: false,
|
|
134
|
+
status: 'error',
|
|
135
|
+
error: { code: 'PARSE_ERROR', message }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
|
|
139
|
+
? options._revisionIdAllocator
|
|
140
|
+
: new RevisionIdAllocator();
|
|
141
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
142
|
+
|
|
143
|
+
if (containsTrackedChanges(xmlDoc)) {
|
|
144
|
+
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
145
|
+
const authors = getTrackedChangeAuthors(xmlDoc);
|
|
146
|
+
const currentAuthor = String(author || '').trim().toLowerCase();
|
|
147
|
+
const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
|
|
148
|
+
if (isSameAuthor) {
|
|
149
|
+
const commentIds = getCommentIdsInOoxml(xmlDoc);
|
|
150
|
+
if (commentIds.length > 0) {
|
|
151
|
+
return finalize({
|
|
152
|
+
oxml: inputOoxml,
|
|
153
|
+
hasChanges: false,
|
|
154
|
+
status: 'error',
|
|
155
|
+
error: {
|
|
156
|
+
code: 'COMMENTED_CONTENT_MERGE',
|
|
157
|
+
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
158
|
+
commentIds
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
|
|
163
|
+
const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
|
|
164
|
+
if (rejected.status === 'error') return finalize(rejected);
|
|
165
|
+
workingOoxml = rejected.oxml;
|
|
166
|
+
normalizedExistingRevisions = true;
|
|
167
|
+
const rejectedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
|
|
168
|
+
parseWarnings.push(...rejectedParsed.warnings);
|
|
169
|
+
xmlDoc = rejectedParsed.doc;
|
|
170
|
+
const rejectedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
|
|
171
|
+
if (rejectedParsed.error || rejectedParseError) {
|
|
172
|
+
const message = rejectedParsed.error?.message || rejectedParseError?.textContent || 'Could not parse OOXML after rejecting same-author revisions.';
|
|
173
|
+
error('[OxmlEngine] XML parse error after rejecting same-author revisions:', message);
|
|
174
|
+
return finalize({
|
|
175
|
+
oxml: inputOoxml,
|
|
176
|
+
hasChanges: false,
|
|
177
|
+
status: 'error',
|
|
178
|
+
error: {
|
|
179
|
+
code: 'PARSE_ERROR',
|
|
180
|
+
message
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (containsTrackedChanges(xmlDoc)) {
|
|
185
|
+
return finalize({
|
|
186
|
+
oxml: inputOoxml,
|
|
187
|
+
hasChanges: false,
|
|
188
|
+
status: 'error',
|
|
189
|
+
error: {
|
|
190
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
191
|
+
message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
196
|
+
|
|
197
|
+
// Re-derive baseline text from the restored baseline OOXML:
|
|
198
|
+
const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
|
|
199
|
+
? [xmlDoc.documentElement]
|
|
200
|
+
: getDocumentParagraphs(xmlDoc);
|
|
201
|
+
const baselineText = paragraphsInDoc.length > 0
|
|
202
|
+
? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
|
|
203
|
+
: '';
|
|
204
|
+
originalText = baselineText;
|
|
205
|
+
} else if (existingRevisionsPolicy === 'merge-same-author') {
|
|
206
|
+
log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
|
|
207
|
+
return finalize({
|
|
208
|
+
oxml: inputOoxml,
|
|
209
|
+
hasChanges: false,
|
|
210
|
+
status: 'error',
|
|
211
|
+
error: {
|
|
212
|
+
code: 'EXISTING_REVISIONS',
|
|
213
|
+
message: `Input OOXML contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
|
|
218
|
+
return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
|
|
140
219
|
});
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
message: 'Input OOXML contains existing tracked changes. Pass existingRevisions: "accept-all-first" to normalize before redlining.'
|
|
220
|
+
if (hasMoveRevision) {
|
|
221
|
+
return finalize({
|
|
222
|
+
oxml: inputOoxml,
|
|
223
|
+
hasChanges: false,
|
|
224
|
+
status: 'error',
|
|
225
|
+
error: {
|
|
226
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
227
|
+
message: 'Cross-author slicing does not support pending move revisions.'
|
|
228
|
+
}
|
|
229
|
+
});
|
|
152
230
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
231
|
+
log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
|
|
232
|
+
}
|
|
233
|
+
} else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
|
|
234
|
+
log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
|
|
235
|
+
const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
|
|
236
|
+
if (accepted.status === 'error') return finalize(accepted);
|
|
237
|
+
workingOoxml = accepted.oxml;
|
|
238
|
+
normalizedExistingRevisions = true;
|
|
239
|
+
const acceptedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
|
|
240
|
+
parseWarnings.push(...acceptedParsed.warnings);
|
|
241
|
+
xmlDoc = acceptedParsed.doc;
|
|
242
|
+
const acceptedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
|
|
243
|
+
if (acceptedParsed.error || acceptedParseError) {
|
|
244
|
+
const message = acceptedParsed.error?.message || acceptedParseError?.textContent || 'Could not parse OOXML after accepting existing revisions.';
|
|
245
|
+
error('[OxmlEngine] XML parse error after accepting existing revisions:', message);
|
|
246
|
+
return finalize({
|
|
247
|
+
oxml: inputOoxml,
|
|
248
|
+
hasChanges: false,
|
|
249
|
+
status: 'error',
|
|
250
|
+
error: {
|
|
251
|
+
code: 'PARSE_ERROR',
|
|
252
|
+
message
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
257
|
+
} else {
|
|
258
|
+
log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
|
|
259
|
+
return finalize({
|
|
260
|
+
oxml: inputOoxml,
|
|
261
|
+
hasChanges: false,
|
|
262
|
+
status: 'error',
|
|
263
|
+
error: {
|
|
264
|
+
code: 'EXISTING_REVISIONS',
|
|
265
|
+
message: 'Input OOXML contains existing tracked changes and existingRevisions is "reject-input".'
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
|
|
158
272
|
if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
|
|
159
273
|
log('[OxmlEngine] Isolating table-cell paragraph before diff');
|
|
160
274
|
const isolatedOxml = serializeParagraphOnly(xmlDoc, initialTableCellContext.targetParagraph, serializer);
|
|
161
|
-
const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
|
|
162
|
-
...options,
|
|
163
|
-
_isolatedTableCell: true
|
|
164
|
-
});
|
|
165
|
-
if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
|
|
166
|
-
return finalizeUnchanged();
|
|
167
|
-
}
|
|
168
|
-
return isolatedResult;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (sanitizedText !== modifiedText) {
|
|
173
|
-
operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
|
|
174
|
-
}
|
|
275
|
+
const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
|
|
276
|
+
...options,
|
|
277
|
+
_isolatedTableCell: true
|
|
278
|
+
});
|
|
279
|
+
if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
|
|
280
|
+
return finalizeUnchanged();
|
|
281
|
+
}
|
|
282
|
+
return isolatedResult;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let sanitizedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
|
|
286
|
+
if (sanitizedText !== modifiedText) {
|
|
287
|
+
operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
|
|
288
|
+
}
|
|
289
|
+
let structuredAnalysis = null;
|
|
290
|
+
if (options.structuredContent !== false) {
|
|
291
|
+
structuredAnalysis = analyzeStructuredContent(sanitizedText);
|
|
292
|
+
if (!structuredAnalysis.valid) {
|
|
293
|
+
if (options.explicitStructuredContent === true) {
|
|
294
|
+
const message = structuredAnalysis.issues.map(issue => `${issue.code}: ${issue.message}`).join(' ');
|
|
295
|
+
return finalize({
|
|
296
|
+
oxml: inputOoxml,
|
|
297
|
+
hasChanges: false,
|
|
298
|
+
status: 'error',
|
|
299
|
+
error: { code: 'STRUCTURED_CONTENT_INVALID', message },
|
|
300
|
+
warnings: structuredAnalysis.issues.map(issue => issue.message)
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
structuredAnalysis = null;
|
|
304
|
+
} else if (structuredAnalysis.requiresStructuredContent) {
|
|
305
|
+
sanitizedText = structuredAnalysis.normalizedMarkdown;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
175
308
|
const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
|
|
176
309
|
|
|
177
310
|
const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
|
|
178
311
|
const hasFormatHints = formatHints.length > 0;
|
|
179
312
|
|
|
180
|
-
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
181
|
-
const hasExistingFormatting = existingFormatHints.length > 0;
|
|
182
|
-
const visibleText = textSpans.map(span => textSpanVisibleText(span)).join('');
|
|
183
|
-
const targetFound = originalText.includes('\n') || originalText.includes('\r')
|
|
184
|
-
? originalText
|
|
185
|
-
.split(/\r?\n/)
|
|
186
|
-
.map(normalizeTargetText)
|
|
187
|
-
.filter(Boolean)
|
|
188
|
-
.every(line => paragraphs.some(paragraph => {
|
|
189
|
-
const paragraphText = textSpans
|
|
190
|
-
.filter(span => span.paragraph === paragraph)
|
|
191
|
-
.map(textSpanVisibleText)
|
|
192
|
-
.join('');
|
|
193
|
-
return normalizeTargetText(paragraphText).includes(line);
|
|
194
|
-
}))
|
|
195
|
-
: visibleText.includes(originalText.trim())
|
|
196
|
-
|| visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
|
|
197
|
-
|| normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
|
|
198
|
-
if (
|
|
199
|
-
hasTextChanges
|
|
200
|
-
&& typeof originalText === 'string'
|
|
201
|
-
&& originalText.trim()
|
|
202
|
-
&& !targetFound
|
|
203
|
-
) {
|
|
204
|
-
log('[OxmlEngine] Target text not found in OOXML');
|
|
205
|
-
return finalize({
|
|
206
|
-
oxml: inputOoxml,
|
|
207
|
-
hasChanges: false,
|
|
208
|
-
status: 'error',
|
|
209
|
-
error: {
|
|
210
|
-
code: 'TARGET_NOT_FOUND',
|
|
211
|
-
message: 'Original text was not found in the supplied OOXML.'
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
let paragraphInfos = null;
|
|
216
|
-
const getParagraphInfos = () => {
|
|
217
|
-
if (!paragraphInfos) {
|
|
218
|
-
paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
|
|
219
|
-
}
|
|
220
|
-
return paragraphInfos;
|
|
221
|
-
};
|
|
222
|
-
const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
|
|
223
|
-
const formatResult = applyFormatOnlyChangesSurgical(
|
|
224
|
-
xmlDoc,
|
|
225
|
-
originalText,
|
|
226
|
-
formatHints,
|
|
227
|
-
serializer,
|
|
228
|
-
author,
|
|
229
|
-
generateRedlines,
|
|
230
|
-
precomputedContext
|
|
231
|
-
);
|
|
232
|
-
if (!formatResult.useNativeApi) {
|
|
233
|
-
return formatResult;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
|
|
237
|
-
return applyReconstructionMode(
|
|
238
|
-
xmlDoc,
|
|
239
|
-
originalText,
|
|
240
|
-
cleanModifiedText,
|
|
241
|
-
serializer,
|
|
242
|
-
author,
|
|
243
|
-
formatHints,
|
|
244
|
-
generateRedlines
|
|
245
|
-
);
|
|
246
|
-
};
|
|
313
|
+
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
314
|
+
const hasExistingFormatting = existingFormatHints.length > 0;
|
|
315
|
+
const visibleText = textSpans.map(span => textSpanVisibleText(span)).join('');
|
|
316
|
+
const targetFound = originalText.includes('\n') || originalText.includes('\r')
|
|
317
|
+
? originalText
|
|
318
|
+
.split(/\r?\n/)
|
|
319
|
+
.map(normalizeTargetText)
|
|
320
|
+
.filter(Boolean)
|
|
321
|
+
.every(line => paragraphs.some(paragraph => {
|
|
322
|
+
const paragraphText = textSpans
|
|
323
|
+
.filter(span => span.paragraph === paragraph)
|
|
324
|
+
.map(textSpanVisibleText)
|
|
325
|
+
.join('');
|
|
326
|
+
return normalizeTargetText(paragraphText).includes(line);
|
|
327
|
+
}))
|
|
328
|
+
: visibleText.includes(originalText.trim())
|
|
329
|
+
|| visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
|
|
330
|
+
|| normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
|
|
331
|
+
if (
|
|
332
|
+
hasTextChanges
|
|
333
|
+
&& typeof originalText === 'string'
|
|
334
|
+
&& originalText.trim()
|
|
335
|
+
&& !targetFound
|
|
336
|
+
) {
|
|
337
|
+
log('[OxmlEngine] Target text not found in OOXML');
|
|
338
|
+
return finalize({
|
|
339
|
+
oxml: inputOoxml,
|
|
340
|
+
hasChanges: false,
|
|
341
|
+
status: 'error',
|
|
342
|
+
error: {
|
|
343
|
+
code: 'TARGET_NOT_FOUND',
|
|
344
|
+
message: 'Original text was not found in the supplied OOXML.'
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
let paragraphInfos = null;
|
|
349
|
+
const getParagraphInfos = () => {
|
|
350
|
+
if (!paragraphInfos) {
|
|
351
|
+
paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
|
|
352
|
+
}
|
|
353
|
+
return paragraphInfos;
|
|
354
|
+
};
|
|
355
|
+
const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
|
|
356
|
+
const formatResult = applyFormatOnlyChangesSurgical(
|
|
357
|
+
xmlDoc,
|
|
358
|
+
originalText,
|
|
359
|
+
formatHints,
|
|
360
|
+
serializer,
|
|
361
|
+
author,
|
|
362
|
+
generateRedlines,
|
|
363
|
+
precomputedContext
|
|
364
|
+
);
|
|
365
|
+
if (!formatResult.useNativeApi) {
|
|
366
|
+
return formatResult;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
|
|
370
|
+
return applyReconstructionMode(
|
|
371
|
+
xmlDoc,
|
|
372
|
+
originalText,
|
|
373
|
+
cleanModifiedText,
|
|
374
|
+
serializer,
|
|
375
|
+
author,
|
|
376
|
+
formatHints,
|
|
377
|
+
generateRedlines
|
|
378
|
+
);
|
|
379
|
+
};
|
|
247
380
|
|
|
248
381
|
log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
|
|
249
382
|
|
|
250
|
-
const needsFormatRemoval = options.removeFormatting === true
|
|
251
|
-
&& !hasTextChanges
|
|
252
|
-
&& !hasFormatHints
|
|
253
|
-
&& hasExistingFormatting;
|
|
383
|
+
const needsFormatRemoval = options.removeFormatting === true
|
|
384
|
+
&& !hasTextChanges
|
|
385
|
+
&& !hasFormatHints
|
|
386
|
+
&& hasExistingFormatting;
|
|
254
387
|
|
|
255
|
-
if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
388
|
+
if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
|
|
389
|
+
recordRouteSelection(options, 'noChange');
|
|
390
|
+
log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
|
|
391
|
+
return finalizeUnchanged();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
|
|
395
|
+
log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
|
|
396
|
+
return finalizeUnchanged();
|
|
397
|
+
}
|
|
264
398
|
|
|
265
399
|
if (needsFormatRemoval) {
|
|
400
|
+
recordRouteSelection(options, 'formatOnly', { removeFormatting: true });
|
|
266
401
|
log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
|
|
267
402
|
|
|
268
403
|
const tableCellCtx = initialTableCellContext;
|
|
@@ -293,16 +428,17 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
293
428
|
);
|
|
294
429
|
|
|
295
430
|
if (tableCellCtx.hasTableWrapper && targetParagraph) {
|
|
296
|
-
return finalize({
|
|
297
|
-
oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
|
|
298
|
-
hasChanges: removalResult.hasChanges
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return finalize(removalResult);
|
|
431
|
+
return finalize({
|
|
432
|
+
oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
|
|
433
|
+
hasChanges: removalResult.hasChanges
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return finalize(removalResult);
|
|
303
438
|
}
|
|
304
439
|
|
|
305
440
|
if (!hasTextChanges && hasFormatHints) {
|
|
441
|
+
recordRouteSelection(options, 'formatOnly', { removeFormatting: false });
|
|
306
442
|
log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
|
|
307
443
|
|
|
308
444
|
const tableCellCtx = initialTableCellContext;
|
|
@@ -311,39 +447,44 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
311
447
|
paragraphs,
|
|
312
448
|
paragraphInfos: getParagraphInfos()
|
|
313
449
|
};
|
|
314
|
-
if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
|
|
315
|
-
log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
|
|
316
|
-
|
|
317
|
-
const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
|
|
318
|
-
|
|
319
|
-
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
|
|
320
|
-
return finalize({
|
|
321
|
-
oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
|
|
322
|
-
hasChanges: formatResult.hasChanges
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
|
|
327
|
-
}
|
|
450
|
+
if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
|
|
451
|
+
log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
|
|
452
|
+
|
|
453
|
+
const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
|
|
454
|
+
|
|
455
|
+
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
|
|
456
|
+
return finalize({
|
|
457
|
+
oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
|
|
458
|
+
hasChanges: formatResult.hasChanges
|
|
459
|
+
});
|
|
460
|
+
}
|
|
328
461
|
|
|
329
|
-
|
|
462
|
+
return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
|
|
330
466
|
const hasTables = tables.length > 0;
|
|
331
467
|
const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
|
|
332
468
|
const isTargetList = isListTargetLoose(cleanModifiedText);
|
|
469
|
+
const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
|
|
333
470
|
const tableCellContext = initialTableCellContext;
|
|
334
471
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
472
|
+
const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
|
|
473
|
+
log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
if (isMarkdownTable && !hasTables) {
|
|
477
|
+
recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
|
|
339
478
|
log('[OxmlEngine] Text-to-table transformation: generating new table from Markdown');
|
|
340
|
-
return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (hasTables && isMarkdownTable) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
479
|
+
return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (hasTables && isMarkdownTable) {
|
|
483
|
+
recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
|
|
484
|
+
return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
485
|
+
}
|
|
486
|
+
if (usesSurgicalTextMode) {
|
|
487
|
+
recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
|
|
347
488
|
const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
|
|
348
489
|
? tableCellContext.targetParagraph
|
|
349
490
|
: null;
|
|
@@ -356,91 +497,122 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
356
497
|
originalText,
|
|
357
498
|
cleanModifiedText,
|
|
358
499
|
serializer,
|
|
359
|
-
author,
|
|
360
|
-
formatHints,
|
|
361
|
-
generateRedlines,
|
|
362
|
-
surgicalTarget
|
|
500
|
+
author,
|
|
501
|
+
formatHints,
|
|
502
|
+
generateRedlines,
|
|
503
|
+
surgicalTarget,
|
|
504
|
+
{},
|
|
505
|
+
options
|
|
363
506
|
);
|
|
364
507
|
|
|
365
|
-
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
366
|
-
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
|
|
367
|
-
return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
|
|
368
|
-
}
|
|
369
|
-
return finalize(result);
|
|
508
|
+
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
509
|
+
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
|
|
510
|
+
return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
|
|
511
|
+
}
|
|
512
|
+
return finalize(result);
|
|
370
513
|
}
|
|
371
|
-
if (isTargetList) {
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
514
|
+
if (isTargetList || isStructuredContent) {
|
|
515
|
+
const sourceParagraphs = getElementsByTagNSOrTag(xmlDoc, NS_W, 'p');
|
|
516
|
+
const useDirectListGeneration = sourceParagraphs.length === 1;
|
|
517
|
+
recordRouteSelection(options, useDirectListGeneration ? 'listDirect' : 'listCompatibilityPipeline', {
|
|
518
|
+
sourceParagraphCount: sourceParagraphs.length
|
|
519
|
+
});
|
|
520
|
+
log(`[OxmlEngine] 🎯 Using ${useDirectListGeneration ? 'direct list generation' : 'compatibility pipeline'} for list reconciliation`);
|
|
521
|
+
let result;
|
|
522
|
+
if (useDirectListGeneration) {
|
|
523
|
+
const ingested = ingestOoxml(workingOoxml, { xmlDoc });
|
|
524
|
+
const numberingContext = detectNumberingContext(sourceParagraphs[0]);
|
|
525
|
+
result = await executeListGeneration({
|
|
526
|
+
cleanText: cleanModifiedText,
|
|
527
|
+
numberingContext,
|
|
528
|
+
originalRunModel: ingested.runModel,
|
|
529
|
+
originalText: ingested.acceptedText,
|
|
530
|
+
generateRedlines,
|
|
531
|
+
author,
|
|
532
|
+
font: options.font || null,
|
|
533
|
+
revisionIdAllocator,
|
|
534
|
+
numberingService: new NumberingService()
|
|
535
|
+
});
|
|
536
|
+
} else {
|
|
537
|
+
const pipeline = new ReconciliationPipeline({
|
|
538
|
+
author,
|
|
539
|
+
generateRedlines,
|
|
540
|
+
revisionIdAllocator
|
|
541
|
+
});
|
|
542
|
+
result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
|
|
546
|
+
return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
|
|
550
|
+
const includeNumbering = result.includeNumbering === true;
|
|
551
|
+
log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
|
|
552
|
+
const wrapped = wrapInDocumentFragment(result.ooxml, {
|
|
553
|
+
includeNumbering,
|
|
554
|
+
numberingXml: result.numberingXml
|
|
555
|
+
});
|
|
391
556
|
log(`[OxmlEngine] ✅ Wrapped OOXML length: ${wrapped.length}`);
|
|
392
|
-
return finalize({
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
557
|
+
return finalize({
|
|
558
|
+
oxml: wrapped,
|
|
559
|
+
hasChanges: true,
|
|
560
|
+
...(Array.isArray(result.warnings) ? { warnings: result.warnings } : {})
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return finalizeUnchanged();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
recordRouteSelection(options, 'reconstruction');
|
|
567
|
+
return finalize(applyReconstructionMode(
|
|
568
|
+
xmlDoc,
|
|
569
|
+
originalText,
|
|
570
|
+
cleanModifiedText,
|
|
571
|
+
serializer,
|
|
572
|
+
author,
|
|
573
|
+
formatHints,
|
|
574
|
+
generateRedlines,
|
|
575
|
+
{},
|
|
576
|
+
options
|
|
577
|
+
));
|
|
578
|
+
} catch (caught) {
|
|
579
|
+
if (isDiffTokenLimitError(caught)) {
|
|
580
|
+
return finalize({
|
|
581
|
+
oxml: inputOoxml,
|
|
582
|
+
hasChanges: false,
|
|
583
|
+
status: 'error',
|
|
584
|
+
error: { code: caught.code, message: caught.message }
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
throw caught;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function normalizeTargetText(text) {
|
|
592
|
+
return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function textSpanVisibleText(span) {
|
|
596
|
+
const node = span?.textElement;
|
|
597
|
+
const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
|
|
598
|
+
if (localName === 'tab') return '\t';
|
|
599
|
+
if (localName === 'br' || localName === 'cr') return '\n';
|
|
600
|
+
if (localName === 'noBreakHyphen') return '\u2011';
|
|
601
|
+
return node?.textContent || '';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
433
605
|
* Sanitizes AI response text by removing common prefixes.
|
|
434
606
|
*
|
|
435
607
|
* @param {string} text - AI response text
|
|
436
608
|
* @returns {string}
|
|
437
609
|
*/
|
|
438
|
-
export function sanitizeAiResponse(text) {
|
|
439
|
-
return String(text ?? '').replace(
|
|
440
|
-
/^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
|
|
441
|
-
''
|
|
442
|
-
);
|
|
443
|
-
}
|
|
610
|
+
export function sanitizeAiResponse(text) {
|
|
611
|
+
return String(text ?? '').replace(
|
|
612
|
+
/^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
|
|
613
|
+
''
|
|
614
|
+
);
|
|
615
|
+
}
|
|
444
616
|
|
|
445
617
|
/**
|
|
446
618
|
* Parses OOXML into a DOM document.
|
|
@@ -448,9 +620,9 @@ export function sanitizeAiResponse(text) {
|
|
|
448
620
|
* @param {string} ooxmlString - OOXML text
|
|
449
621
|
* @returns {Document}
|
|
450
622
|
*/
|
|
451
|
-
export function parseOoxml(ooxmlString) {
|
|
452
|
-
return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
|
|
453
|
-
}
|
|
623
|
+
export function parseOoxml(ooxmlString) {
|
|
624
|
+
return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
|
|
625
|
+
}
|
|
454
626
|
|
|
455
627
|
/**
|
|
456
628
|
* Serializes a DOM document to OOXML text.
|