@ansonlai/docx-redline-js 0.5.1 → 0.5.3
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 +39 -13
- package/CHANGELOG.md +35 -0
- package/README.md +59 -14
- package/core/paragraph-revision-safety.js +213 -0
- package/core/paragraph-targeting.js +19 -0
- package/core/redline-validation.js +13 -0
- package/dist/docx-redline-js.esm.js +389 -94
- package/dist/docx-redline-js.esm.js.map +4 -4
- package/dist/docx-redline-js.esm.min.js +79 -77
- package/dist/docx-redline-js.esm.min.js.map +4 -4
- package/docs/TESTING.md +18 -0
- package/docs/plans/2026-09-08-cross-author-revision-slicing.md +351 -0
- package/docs/schemas/document-operations.schema.json +3 -0
- package/engine/oxml-engine.js +36 -9
- package/engine/surgical-diff-application.js +49 -3
- package/engine/surgical-mode.js +97 -6
- package/index.d.ts +12 -0
- package/package.json +1 -1
- package/pipeline/diff-engine.js +22 -0
- package/services/document-operation-applier.js +24 -5
- package/services/document-operation-contract.js +37 -2
- package/services/document-operation-mutations.js +509 -29
- package/services/operation-preflight.js +72 -8
- package/services/standalone-operation-runner.d.ts +8 -0
package/engine/surgical-mode.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getApplicableFormatHints } from '../pipeline/markdown-processor.js';
|
|
9
|
-
import { computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
9
|
+
import { computeInsertionOnlyDiffs, computeWordDiffs } from '../pipeline/diff-engine.js';
|
|
10
10
|
import { getDocumentParagraphs } from './format-extraction.js';
|
|
11
11
|
import { buildSpanIndex, buildSurgicalTextSpans, forEachOverlappingSpan } from './surgical-spans.js';
|
|
12
12
|
import {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from './surgical-diff-application.js';
|
|
17
17
|
import { withOoxmlSourceType } from '../core/word-xml.js';
|
|
18
18
|
import { createReplacementRevisionEvent } from '../core/types.js';
|
|
19
|
+
import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
|
|
19
20
|
|
|
20
21
|
function checkSafeAdjacencyForPairing(spanIndex, startPos, endPos, allowInsertionCarrier = false) {
|
|
21
22
|
const spans = [];
|
|
@@ -111,7 +112,10 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
111
112
|
: getDocumentParagraphs(xmlDoc);
|
|
112
113
|
|
|
113
114
|
const { fullText, textSpans } = buildSurgicalTextSpans(allParagraphs);
|
|
114
|
-
const
|
|
115
|
+
const insertionOnlyDiffs = options.existingRevisions === 'slice-cross-author'
|
|
116
|
+
? computeInsertionOnlyDiffs(fullText, modifiedText)
|
|
117
|
+
: null;
|
|
118
|
+
const diffs = insertionOnlyDiffs || computeWordDiffs(fullText, modifiedText, diffOptions);
|
|
115
119
|
const spanIndex = buildSpanIndex(textSpans);
|
|
116
120
|
const pairReplacements = options.pairReplacements === true;
|
|
117
121
|
const warnings = [];
|
|
@@ -120,7 +124,42 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
120
124
|
let newPos = 0;
|
|
121
125
|
let hasChanges = false;
|
|
122
126
|
|
|
123
|
-
|
|
127
|
+
const insertionOperations = insertionOnlyDiffs
|
|
128
|
+
? collectInsertionOperations(insertionOnlyDiffs)
|
|
129
|
+
: [];
|
|
130
|
+
if (insertionOperations.length > 1 && formatHints.length === 0) {
|
|
131
|
+
for (const operation of insertionOperations.slice().reverse()) {
|
|
132
|
+
const liveSpans = buildSurgicalTextSpans(allParagraphs).textSpans;
|
|
133
|
+
const liveSpanIndex = buildSpanIndex(liveSpans);
|
|
134
|
+
const textWithoutNewlines = operation.text.replace(/\n/g, ' ');
|
|
135
|
+
if (textWithoutNewlines.length === 0) continue;
|
|
136
|
+
const insertResult = processInsert(
|
|
137
|
+
xmlDoc,
|
|
138
|
+
liveSpanIndex,
|
|
139
|
+
operation.originalPos,
|
|
140
|
+
textWithoutNewlines,
|
|
141
|
+
author,
|
|
142
|
+
formatHints,
|
|
143
|
+
operation.newPos,
|
|
144
|
+
generateRedlines,
|
|
145
|
+
allParagraphs[0] || null,
|
|
146
|
+
null,
|
|
147
|
+
options?.insertionAffinity || null,
|
|
148
|
+
options?.existingRevisions || 'merge-same-author'
|
|
149
|
+
);
|
|
150
|
+
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
151
|
+
return withOoxmlSourceType({
|
|
152
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
153
|
+
hasChanges: false,
|
|
154
|
+
status: 'error',
|
|
155
|
+
error: insertResult.error
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (insertResult === true) hasChanges = true;
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < diffs.length; i++) {
|
|
124
163
|
const [op, text] = diffs[i];
|
|
125
164
|
if (op === 0) {
|
|
126
165
|
const len = text.length;
|
|
@@ -151,7 +190,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
151
190
|
if (pairReplacements && generateRedlines && hasNextInsert) {
|
|
152
191
|
const nextText = diffs[i + 1][1];
|
|
153
192
|
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
154
|
-
if (textWithoutNewlines.
|
|
193
|
+
if (textWithoutNewlines.length > 0) {
|
|
155
194
|
const checkResult = checkSafeAdjacencyForPairing(
|
|
156
195
|
spanIndex,
|
|
157
196
|
originalPos,
|
|
@@ -178,7 +217,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
178
217
|
i++;
|
|
179
218
|
const [, nextText] = diffs[i];
|
|
180
219
|
const textWithoutNewlines = nextText.replace(/\n/g, ' ');
|
|
181
|
-
if (textWithoutNewlines.
|
|
220
|
+
if (textWithoutNewlines.length > 0) {
|
|
182
221
|
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, insMetadata, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
183
222
|
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
184
223
|
return withOoxmlSourceType({
|
|
@@ -196,7 +235,7 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
196
235
|
}
|
|
197
236
|
} else if (op === 1) {
|
|
198
237
|
const textWithoutNewlines = text.replace(/\n/g, ' ');
|
|
199
|
-
if (textWithoutNewlines.
|
|
238
|
+
if (textWithoutNewlines.length > 0) {
|
|
200
239
|
const insertResult = processInsert(xmlDoc, spanIndex, originalPos, textWithoutNewlines, author, formatHints, newPos, generateRedlines, allParagraphs[0] || null, null, options?.insertionAffinity || null, options?.existingRevisions || 'merge-same-author');
|
|
201
240
|
if (insertResult && typeof insertResult === 'object' && insertResult.error) {
|
|
202
241
|
return withOoxmlSourceType({
|
|
@@ -212,6 +251,26 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
212
251
|
}
|
|
213
252
|
newPos += text.length;
|
|
214
253
|
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const actualText = allParagraphs.map(paragraph => extractCanonicalParagraphText(paragraph)).join('\n');
|
|
258
|
+
const expectedText = String(modifiedText).replace(/\r\n/g, '\n');
|
|
259
|
+
if (options.existingRevisions === 'slice-cross-author' && actualText !== expectedText) {
|
|
260
|
+
const mismatchOffset = firstMismatchOffset(expectedText, actualText);
|
|
261
|
+
return withOoxmlSourceType({
|
|
262
|
+
oxml: serializer.serializeToString(xmlDoc),
|
|
263
|
+
hasChanges: false,
|
|
264
|
+
status: 'error',
|
|
265
|
+
error: {
|
|
266
|
+
code: 'PATCH_ROUNDTRIP_MISMATCH',
|
|
267
|
+
message: 'Generated OOXML accepted-view text does not match the requested modified text; the mutation was rejected.',
|
|
268
|
+
mismatchOffset,
|
|
269
|
+
expectedExcerpt: excerptAt(expectedText, mismatchOffset),
|
|
270
|
+
actualExcerpt: excerptAt(actualText, mismatchOffset)
|
|
271
|
+
},
|
|
272
|
+
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
273
|
+
});
|
|
215
274
|
}
|
|
216
275
|
|
|
217
276
|
return withOoxmlSourceType({
|
|
@@ -220,3 +279,35 @@ export function applySurgicalMode(xmlDoc, originalText, modifiedText, serializer
|
|
|
220
279
|
...(warnings.length > 0 ? { warnings: [...new Set(warnings)] } : {})
|
|
221
280
|
});
|
|
222
281
|
}
|
|
282
|
+
|
|
283
|
+
function firstMismatchOffset(expected, actual) {
|
|
284
|
+
const limit = Math.min(expected.length, actual.length);
|
|
285
|
+
for (let index = 0; index < limit; index++) {
|
|
286
|
+
if (expected[index] !== actual[index]) return index;
|
|
287
|
+
}
|
|
288
|
+
return limit;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function excerptAt(text, offset, radius = 40) {
|
|
292
|
+
const start = Math.max(0, offset - radius);
|
|
293
|
+
const end = Math.min(text.length, offset + radius);
|
|
294
|
+
return text.slice(start, end);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function collectInsertionOperations(diffs) {
|
|
298
|
+
const operations = [];
|
|
299
|
+
let originalPos = 0;
|
|
300
|
+
let newPos = 0;
|
|
301
|
+
for (const [op, text] of diffs) {
|
|
302
|
+
if (op === 0) {
|
|
303
|
+
originalPos += text.length;
|
|
304
|
+
newPos += text.length;
|
|
305
|
+
} else if (op === -1) {
|
|
306
|
+
originalPos += text.length;
|
|
307
|
+
} else if (op === 1) {
|
|
308
|
+
operations.push({ originalPos, newPos, text });
|
|
309
|
+
newPos += text.length;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return operations;
|
|
313
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export type {
|
|
|
29
29
|
OperationPreflightResult,
|
|
30
30
|
ParagraphTargetDescriptor,
|
|
31
31
|
RedlineDocumentOperation,
|
|
32
|
+
RestoreDocumentOperation,
|
|
32
33
|
ResolvedCommentAnchor,
|
|
33
34
|
ResolvedDocumentTarget,
|
|
34
35
|
StandaloneRunnerOptions
|
|
@@ -43,6 +44,13 @@ export interface RedlineError {
|
|
|
43
44
|
| 'UNSUPPORTED_REVISION_VIEW_MUTATION'
|
|
44
45
|
| 'UNSUPPORTED_INSERTION_AFFINITY'
|
|
45
46
|
| 'UNSAFE_PARAGRAPH_BOUNDARY'
|
|
47
|
+
| 'FOREIGN_PARAGRAPH_MARK_DELETION'
|
|
48
|
+
| 'RESTORATION_STATE_REQUIRED'
|
|
49
|
+
| 'RESTORATION_COUNT_MISMATCH'
|
|
50
|
+
| 'UNSAFE_DELETED_TABLE_ROW'
|
|
51
|
+
| 'UNSUPPORTED_MOVE_REVISION'
|
|
52
|
+
| 'SECTION_BREAK_PARAGRAPH'
|
|
53
|
+
| 'UNSAFE_PARAGRAPH_PLACEMENT'
|
|
46
54
|
| 'TARGET_INDEX_MISMATCH'
|
|
47
55
|
| 'REVISION_MISMATCH'
|
|
48
56
|
| 'REVISION_TOKEN_SCOPE_MISMATCH'
|
|
@@ -54,6 +62,10 @@ export interface RedlineError {
|
|
|
54
62
|
| 'CAPTURE_STALE'
|
|
55
63
|
| string;
|
|
56
64
|
message: string;
|
|
65
|
+
ownerAuthor?: string;
|
|
66
|
+
stage?: string;
|
|
67
|
+
expected?: unknown;
|
|
68
|
+
actual?: unknown;
|
|
57
69
|
commentIds?: string[];
|
|
58
70
|
comments?: Array<{ id: string; author: string; text: string }>;
|
|
59
71
|
}
|
package/package.json
CHANGED
package/pipeline/diff-engine.js
CHANGED
|
@@ -232,6 +232,28 @@ export function computeWordDiffs(originalText, newText, options = {}) {
|
|
|
232
232
|
return decodeBmpDiffs(charDiffs, wordArray);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Returns a character-local diff only when the modified string can be made
|
|
237
|
+
* solely by inserting into the original. This prevents word-token cleanup
|
|
238
|
+
* from relocating small insertions between repeated phrases or containers.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} originalText
|
|
241
|
+
* @param {string} newText
|
|
242
|
+
* @returns {Array<[number, string]>|null}
|
|
243
|
+
*/
|
|
244
|
+
export function computeInsertionOnlyDiffs(originalText, newText) {
|
|
245
|
+
if (originalText === newText) return [[0, originalText]];
|
|
246
|
+
if (!originalText) return [[1, newText]];
|
|
247
|
+
let originalIndex = 0;
|
|
248
|
+
for (let modifiedIndex = 0; modifiedIndex < newText.length && originalIndex < originalText.length; modifiedIndex++) {
|
|
249
|
+
if (newText[modifiedIndex] === originalText[originalIndex]) originalIndex++;
|
|
250
|
+
}
|
|
251
|
+
if (originalIndex !== originalText.length) return null;
|
|
252
|
+
|
|
253
|
+
const diffs = createDiffEngine().diff_main(originalText, newText);
|
|
254
|
+
return diffs.some(([op]) => op === -1) ? null : diffs;
|
|
255
|
+
}
|
|
256
|
+
|
|
235
257
|
/**
|
|
236
258
|
* Computes word-level diff operations with offset tracking.
|
|
237
259
|
*
|
|
@@ -14,8 +14,9 @@ import {
|
|
|
14
14
|
applyCommentToParagraphByExactText,
|
|
15
15
|
applyFormattingToParagraphByExactText,
|
|
16
16
|
applyHighlightToParagraphByExactText,
|
|
17
|
-
applyParagraphFormatToParagraphByExactText,
|
|
18
|
-
|
|
17
|
+
applyParagraphFormatToParagraphByExactText,
|
|
18
|
+
restoreDeletedParagraphByExactText,
|
|
19
|
+
applyToParagraphByExactText
|
|
19
20
|
} from './document-operation-mutations.js';
|
|
20
21
|
import { applyCommentReplyToParts } from './comment-replies.js';
|
|
21
22
|
import {
|
|
@@ -58,7 +59,13 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
58
59
|
const operation = validation.operation || normalizeDocumentOperation(op);
|
|
59
60
|
const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
|
|
60
61
|
|
|
61
|
-
if (
|
|
62
|
+
if (
|
|
63
|
+
operation.operationKind !== 'comment_reply'
|
|
64
|
+
&& (
|
|
65
|
+
operation.targetDescriptor?.revisionView === 'rejected'
|
|
66
|
+
|| operation.targetEndDescriptor?.revisionView === 'rejected'
|
|
67
|
+
)
|
|
68
|
+
) {
|
|
62
69
|
return {
|
|
63
70
|
documentXml,
|
|
64
71
|
hasChanges: false,
|
|
@@ -159,7 +166,8 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
159
166
|
pairReplacements: typeof operation.pairReplacements === 'boolean' ? operation.pairReplacements : (options.pairReplacements !== false),
|
|
160
167
|
...(operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {}),
|
|
161
168
|
...(operation.formattingRevisionPolicy ? { formattingRevisionPolicy: operation.formattingRevisionPolicy } : {}),
|
|
162
|
-
targetDescriptor: operation.targetDescriptor,
|
|
169
|
+
targetDescriptor: operation.targetDescriptor,
|
|
170
|
+
targetEndDescriptor: operation.targetEndDescriptor,
|
|
163
171
|
_resolutionCapture: resolutionCapture,
|
|
164
172
|
_revisionIdAllocator: session.revisionIdAllocator,
|
|
165
173
|
_documentOperationSession: session,
|
|
@@ -242,7 +250,18 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
|
|
|
242
250
|
runtimeContext,
|
|
243
251
|
operationOptions
|
|
244
252
|
);
|
|
245
|
-
} else {
|
|
253
|
+
} else if (operation.operationKind === 'restore') {
|
|
254
|
+
result = await restoreDeletedParagraphByExactText(
|
|
255
|
+
documentXml,
|
|
256
|
+
operation.target,
|
|
257
|
+
operation.modified,
|
|
258
|
+
authorUsed,
|
|
259
|
+
operation.targetRef,
|
|
260
|
+
operation.targetEndRef,
|
|
261
|
+
runtimeContext,
|
|
262
|
+
operationOptions
|
|
263
|
+
);
|
|
264
|
+
} else {
|
|
246
265
|
result = await applyToParagraphByExactText(
|
|
247
266
|
documentXml,
|
|
248
267
|
operation.target,
|
|
@@ -14,6 +14,7 @@ const SUPPORTED_OPERATION_TYPES = new Set([
|
|
|
14
14
|
'list-change',
|
|
15
15
|
'table-reconciliation',
|
|
16
16
|
'insert',
|
|
17
|
+
'restore',
|
|
17
18
|
'delete',
|
|
18
19
|
'comment',
|
|
19
20
|
'comment_reply',
|
|
@@ -42,6 +43,7 @@ function nonEmptyString(value) {
|
|
|
42
43
|
|
|
43
44
|
export function getCanonicalOperationType(operation) {
|
|
44
45
|
const type = operation?.type;
|
|
46
|
+
if (type === 'restore') return 'restore';
|
|
45
47
|
if (type === 'comment' || type === 'comment_reply' || type === 'highlight') return type;
|
|
46
48
|
if (type === 'paragraph-format') return 'paragraph-format';
|
|
47
49
|
if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
|
|
@@ -83,7 +85,7 @@ export function normalizeTargetDescriptor(target, legacyTargetRef = null) {
|
|
|
83
85
|
export function normalizeDocumentOperation(operation) {
|
|
84
86
|
const source = isRecord(operation) ? operation : {};
|
|
85
87
|
const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef);
|
|
86
|
-
const targetEndDescriptor =
|
|
88
|
+
const targetEndDescriptor = source.targetEnd != null
|
|
87
89
|
? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef)
|
|
88
90
|
: null;
|
|
89
91
|
const kind = getCanonicalOperationType(source);
|
|
@@ -94,6 +96,7 @@ export function normalizeDocumentOperation(operation) {
|
|
|
94
96
|
captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
|
|
95
97
|
operationKind: kind,
|
|
96
98
|
targetDescriptor,
|
|
99
|
+
targetEndDescriptor,
|
|
97
100
|
target: targetDescriptor.text,
|
|
98
101
|
targetRef: targetDescriptor.index,
|
|
99
102
|
targetEndRef: targetEndDescriptor?.index ?? source.targetEndRef ?? null,
|
|
@@ -174,7 +177,14 @@ export function validateDocumentOperation(operation) {
|
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
const target = normalized.targetDescriptor;
|
|
177
|
-
if (
|
|
180
|
+
if (
|
|
181
|
+
normalized.operationKind !== 'comment_reply'
|
|
182
|
+
&& !nonEmptyString(target.text)
|
|
183
|
+
&& target.index == null
|
|
184
|
+
&& !target.paragraphId
|
|
185
|
+
&& !target.fingerprint
|
|
186
|
+
&& !target.captureRef
|
|
187
|
+
) {
|
|
178
188
|
return {
|
|
179
189
|
valid: false,
|
|
180
190
|
error: {
|
|
@@ -201,6 +211,31 @@ export function validateDocumentOperation(operation) {
|
|
|
201
211
|
};
|
|
202
212
|
}
|
|
203
213
|
|
|
214
|
+
if (normalized.operationKind === 'restore') {
|
|
215
|
+
const validSingle = nonEmptyString(normalized.modified);
|
|
216
|
+
const validRange = Array.isArray(normalized.modified)
|
|
217
|
+
&& normalized.modified.length > 0
|
|
218
|
+
&& normalized.modified.every(nonEmptyString);
|
|
219
|
+
if (!validSingle && !validRange) {
|
|
220
|
+
return {
|
|
221
|
+
valid: false,
|
|
222
|
+
error: {
|
|
223
|
+
code: 'INVALID_OPERATION',
|
|
224
|
+
message: 'Restore operations require a non-empty string or non-empty string array in "modified".'
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (normalized.generateRedlines === false) {
|
|
229
|
+
return {
|
|
230
|
+
valid: false,
|
|
231
|
+
error: {
|
|
232
|
+
code: 'INVALID_OPERATION',
|
|
233
|
+
message: 'Restore operations require tracked changes and cannot set generateRedlines to false.'
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
204
239
|
if (normalized.structuredContent != null && typeof normalized.structuredContent !== 'boolean') {
|
|
205
240
|
return {
|
|
206
241
|
valid: false,
|