@ansonlai/docx-redline-js 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +589 -287
- 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/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 +2849 -466
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +87 -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/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 +491 -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 +222 -37
- package/engine/surgical-mode.js +134 -6
- 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 +317 -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 +24 -3
- 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-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 +494 -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 +323 -0
- package/services/document-operation-mutations.js +1733 -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 +366 -0
- package/services/receipt-collector.js +288 -0
- package/services/revision-comment-management.js +37 -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,330 @@ 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'|'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') {
|
|
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
|
-
status: 'error',
|
|
136
|
-
error: {
|
|
137
|
-
code: 'PARSE_ERROR',
|
|
138
|
-
message
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
143
|
-
} else {
|
|
144
|
-
log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
|
|
145
|
-
return finalize({
|
|
146
|
-
oxml: inputOoxml,
|
|
147
|
-
hasChanges: false,
|
|
148
|
-
status: 'error',
|
|
149
|
-
error: {
|
|
150
|
-
code: 'EXISTING_REVISIONS',
|
|
151
|
-
message: 'Input OOXML contains existing tracked changes. Pass existingRevisions: "accept-all-first" to normalize before redlining.'
|
|
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') {
|
|
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
|
+
});
|
|
152
161
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 {
|
|
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
|
+
}
|
|
217
|
+
} else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
|
|
218
|
+
log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
|
|
219
|
+
const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
|
|
220
|
+
if (accepted.status === 'error') return finalize(accepted);
|
|
221
|
+
workingOoxml = accepted.oxml;
|
|
222
|
+
normalizedExistingRevisions = true;
|
|
223
|
+
const acceptedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
|
|
224
|
+
parseWarnings.push(...acceptedParsed.warnings);
|
|
225
|
+
xmlDoc = acceptedParsed.doc;
|
|
226
|
+
const acceptedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
|
|
227
|
+
if (acceptedParsed.error || acceptedParseError) {
|
|
228
|
+
const message = acceptedParsed.error?.message || acceptedParseError?.textContent || 'Could not parse OOXML after accepting existing revisions.';
|
|
229
|
+
error('[OxmlEngine] XML parse error after accepting existing revisions:', message);
|
|
230
|
+
return finalize({
|
|
231
|
+
oxml: inputOoxml,
|
|
232
|
+
hasChanges: false,
|
|
233
|
+
status: 'error',
|
|
234
|
+
error: {
|
|
235
|
+
code: 'PARSE_ERROR',
|
|
236
|
+
message
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
241
|
+
} else {
|
|
242
|
+
log('[OxmlEngine] Existing revisions detected; rejecting input per existingRevisions policy');
|
|
243
|
+
return finalize({
|
|
244
|
+
oxml: inputOoxml,
|
|
245
|
+
hasChanges: false,
|
|
246
|
+
status: 'error',
|
|
247
|
+
error: {
|
|
248
|
+
code: 'EXISTING_REVISIONS',
|
|
249
|
+
message: 'Input OOXML contains existing tracked changes and existingRevisions is "reject-input".'
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const initialTableCellContext = detectTableCellContext(xmlDoc, originalText, options);
|
|
158
256
|
if (initialTableCellContext.hasTableWrapper && initialTableCellContext.targetParagraph && !options._isolatedTableCell) {
|
|
159
257
|
log('[OxmlEngine] Isolating table-cell paragraph before diff');
|
|
160
258
|
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
|
-
}
|
|
259
|
+
const isolatedResult = await applyRedlineToOxml(isolatedOxml, originalText, modifiedText, {
|
|
260
|
+
...options,
|
|
261
|
+
_isolatedTableCell: true
|
|
262
|
+
});
|
|
263
|
+
if (!isolatedResult.hasChanges && isolatedResult.status === 'no-op') {
|
|
264
|
+
return finalizeUnchanged();
|
|
265
|
+
}
|
|
266
|
+
return isolatedResult;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let sanitizedText = options.sanitizeInput === true ? sanitizeAiResponse(modifiedText) : modifiedText;
|
|
270
|
+
if (sanitizedText !== modifiedText) {
|
|
271
|
+
operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
|
|
272
|
+
}
|
|
273
|
+
let structuredAnalysis = null;
|
|
274
|
+
if (options.structuredContent !== false) {
|
|
275
|
+
structuredAnalysis = analyzeStructuredContent(sanitizedText);
|
|
276
|
+
if (!structuredAnalysis.valid) {
|
|
277
|
+
if (options.explicitStructuredContent === true) {
|
|
278
|
+
const message = structuredAnalysis.issues.map(issue => `${issue.code}: ${issue.message}`).join(' ');
|
|
279
|
+
return finalize({
|
|
280
|
+
oxml: inputOoxml,
|
|
281
|
+
hasChanges: false,
|
|
282
|
+
status: 'error',
|
|
283
|
+
error: { code: 'STRUCTURED_CONTENT_INVALID', message },
|
|
284
|
+
warnings: structuredAnalysis.issues.map(issue => issue.message)
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
structuredAnalysis = null;
|
|
288
|
+
} else if (structuredAnalysis.requiresStructuredContent) {
|
|
289
|
+
sanitizedText = structuredAnalysis.normalizedMarkdown;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
175
292
|
const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
|
|
176
293
|
|
|
177
294
|
const hasTextChanges = cleanModifiedText.trim() !== originalText.trim();
|
|
178
295
|
const hasFormatHints = formatHints.length > 0;
|
|
179
296
|
|
|
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
|
-
};
|
|
297
|
+
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
298
|
+
const hasExistingFormatting = existingFormatHints.length > 0;
|
|
299
|
+
const visibleText = textSpans.map(span => textSpanVisibleText(span)).join('');
|
|
300
|
+
const targetFound = originalText.includes('\n') || originalText.includes('\r')
|
|
301
|
+
? originalText
|
|
302
|
+
.split(/\r?\n/)
|
|
303
|
+
.map(normalizeTargetText)
|
|
304
|
+
.filter(Boolean)
|
|
305
|
+
.every(line => paragraphs.some(paragraph => {
|
|
306
|
+
const paragraphText = textSpans
|
|
307
|
+
.filter(span => span.paragraph === paragraph)
|
|
308
|
+
.map(textSpanVisibleText)
|
|
309
|
+
.join('');
|
|
310
|
+
return normalizeTargetText(paragraphText).includes(line);
|
|
311
|
+
}))
|
|
312
|
+
: visibleText.includes(originalText.trim())
|
|
313
|
+
|| visibleText.replace(/[\t\n\u2011]/g, '').includes(originalText.trim().replace(/[\t\n\u2011]/g, ''))
|
|
314
|
+
|| normalizeTargetText(visibleText).includes(normalizeTargetText(originalText));
|
|
315
|
+
if (
|
|
316
|
+
hasTextChanges
|
|
317
|
+
&& typeof originalText === 'string'
|
|
318
|
+
&& originalText.trim()
|
|
319
|
+
&& !targetFound
|
|
320
|
+
) {
|
|
321
|
+
log('[OxmlEngine] Target text not found in OOXML');
|
|
322
|
+
return finalize({
|
|
323
|
+
oxml: inputOoxml,
|
|
324
|
+
hasChanges: false,
|
|
325
|
+
status: 'error',
|
|
326
|
+
error: {
|
|
327
|
+
code: 'TARGET_NOT_FOUND',
|
|
328
|
+
message: 'Original text was not found in the supplied OOXML.'
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
let paragraphInfos = null;
|
|
333
|
+
const getParagraphInfos = () => {
|
|
334
|
+
if (!paragraphInfos) {
|
|
335
|
+
paragraphInfos = buildParagraphInfos(xmlDoc, paragraphs, textSpans);
|
|
336
|
+
}
|
|
337
|
+
return paragraphInfos;
|
|
338
|
+
};
|
|
339
|
+
const applyFormatOnlyWithOoxmlFallback = (precomputedContext = null) => {
|
|
340
|
+
const formatResult = applyFormatOnlyChangesSurgical(
|
|
341
|
+
xmlDoc,
|
|
342
|
+
originalText,
|
|
343
|
+
formatHints,
|
|
344
|
+
serializer,
|
|
345
|
+
author,
|
|
346
|
+
generateRedlines,
|
|
347
|
+
precomputedContext
|
|
348
|
+
);
|
|
349
|
+
if (!formatResult.useNativeApi) {
|
|
350
|
+
return formatResult;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
log('[OxmlEngine] Format-only surgical fallback signal encountered; retrying with OOXML reconstruction fallback');
|
|
354
|
+
return applyReconstructionMode(
|
|
355
|
+
xmlDoc,
|
|
356
|
+
originalText,
|
|
357
|
+
cleanModifiedText,
|
|
358
|
+
serializer,
|
|
359
|
+
author,
|
|
360
|
+
formatHints,
|
|
361
|
+
generateRedlines
|
|
362
|
+
);
|
|
363
|
+
};
|
|
247
364
|
|
|
248
365
|
log(`[OxmlEngine] Text changes: ${hasTextChanges}, New format hints: ${formatHints.length}, Existing format hints: ${existingFormatHints.length}`);
|
|
249
366
|
|
|
250
|
-
const needsFormatRemoval = options.removeFormatting === true
|
|
251
|
-
&& !hasTextChanges
|
|
252
|
-
&& !hasFormatHints
|
|
253
|
-
&& hasExistingFormatting;
|
|
367
|
+
const needsFormatRemoval = options.removeFormatting === true
|
|
368
|
+
&& !hasTextChanges
|
|
369
|
+
&& !hasFormatHints
|
|
370
|
+
&& hasExistingFormatting;
|
|
254
371
|
|
|
255
|
-
if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
372
|
+
if (!hasTextChanges && !hasFormatHints && !hasExistingFormatting) {
|
|
373
|
+
recordRouteSelection(options, 'noChange');
|
|
374
|
+
log('[OxmlEngine] No text changes, no format hints, and no existing formatting detected');
|
|
375
|
+
return finalizeUnchanged();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!hasTextChanges && !hasFormatHints && hasExistingFormatting && !needsFormatRemoval) {
|
|
379
|
+
log('[OxmlEngine] No text or explicit formatting changes; preserving existing formatting');
|
|
380
|
+
return finalizeUnchanged();
|
|
381
|
+
}
|
|
264
382
|
|
|
265
383
|
if (needsFormatRemoval) {
|
|
384
|
+
recordRouteSelection(options, 'formatOnly', { removeFormatting: true });
|
|
266
385
|
log('[OxmlEngine] Format REMOVAL detected: applying surgical replacement in OOXML');
|
|
267
386
|
|
|
268
387
|
const tableCellCtx = initialTableCellContext;
|
|
@@ -293,16 +412,17 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
293
412
|
);
|
|
294
413
|
|
|
295
414
|
if (tableCellCtx.hasTableWrapper && targetParagraph) {
|
|
296
|
-
return finalize({
|
|
297
|
-
oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
|
|
298
|
-
hasChanges: removalResult.hasChanges
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return finalize(removalResult);
|
|
415
|
+
return finalize({
|
|
416
|
+
oxml: serializeParagraphOnly(xmlDoc, targetParagraph, serializer),
|
|
417
|
+
hasChanges: removalResult.hasChanges
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return finalize(removalResult);
|
|
303
422
|
}
|
|
304
423
|
|
|
305
424
|
if (!hasTextChanges && hasFormatHints) {
|
|
425
|
+
recordRouteSelection(options, 'formatOnly', { removeFormatting: false });
|
|
306
426
|
log(`[OxmlEngine] Format-only change detected: ${formatHints.length} format hints`);
|
|
307
427
|
|
|
308
428
|
const tableCellCtx = initialTableCellContext;
|
|
@@ -311,39 +431,43 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
311
431
|
paragraphs,
|
|
312
432
|
paragraphInfos: getParagraphInfos()
|
|
313
433
|
};
|
|
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
|
-
}
|
|
434
|
+
if (tableCellCtx.hasTableWrapper && tableCellCtx.targetParagraph) {
|
|
435
|
+
log('[OxmlEngine] Table cell context: applying formatting to target paragraph only');
|
|
436
|
+
|
|
437
|
+
const formatResult = applyFormatOnlyWithOoxmlFallback(precomputedFormatContext);
|
|
328
438
|
|
|
329
|
-
|
|
439
|
+
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (format-only)');
|
|
440
|
+
return finalize({
|
|
441
|
+
oxml: serializeParagraphOnly(xmlDoc, tableCellCtx.targetParagraph, serializer),
|
|
442
|
+
hasChanges: formatResult.hasChanges
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return finalize(applyFormatOnlyWithOoxmlFallback(precomputedFormatContext));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
|
|
330
450
|
const hasTables = tables.length > 0;
|
|
331
451
|
const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
|
|
332
452
|
const isTargetList = isListTargetLoose(cleanModifiedText);
|
|
453
|
+
const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
|
|
333
454
|
const tableCellContext = initialTableCellContext;
|
|
334
455
|
|
|
335
|
-
log(`[OxmlEngine] Mode: ${hasTables ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
|
|
336
|
-
|
|
337
|
-
try {
|
|
338
|
-
if (isMarkdownTable && !hasTables) {
|
|
456
|
+
log(`[OxmlEngine] Mode: ${hasTables ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
if (isMarkdownTable && !hasTables) {
|
|
460
|
+
recordRouteSelection(options, 'table', { transformation: 'text-to-table' });
|
|
339
461
|
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
|
-
|
|
462
|
+
return finalize(applyTextToTableTransformation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (hasTables && isMarkdownTable) {
|
|
466
|
+
recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
|
|
467
|
+
return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
468
|
+
}
|
|
346
469
|
if (hasTables) {
|
|
470
|
+
recordRouteSelection(options, 'surgical', { tableScoped: true });
|
|
347
471
|
const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
|
|
348
472
|
? tableCellContext.targetParagraph
|
|
349
473
|
: null;
|
|
@@ -356,91 +480,122 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
356
480
|
originalText,
|
|
357
481
|
cleanModifiedText,
|
|
358
482
|
serializer,
|
|
359
|
-
author,
|
|
360
|
-
formatHints,
|
|
361
|
-
generateRedlines,
|
|
362
|
-
surgicalTarget
|
|
483
|
+
author,
|
|
484
|
+
formatHints,
|
|
485
|
+
generateRedlines,
|
|
486
|
+
surgicalTarget,
|
|
487
|
+
{},
|
|
488
|
+
options
|
|
363
489
|
);
|
|
364
490
|
|
|
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);
|
|
491
|
+
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
492
|
+
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
|
|
493
|
+
return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
|
|
494
|
+
}
|
|
495
|
+
return finalize(result);
|
|
370
496
|
}
|
|
371
|
-
if (isTargetList) {
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
497
|
+
if (isTargetList || isStructuredContent) {
|
|
498
|
+
const sourceParagraphs = getElementsByTagNSOrTag(xmlDoc, NS_W, 'p');
|
|
499
|
+
const useDirectListGeneration = sourceParagraphs.length === 1;
|
|
500
|
+
recordRouteSelection(options, useDirectListGeneration ? 'listDirect' : 'listCompatibilityPipeline', {
|
|
501
|
+
sourceParagraphCount: sourceParagraphs.length
|
|
502
|
+
});
|
|
503
|
+
log(`[OxmlEngine] 🎯 Using ${useDirectListGeneration ? 'direct list generation' : 'compatibility pipeline'} for list reconciliation`);
|
|
504
|
+
let result;
|
|
505
|
+
if (useDirectListGeneration) {
|
|
506
|
+
const ingested = ingestOoxml(workingOoxml, { xmlDoc });
|
|
507
|
+
const numberingContext = detectNumberingContext(sourceParagraphs[0]);
|
|
508
|
+
result = await executeListGeneration({
|
|
509
|
+
cleanText: cleanModifiedText,
|
|
510
|
+
numberingContext,
|
|
511
|
+
originalRunModel: ingested.runModel,
|
|
512
|
+
originalText: ingested.acceptedText,
|
|
513
|
+
generateRedlines,
|
|
514
|
+
author,
|
|
515
|
+
font: options.font || null,
|
|
516
|
+
revisionIdAllocator,
|
|
517
|
+
numberingService: new NumberingService()
|
|
518
|
+
});
|
|
519
|
+
} else {
|
|
520
|
+
const pipeline = new ReconciliationPipeline({
|
|
521
|
+
author,
|
|
522
|
+
generateRedlines,
|
|
523
|
+
revisionIdAllocator
|
|
524
|
+
});
|
|
525
|
+
result = await pipeline.execute(workingOoxml, sanitizedText, { xmlDoc });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (result.error?.code === 'DIFF_TOKEN_LIMIT') {
|
|
529
|
+
return finalize({ oxml: inputOoxml, hasChanges: false, status: 'error', error: result.error });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (result.isValid && result.ooxml && result.ooxml !== workingOoxml) {
|
|
533
|
+
const includeNumbering = result.includeNumbering === true;
|
|
534
|
+
log(`[OxmlEngine] Wrapping list OOXML with numbering definitions, includeNumbering=${includeNumbering}`);
|
|
535
|
+
const wrapped = wrapInDocumentFragment(result.ooxml, {
|
|
536
|
+
includeNumbering,
|
|
537
|
+
numberingXml: result.numberingXml
|
|
538
|
+
});
|
|
391
539
|
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
|
-
|
|
540
|
+
return finalize({
|
|
541
|
+
oxml: wrapped,
|
|
542
|
+
hasChanges: true,
|
|
543
|
+
...(Array.isArray(result.warnings) ? { warnings: result.warnings } : {})
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
return finalizeUnchanged();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
recordRouteSelection(options, 'reconstruction');
|
|
550
|
+
return finalize(applyReconstructionMode(
|
|
551
|
+
xmlDoc,
|
|
552
|
+
originalText,
|
|
553
|
+
cleanModifiedText,
|
|
554
|
+
serializer,
|
|
555
|
+
author,
|
|
556
|
+
formatHints,
|
|
557
|
+
generateRedlines,
|
|
558
|
+
{},
|
|
559
|
+
options
|
|
560
|
+
));
|
|
561
|
+
} catch (caught) {
|
|
562
|
+
if (isDiffTokenLimitError(caught)) {
|
|
563
|
+
return finalize({
|
|
564
|
+
oxml: inputOoxml,
|
|
565
|
+
hasChanges: false,
|
|
566
|
+
status: 'error',
|
|
567
|
+
error: { code: caught.code, message: caught.message }
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
throw caught;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function normalizeTargetText(text) {
|
|
575
|
+
return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function textSpanVisibleText(span) {
|
|
579
|
+
const node = span?.textElement;
|
|
580
|
+
const localName = String(node?.localName || node?.nodeName || '').replace(/^.*:/, '');
|
|
581
|
+
if (localName === 'tab') return '\t';
|
|
582
|
+
if (localName === 'br' || localName === 'cr') return '\n';
|
|
583
|
+
if (localName === 'noBreakHyphen') return '\u2011';
|
|
584
|
+
return node?.textContent || '';
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
433
588
|
* Sanitizes AI response text by removing common prefixes.
|
|
434
589
|
*
|
|
435
590
|
* @param {string} text - AI response text
|
|
436
591
|
* @returns {string}
|
|
437
592
|
*/
|
|
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
|
-
}
|
|
593
|
+
export function sanitizeAiResponse(text) {
|
|
594
|
+
return String(text ?? '').replace(
|
|
595
|
+
/^(?:Here is the redline:|Here is the text:|Sure, I can help:|Here's the updated text:)[ \t]*\r?\n/i,
|
|
596
|
+
''
|
|
597
|
+
);
|
|
598
|
+
}
|
|
444
599
|
|
|
445
600
|
/**
|
|
446
601
|
* Parses OOXML into a DOM document.
|
|
@@ -448,9 +603,9 @@ export function sanitizeAiResponse(text) {
|
|
|
448
603
|
* @param {string} ooxmlString - OOXML text
|
|
449
604
|
* @returns {Document}
|
|
450
605
|
*/
|
|
451
|
-
export function parseOoxml(ooxmlString) {
|
|
452
|
-
return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
|
|
453
|
-
}
|
|
606
|
+
export function parseOoxml(ooxmlString) {
|
|
607
|
+
return parseOoxmlSafe(ooxmlString, 'application/xml').doc;
|
|
608
|
+
}
|
|
454
609
|
|
|
455
610
|
/**
|
|
456
611
|
* Serializes a DOM document to OOXML text.
|