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