@ansonlai/docx-redline-js 0.8.3 → 0.8.4
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/CHANGELOG.md +627 -586
- package/README.md +905 -901
- package/dist/docx-redline-js.esm.js +771 -609
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +92 -89
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/dist/docx-redline.bundle.cjs +771 -609
- package/dist/docx-redline.bundle.cjs.map +4 -4
- package/dist/docx-redline.bundle.js +771 -609
- package/dist/docx-redline.bundle.js.map +4 -4
- package/docs/AGENT_KNOWLEDGE_BASE.md +4 -1
- package/docs/schemas/document-operations.schema.json +168 -168
- package/engine/oxml-engine.js +215 -195
- package/engine/reconstruction-mode.js +183 -183
- package/index.d.ts +4 -0
- package/index.js +36 -1
- package/node/cli.js +2 -1
- package/package.json +1 -1
- package/pipeline/serialization.js +111 -94
- package/services/batch-operation-orchestrator.js +6 -0
- package/services/document-operation-applier.js +2 -0
- package/services/document-operation-contract.js +553 -537
- package/services/document-operation-mutations.js +2721 -2603
- package/services/operation-batch-compiler.js +440 -436
- package/services/operation-heuristics.js +197 -173
- package/services/revision-comment-management.js +601 -594
- package/services/standalone-operation-runner.d.ts +429 -427
- package/services/table-reconciliation.js +48 -46
package/engine/oxml-engine.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from '../core/xml-query.js';
|
|
18
18
|
import { createSerializer, parseOoxmlSafe, serializeXml } from '../adapters/xml-adapter.js';
|
|
19
19
|
import { log, error } from '../adapters/logger.js';
|
|
20
|
-
import { extractFormattingFromOoxml } from './format-extraction.js';
|
|
20
|
+
import { buildTextSpansFromParagraphs, extractFormattingFromOoxml } from './format-extraction.js';
|
|
21
21
|
import {
|
|
22
22
|
applyFormatRemovalAsSurgicalReplacement,
|
|
23
23
|
applyFormatOnlyChangesSurgical
|
|
@@ -28,8 +28,8 @@ import { applySurgicalMode } from './surgical-mode.js';
|
|
|
28
28
|
import { applyReconstructionMode } from './reconstruction-mode.js';
|
|
29
29
|
import { applyTableReconciliation, applyTextToTableTransformation } from './table-mode.js';
|
|
30
30
|
import { getDefaultAuthor } from '../adapters/config.js';
|
|
31
|
-
import { containsTrackedChanges, createWordElement, getTrackedChangeAuthors, withOoxmlSourceType } from '../core/word-xml.js';
|
|
32
|
-
import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
|
|
31
|
+
import { containsTrackedChanges, createWordElement, getTrackedChangeAuthors, withOoxmlSourceType } from '../core/word-xml.js';
|
|
32
|
+
import { clonePropertiesWithoutRevisionHistory } from '../core/revision-cloning.js';
|
|
33
33
|
import {
|
|
34
34
|
NS_W,
|
|
35
35
|
RevisionIdAllocator,
|
|
@@ -40,75 +40,75 @@ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
|
40
40
|
import { getDocumentParagraphs } from './format-extraction.js';
|
|
41
41
|
import { isDiffTokenLimitError } from '../pipeline/diff-engine.js';
|
|
42
42
|
import { NumberingService } from '../services/numbering-service.js';
|
|
43
|
-
import { recordRouteSelection } from './route-selection.js';
|
|
44
|
-
import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
|
|
45
|
-
|
|
46
|
-
function getCommentIdsInOoxml(node) {
|
|
47
|
-
const ids = new Set();
|
|
48
|
-
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
49
|
-
for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
|
|
50
|
-
const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
|
|
51
|
-
if (id !== '') ids.add(id);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function directWordChild(node, localName) {
|
|
58
|
-
return Array.from(node?.childNodes || []).find(child => (
|
|
59
|
-
child?.nodeType === 1
|
|
60
|
-
&& child.namespaceURI === NS_W
|
|
61
|
-
&& child.localName === localName
|
|
62
|
-
)) || null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function insertedParagraphMarkMetadata(paragraph, author) {
|
|
66
|
-
const marker = directWordChild(directWordChild(directWordChild(paragraph, 'pPr'), 'rPr'), 'ins');
|
|
67
|
-
if (!marker) return null;
|
|
68
|
-
const markerAuthor = marker.getAttribute('w:author') || marker.getAttributeNS(NS_W, 'author') || '';
|
|
69
|
-
if (markerAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase()) return null;
|
|
70
|
-
return {
|
|
71
|
-
id: marker.getAttribute('w:id') || marker.getAttributeNS(NS_W, 'id') || '',
|
|
72
|
-
author: markerAuthor,
|
|
73
|
-
date: marker.getAttribute('w:date') || marker.getAttributeNS(NS_W, 'date') || ''
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function emptyParagraphBaseline(paragraph, serializer) {
|
|
78
|
-
const clone = paragraph.cloneNode(false);
|
|
79
|
-
const pPr = directWordChild(paragraph, 'pPr');
|
|
80
|
-
if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
|
|
81
|
-
return serializer.serializeToString(clone);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function restoreInsertedParagraphMark(oxml, metadata) {
|
|
85
|
-
if (!metadata || typeof oxml !== 'string' || !oxml.trim()) return oxml;
|
|
86
|
-
const parsed = parseOoxmlSafe(oxml, 'text/xml');
|
|
87
|
-
if (!parsed.doc || parsed.error) return oxml;
|
|
88
|
-
const paragraph = parsed.doc.documentElement?.localName === 'p'
|
|
89
|
-
? parsed.doc.documentElement
|
|
90
|
-
: getDocumentParagraphs(parsed.doc)[0];
|
|
91
|
-
if (!paragraph) return oxml;
|
|
92
|
-
|
|
93
|
-
let pPr = directWordChild(paragraph, 'pPr');
|
|
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
|
-
}
|
|
110
|
-
return serializeXml(parsed.doc);
|
|
111
|
-
}
|
|
43
|
+
import { recordRouteSelection } from './route-selection.js';
|
|
44
|
+
import { inspectForeignDeletedParagraphTarget } from '../core/paragraph-revision-safety.js';
|
|
45
|
+
|
|
46
|
+
function getCommentIdsInOoxml(node) {
|
|
47
|
+
const ids = new Set();
|
|
48
|
+
for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
|
|
49
|
+
for (const marker of getElementsByTagNSOrTag(node, NS_W, localName)) {
|
|
50
|
+
const id = marker.getAttribute?.('w:id') || marker.getAttribute?.('id');
|
|
51
|
+
if (id !== '') ids.add(id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...ids].sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function directWordChild(node, localName) {
|
|
58
|
+
return Array.from(node?.childNodes || []).find(child => (
|
|
59
|
+
child?.nodeType === 1
|
|
60
|
+
&& child.namespaceURI === NS_W
|
|
61
|
+
&& child.localName === localName
|
|
62
|
+
)) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function insertedParagraphMarkMetadata(paragraph, author) {
|
|
66
|
+
const marker = directWordChild(directWordChild(directWordChild(paragraph, 'pPr'), 'rPr'), 'ins');
|
|
67
|
+
if (!marker) return null;
|
|
68
|
+
const markerAuthor = marker.getAttribute('w:author') || marker.getAttributeNS(NS_W, 'author') || '';
|
|
69
|
+
if (markerAuthor.trim().toLowerCase() !== String(author || '').trim().toLowerCase()) return null;
|
|
70
|
+
return {
|
|
71
|
+
id: marker.getAttribute('w:id') || marker.getAttributeNS(NS_W, 'id') || '',
|
|
72
|
+
author: markerAuthor,
|
|
73
|
+
date: marker.getAttribute('w:date') || marker.getAttributeNS(NS_W, 'date') || ''
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function emptyParagraphBaseline(paragraph, serializer) {
|
|
78
|
+
const clone = paragraph.cloneNode(false);
|
|
79
|
+
const pPr = directWordChild(paragraph, 'pPr');
|
|
80
|
+
if (pPr) clone.appendChild(clonePropertiesWithoutRevisionHistory(pPr));
|
|
81
|
+
return serializer.serializeToString(clone);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function restoreInsertedParagraphMark(oxml, metadata) {
|
|
85
|
+
if (!metadata || typeof oxml !== 'string' || !oxml.trim()) return oxml;
|
|
86
|
+
const parsed = parseOoxmlSafe(oxml, 'text/xml');
|
|
87
|
+
if (!parsed.doc || parsed.error) return oxml;
|
|
88
|
+
const paragraph = parsed.doc.documentElement?.localName === 'p'
|
|
89
|
+
? parsed.doc.documentElement
|
|
90
|
+
: getDocumentParagraphs(parsed.doc)[0];
|
|
91
|
+
if (!paragraph) return oxml;
|
|
92
|
+
|
|
93
|
+
let pPr = directWordChild(paragraph, 'pPr');
|
|
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
|
+
}
|
|
110
|
+
return serializeXml(parsed.doc);
|
|
111
|
+
}
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* Applies redline track changes to OOXML by modifying the DOM in-place.
|
|
@@ -119,7 +119,7 @@ function restoreInsertedParagraphMark(oxml, metadata) {
|
|
|
119
119
|
* @param {Object} [options={}] - Options
|
|
120
120
|
* @param {string} [options.author='AI'] - Author for track changes
|
|
121
121
|
* @param {string|null} [options.targetParagraphId=null] - Preferred paragraph identity for table wrappers
|
|
122
|
-
* @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
|
|
122
|
+
* @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
123
|
* @param {boolean} [options.removeFormatting=false] - Remove existing core formatting when text is otherwise unchanged
|
|
124
124
|
* @param {boolean} [options.sanitizeInput=false] - Strip a standalone leading assistant preface line
|
|
125
125
|
* @returns {Promise<{ oxml: string, hasChanges: boolean, sourceType?: 'package'|'document'|'fragment', status?: 'ok'|'no-op'|'error', error?: { code: string, message: string } }>}
|
|
@@ -134,14 +134,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
134
134
|
const serializer = createSerializer();
|
|
135
135
|
let parseWarnings = [];
|
|
136
136
|
const operationWarnings = [];
|
|
137
|
-
let normalizedExistingRevisions = false;
|
|
138
|
-
let preservedInsertedParagraphMark = null;
|
|
137
|
+
let normalizedExistingRevisions = false;
|
|
138
|
+
let preservedInsertedParagraphMark = null;
|
|
139
139
|
const existingRevisionsPolicy = options.existingRevisions || 'merge-same-author';
|
|
140
140
|
const keepNormalizedNoOp = existingRevisionsPolicy === 'accept-all-first-keep-normalized';
|
|
141
|
-
const finalize = result => {
|
|
142
|
-
const withStatus = { ...result };
|
|
141
|
+
const finalize = result => {
|
|
142
|
+
const withStatus = { ...result };
|
|
143
143
|
if (normalizedExistingRevisions && withStatus.hasChanges === false && withStatus.status !== 'error') {
|
|
144
|
-
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
144
|
+
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
145
145
|
withStatus.oxml = workingOoxml;
|
|
146
146
|
withStatus.hasChanges = true;
|
|
147
147
|
withStatus.warnings = [
|
|
@@ -163,13 +163,13 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
163
163
|
if (warnings.length > 0) {
|
|
164
164
|
withStatus.warnings = [...new Set(warnings)];
|
|
165
165
|
}
|
|
166
|
-
if (!withStatus.status) {
|
|
167
|
-
withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
|
|
168
|
-
}
|
|
169
|
-
if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === 'string') {
|
|
170
|
-
withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
|
|
171
|
-
}
|
|
172
|
-
return withOoxmlSourceType(withStatus);
|
|
166
|
+
if (!withStatus.status) {
|
|
167
|
+
withStatus.status = withStatus.hasChanges ? 'ok' : 'no-op';
|
|
168
|
+
}
|
|
169
|
+
if (preservedInsertedParagraphMark && withStatus.hasChanges && typeof withStatus.oxml === 'string') {
|
|
170
|
+
withStatus.oxml = restoreInsertedParagraphMark(withStatus.oxml, preservedInsertedParagraphMark);
|
|
171
|
+
}
|
|
172
|
+
return withOoxmlSourceType(withStatus);
|
|
173
173
|
};
|
|
174
174
|
const finalizeUnchanged = () => {
|
|
175
175
|
if (normalizedExistingRevisions && keepNormalizedNoOp) {
|
|
@@ -200,61 +200,61 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
200
200
|
const revisionIdAllocator = options?._revisionIdAllocator instanceof RevisionIdAllocator
|
|
201
201
|
? options._revisionIdAllocator
|
|
202
202
|
: new RevisionIdAllocator();
|
|
203
|
-
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
204
|
-
|
|
205
|
-
const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
|
|
206
|
-
? [xmlDoc.documentElement]
|
|
207
|
-
: getDocumentParagraphs(xmlDoc);
|
|
208
|
-
if (inputParagraphs.length === 1 && modifiedText.length > 0) {
|
|
209
|
-
const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
|
|
210
|
-
if (resurrectionTarget.matches) {
|
|
211
|
-
const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
|
|
212
|
-
return finalize({
|
|
213
|
-
oxml: inputOoxml,
|
|
214
|
-
hasChanges: false,
|
|
215
|
-
status: 'error',
|
|
216
|
-
error: {
|
|
217
|
-
code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
|
|
218
|
-
message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
|
|
219
|
-
ownerAuthor
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (containsTrackedChanges(xmlDoc)) {
|
|
226
|
-
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
203
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
204
|
+
|
|
205
|
+
const inputParagraphs = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
|
|
206
|
+
? [xmlDoc.documentElement]
|
|
207
|
+
: getDocumentParagraphs(xmlDoc);
|
|
208
|
+
if (inputParagraphs.length === 1 && modifiedText.length > 0) {
|
|
209
|
+
const resurrectionTarget = inspectForeignDeletedParagraphTarget(inputParagraphs[0], author);
|
|
210
|
+
if (resurrectionTarget.matches) {
|
|
211
|
+
const ownerAuthor = resurrectionTarget.ownerAuthor || 'unattributed';
|
|
212
|
+
return finalize({
|
|
213
|
+
oxml: inputOoxml,
|
|
214
|
+
hasChanges: false,
|
|
215
|
+
status: 'error',
|
|
216
|
+
error: {
|
|
217
|
+
code: 'FOREIGN_PARAGRAPH_MARK_DELETION',
|
|
218
|
+
message: `Refusing to add visible text to a paragraph whose paragraph mark is deleted by another author (${ownerAuthor}). Use explicit paragraph restoration when supported.`,
|
|
219
|
+
ownerAuthor
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (containsTrackedChanges(xmlDoc)) {
|
|
226
|
+
if (existingRevisionsPolicy === 'merge-same-author' || existingRevisionsPolicy === 'slice-cross-author') {
|
|
227
227
|
const authors = getTrackedChangeAuthors(xmlDoc);
|
|
228
|
-
const currentAuthor = String(author || '').trim().toLowerCase();
|
|
229
|
-
const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
|
|
230
|
-
if (isSameAuthor) {
|
|
231
|
-
const commentIds = getCommentIdsInOoxml(xmlDoc);
|
|
232
|
-
if (commentIds.length > 0) {
|
|
233
|
-
return finalize({
|
|
234
|
-
oxml: inputOoxml,
|
|
235
|
-
hasChanges: false,
|
|
236
|
-
status: 'error',
|
|
237
|
-
error: {
|
|
238
|
-
code: 'COMMENTED_CONTENT_MERGE',
|
|
239
|
-
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
240
|
-
commentIds
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
|
|
245
|
-
const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
|
|
246
|
-
preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
|
|
247
|
-
const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
|
|
248
|
-
if (rejected.status === 'error') return finalize(rejected);
|
|
249
|
-
workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
|
|
250
|
-
? emptyParagraphBaseline(soleParagraph, serializer)
|
|
251
|
-
: rejected.oxml;
|
|
228
|
+
const currentAuthor = String(author || '').trim().toLowerCase();
|
|
229
|
+
const isSameAuthor = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === currentAuthor);
|
|
230
|
+
if (isSameAuthor) {
|
|
231
|
+
const commentIds = getCommentIdsInOoxml(xmlDoc);
|
|
232
|
+
if (commentIds.length > 0) {
|
|
233
|
+
return finalize({
|
|
234
|
+
oxml: inputOoxml,
|
|
235
|
+
hasChanges: false,
|
|
236
|
+
status: 'error',
|
|
237
|
+
error: {
|
|
238
|
+
code: 'COMMENTED_CONTENT_MERGE',
|
|
239
|
+
message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
|
|
240
|
+
commentIds
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
log('[OxmlEngine] Existing revisions from same author detected; rejecting previous changes to merge against baseline');
|
|
245
|
+
const soleParagraph = inputParagraphs.length === 1 ? inputParagraphs[0] : null;
|
|
246
|
+
preservedInsertedParagraphMark = insertedParagraphMarkMetadata(soleParagraph, author);
|
|
247
|
+
const rejected = rejectTrackedChangesInOoxml(inputOoxml, { author });
|
|
248
|
+
if (rejected.status === 'error') return finalize(rejected);
|
|
249
|
+
workingOoxml = preservedInsertedParagraphMark && !String(rejected.oxml || '').trim()
|
|
250
|
+
? emptyParagraphBaseline(soleParagraph, serializer)
|
|
251
|
+
: rejected.oxml;
|
|
252
252
|
normalizedExistingRevisions = true;
|
|
253
253
|
const rejectedParsed = parseOoxmlSafe(workingOoxml, 'text/xml');
|
|
254
254
|
parseWarnings.push(...rejectedParsed.warnings);
|
|
255
255
|
xmlDoc = rejectedParsed.doc;
|
|
256
256
|
const rejectedParseError = xmlDoc ? getXmlParseError(xmlDoc) : null;
|
|
257
|
-
if (rejectedParsed.error || rejectedParseError) {
|
|
257
|
+
if (rejectedParsed.error || rejectedParseError) {
|
|
258
258
|
const message = rejectedParsed.error?.message || rejectedParseError?.textContent || 'Could not parse OOXML after rejecting same-author revisions.';
|
|
259
259
|
error('[OxmlEngine] XML parse error after rejecting same-author revisions:', message);
|
|
260
260
|
return finalize({
|
|
@@ -264,21 +264,21 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
264
264
|
error: {
|
|
265
265
|
code: 'PARSE_ERROR',
|
|
266
266
|
message
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
if (containsTrackedChanges(xmlDoc)) {
|
|
271
|
-
return finalize({
|
|
272
|
-
oxml: inputOoxml,
|
|
273
|
-
hasChanges: false,
|
|
274
|
-
status: 'error',
|
|
275
|
-
error: {
|
|
276
|
-
code: 'UNSAFE_REVISION_NESTING',
|
|
277
|
-
message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (containsTrackedChanges(xmlDoc)) {
|
|
271
|
+
return finalize({
|
|
272
|
+
oxml: inputOoxml,
|
|
273
|
+
hasChanges: false,
|
|
274
|
+
status: 'error',
|
|
275
|
+
error: {
|
|
276
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
277
|
+
message: 'Existing same-author revisions could not be completely restored to baseline; refusing to layer new revisions over unsupported revision markup.'
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
seedRevisionIdsFromDocument(xmlDoc, revisionIdAllocator);
|
|
282
282
|
|
|
283
283
|
// Re-derive baseline text from the restored baseline OOXML:
|
|
284
284
|
const paragraphsInDoc = xmlDoc.documentElement && String(xmlDoc.documentElement.localName || '').toLowerCase() === 'p'
|
|
@@ -288,37 +288,37 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
288
288
|
? paragraphsInDoc.map(p => extractCanonicalParagraphText(p)).join('\n')
|
|
289
289
|
: '';
|
|
290
290
|
originalText = baselineText;
|
|
291
|
-
} else if (existingRevisionsPolicy === 'merge-same-author') {
|
|
292
|
-
log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
|
|
291
|
+
} else if (existingRevisionsPolicy === 'merge-same-author') {
|
|
292
|
+
log('[OxmlEngine] Existing revisions detected from another/unattributed author; refusing per merge-same-author policy');
|
|
293
293
|
return finalize({
|
|
294
294
|
oxml: inputOoxml,
|
|
295
295
|
hasChanges: false,
|
|
296
296
|
status: 'error',
|
|
297
297
|
error: {
|
|
298
|
-
code: 'EXISTING_REVISIONS',
|
|
299
|
-
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
|
-
revisionAuthors: authors,
|
|
301
|
-
currentPolicy: existingRevisionsPolicy
|
|
298
|
+
code: 'EXISTING_REVISIONS',
|
|
299
|
+
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
|
+
revisionAuthors: authors,
|
|
301
|
+
currentPolicy: existingRevisionsPolicy
|
|
302
302
|
}
|
|
303
303
|
});
|
|
304
|
-
} else {
|
|
305
|
-
const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
|
|
306
|
-
return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
|
|
307
|
-
});
|
|
308
|
-
if (hasMoveRevision) {
|
|
309
|
-
return finalize({
|
|
310
|
-
oxml: inputOoxml,
|
|
311
|
-
hasChanges: false,
|
|
312
|
-
status: 'error',
|
|
313
|
-
error: {
|
|
314
|
-
code: 'UNSAFE_REVISION_NESTING',
|
|
315
|
-
message: 'Cross-author slicing does not support pending move revisions.'
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
|
|
320
|
-
}
|
|
321
|
-
} else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
|
|
304
|
+
} else {
|
|
305
|
+
const hasMoveRevision = ['moveFrom', 'moveTo'].some(localName => {
|
|
306
|
+
return getElementsByTagNSOrTag(xmlDoc, NS_W, localName).length > 0;
|
|
307
|
+
});
|
|
308
|
+
if (hasMoveRevision) {
|
|
309
|
+
return finalize({
|
|
310
|
+
oxml: inputOoxml,
|
|
311
|
+
hasChanges: false,
|
|
312
|
+
status: 'error',
|
|
313
|
+
error: {
|
|
314
|
+
code: 'UNSAFE_REVISION_NESTING',
|
|
315
|
+
message: 'Cross-author slicing does not support pending move revisions.'
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
log('[OxmlEngine] Existing revisions retained for cross-author surgical slicing');
|
|
320
|
+
}
|
|
321
|
+
} else if (existingRevisionsPolicy === 'accept-all-first' || existingRevisionsPolicy === 'accept-all-first-keep-normalized') {
|
|
322
322
|
log('[OxmlEngine] Existing revisions detected; accepting all input revisions before redlining');
|
|
323
323
|
const accepted = acceptTrackedChangesInOoxml(inputOoxml, { allAuthors: true });
|
|
324
324
|
if (accepted.status === 'error') return finalize(accepted);
|
|
@@ -375,7 +375,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
375
375
|
operationWarnings.push('Input was sanitized; pass sanitizeInput: false to disable.');
|
|
376
376
|
}
|
|
377
377
|
let structuredAnalysis = null;
|
|
378
|
-
if (options.structuredContent !== false) {
|
|
378
|
+
if (options.structuredContent !== false && options.literalTextEdit !== true) {
|
|
379
379
|
structuredAnalysis = analyzeStructuredContent(sanitizedText);
|
|
380
380
|
if (!structuredAnalysis.valid) {
|
|
381
381
|
if (options.explicitStructuredContent === true) {
|
|
@@ -395,21 +395,32 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
395
395
|
}
|
|
396
396
|
const { cleanText: cleanModifiedText, formatHints } = preprocessMarkdown(sanitizedText);
|
|
397
397
|
|
|
398
|
-
const hasTextChanges = existingRevisionsPolicy === 'slice-cross-author'
|
|
399
|
-
? cleanModifiedText !== originalText
|
|
400
|
-
: cleanModifiedText.trim() !== originalText.trim();
|
|
401
|
-
const hasFormatHints = formatHints.length > 0;
|
|
402
|
-
|
|
403
398
|
const { existingFormatHints, textSpans, paragraphs } = extractFormattingFromOoxml(xmlDoc);
|
|
399
|
+
const hasLiteralTextChanges = existingRevisionsPolicy === 'slice-cross-author'
|
|
400
|
+
? cleanModifiedText !== originalText
|
|
401
|
+
: cleanModifiedText.trim() !== originalText.trim();
|
|
402
|
+
// Markdown list markers can equal manually typed ones ("A. ..." lines), so
|
|
403
|
+
// an explicit structured-content request that turns plain paragraphs or
|
|
404
|
+
// soft-break lines into a list is a change even when the text is identical.
|
|
405
|
+
const hasListStructureChange = !hasLiteralTextChanges
|
|
406
|
+
&& options.explicitStructuredContent === true
|
|
407
|
+
&& isListTargetLoose(cleanModifiedText)
|
|
408
|
+
&& !sourceMatchesListShape(paragraphs, cleanModifiedText);
|
|
409
|
+
const hasTextChanges = hasLiteralTextChanges || hasListStructureChange;
|
|
410
|
+
const hasFormatHints = formatHints.length > 0;
|
|
404
411
|
const hasExistingFormatting = existingFormatHints.length > 0;
|
|
405
|
-
|
|
412
|
+
// Formatting spans carry only w:t text. Target matching also needs w:br,
|
|
413
|
+
// w:cr and w:tab so host text with soft line breaks (Word's Paragraph.text
|
|
414
|
+
// reports them as "\v") still matches its source paragraph.
|
|
415
|
+
const { textSpans: targetSpans } = buildTextSpansFromParagraphs(paragraphs);
|
|
416
|
+
const visibleText = targetSpans.map(span => textSpanVisibleText(span)).join('');
|
|
406
417
|
const targetFound = originalText.includes('\n') || originalText.includes('\r')
|
|
407
418
|
? originalText
|
|
408
419
|
.split(/\r?\n/)
|
|
409
420
|
.map(normalizeTargetText)
|
|
410
421
|
.filter(Boolean)
|
|
411
422
|
.every(line => paragraphs.some(paragraph => {
|
|
412
|
-
const paragraphText =
|
|
423
|
+
const paragraphText = targetSpans
|
|
413
424
|
.filter(span => span.paragraph === paragraph)
|
|
414
425
|
.map(textSpanVisibleText)
|
|
415
426
|
.join('');
|
|
@@ -555,12 +566,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
555
566
|
const tables = getElementsByTagNSOrTag(xmlDoc, NS_W, 'tbl');
|
|
556
567
|
const hasTables = tables.length > 0;
|
|
557
568
|
const isMarkdownTable = /^\|.+\|/.test(cleanModifiedText.trim()) && cleanModifiedText.includes('\n');
|
|
558
|
-
|
|
569
|
+
// Localized replacements are literal text edits: marker-like lines ("A. ..." separated by
|
|
570
|
+
// soft breaks) must not be re-interpreted as a request to build a numbered list.
|
|
571
|
+
const isTargetList = options.literalTextEdit !== true && isListTargetLoose(cleanModifiedText);
|
|
559
572
|
const isStructuredContent = options.structuredContent !== false && structuredAnalysis?.requiresStructuredContent === true;
|
|
560
573
|
const tableCellContext = initialTableCellContext;
|
|
561
574
|
|
|
562
|
-
const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
|
|
563
|
-
log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
|
|
575
|
+
const usesSurgicalTextMode = hasTables || existingRevisionsPolicy === 'slice-cross-author';
|
|
576
|
+
log(`[OxmlEngine] Mode: ${usesSurgicalTextMode ? 'SURGICAL' : 'RECONSTRUCTION'}, formatHints: ${formatHints.length}, isMarkdownTable: ${isMarkdownTable}, isTargetList: ${isTargetList}, isTableCellParagraph: ${tableCellContext.isTableCellParagraph}`);
|
|
564
577
|
|
|
565
578
|
try {
|
|
566
579
|
if (isMarkdownTable && !hasTables) {
|
|
@@ -573,8 +586,8 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
573
586
|
recordRouteSelection(options, 'table', { transformation: 'table-reconciliation' });
|
|
574
587
|
return finalize(applyTableReconciliation(xmlDoc, cleanModifiedText, serializer, null, author, generateRedlines));
|
|
575
588
|
}
|
|
576
|
-
if (usesSurgicalTextMode) {
|
|
577
|
-
recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
|
|
589
|
+
if (usesSurgicalTextMode) {
|
|
590
|
+
recordRouteSelection(options, 'surgical', { tableScoped: hasTables });
|
|
578
591
|
const surgicalTarget = tableCellContext.hasTableWrapper && tableCellContext.targetParagraph
|
|
579
592
|
? tableCellContext.targetParagraph
|
|
580
593
|
: null;
|
|
@@ -582,7 +595,7 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
582
595
|
log('[OxmlEngine] Table cell edit: scoping surgical mode to target paragraph');
|
|
583
596
|
}
|
|
584
597
|
|
|
585
|
-
const result = applySurgicalMode(
|
|
598
|
+
const result = applySurgicalMode(
|
|
586
599
|
xmlDoc,
|
|
587
600
|
originalText,
|
|
588
601
|
cleanModifiedText,
|
|
@@ -592,14 +605,14 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
592
605
|
generateRedlines,
|
|
593
606
|
surgicalTarget,
|
|
594
607
|
{},
|
|
595
|
-
options
|
|
596
|
-
);
|
|
597
|
-
|
|
598
|
-
if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
|
|
599
|
-
return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
608
|
+
options
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
if (result.status === 'error' && result.error?.code === 'PATCH_ROUNDTRIP_MISMATCH') {
|
|
612
|
+
return finalize({ ...result, oxml: inputOoxml, hasChanges: false });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (tableCellContext.hasTableWrapper && result.hasChanges && tableCellContext.targetParagraph) {
|
|
603
616
|
log('[OxmlEngine] Stripping table wrapper for table cell paragraph (surgical mode)');
|
|
604
617
|
return finalize({ oxml: serializeParagraphOnly(xmlDoc, tableCellContext.targetParagraph, serializer), hasChanges: true });
|
|
605
618
|
}
|
|
@@ -682,6 +695,13 @@ export async function applyRedlineToOxml(oxml, originalText, modifiedText, optio
|
|
|
682
695
|
}
|
|
683
696
|
}
|
|
684
697
|
|
|
698
|
+
function sourceMatchesListShape(paragraphs, listText) {
|
|
699
|
+
const itemCount = String(listText).split(/\r?\n/).filter(line => line.trim()).length;
|
|
700
|
+
return paragraphs.length === itemCount && paragraphs.every(paragraph => (
|
|
701
|
+
getElementsByTagNSOrTag(paragraph, NS_W, 'numPr').length > 0
|
|
702
|
+
));
|
|
703
|
+
}
|
|
704
|
+
|
|
685
705
|
function normalizeTargetText(text) {
|
|
686
706
|
return String(text || '').replace(/[\t\n\u2011]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
687
707
|
}
|