@ansonlai/docx-redline-js 0.6.2 → 0.7.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.
@@ -4,7 +4,8 @@ import {
4
4
  resolveDocumentOperationAuthor,
5
5
  validateDocumentOperation
6
6
  } from './document-operation-contract.js';
7
- import { DocumentOperationSession } from './document-operation-session.js';
7
+ import { DocumentOperationSession } from './document-operation-session.js';
8
+ import { compileOperationBatch } from './operation-batch-compiler.js';
8
9
  import {
9
10
  validateRevisionToken,
10
11
  computeDocumentPartsRevisionToken,
@@ -31,6 +32,8 @@ import {
31
32
  import { validateRedlineOoxml } from '../core/redline-validation.js';
32
33
  import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
33
34
  import { normalizeErrorWithRecovery } from './error-recovery.js';
35
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
36
+ import { buildLocalizedReplacementChange } from './localized-replacement-compiler.js';
34
37
 
35
38
  export function normalizeOperationError(error, context = {}) {
36
39
  return normalizeErrorWithRecovery(error, context);
@@ -60,7 +63,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
60
63
  };
61
64
  }
62
65
 
63
- const operation = validation.operation || normalizeDocumentOperation(op);
66
+ let operation = validation.operation || normalizeDocumentOperation(op);
64
67
  const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
65
68
 
66
69
  if (
@@ -144,7 +147,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
144
147
  const session = options?._documentOperationSession instanceof DocumentOperationSession
145
148
  ? options._documentOperationSession
146
149
  : new DocumentOperationSession(documentXml, options);
147
- if (!session.valid) {
150
+ if (!session.valid) {
148
151
  return {
149
152
  documentXml,
150
153
  hasChanges: false,
@@ -154,10 +157,45 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
154
157
  operationType: operation.operationKind,
155
158
  authorUsed,
156
159
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
157
- };
158
- }
159
-
160
- const resolutionCapture = {};
160
+ };
161
+ }
162
+
163
+ if (Array.isArray(operation.replacements)) {
164
+ const compilation = compileOperationBatch(session.document, [op], {
165
+ strictTargets: options.strictTargets !== false,
166
+ onInfo: options.onInfo,
167
+ onWarn: options.onWarn
168
+ });
169
+ const compilationError = compilation.bindings[0]?.error || compilation.conflicts[0] || null;
170
+ if (compilationError) {
171
+ return {
172
+ documentXml,
173
+ hasChanges: false,
174
+ status: 'error',
175
+ error: normalizeOperationError(compilationError, errorContext),
176
+ operationType: operation.operationKind,
177
+ authorUsed,
178
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
179
+ };
180
+ }
181
+ const compiledValidation = validateDocumentOperation(compilation.compiledOperations[0]);
182
+ if (!compiledValidation.valid) {
183
+ return {
184
+ documentXml,
185
+ hasChanges: false,
186
+ status: 'error',
187
+ error: normalizeOperationError(compiledValidation.error, errorContext),
188
+ operationType: operation.operationKind,
189
+ authorUsed,
190
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
191
+ };
192
+ }
193
+ session.sourceTargetRegistry = compilation.registry;
194
+ operation = compiledValidation.operation;
195
+ }
196
+
197
+ const resolutionCapture = {};
198
+ let localizedChange = null;
161
199
  const savepoint = session.createSavepoint();
162
200
  session.receiptCollector?.beginOperation(
163
201
  operationIndex,
@@ -375,6 +413,26 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
375
413
  operationIndex
376
414
  );
377
415
  }
416
+ if (operation._localizedReplacementCompilation) {
417
+ let outputText = null;
418
+ if (operation._compiledSourceId && session.sourceTargetRegistry) {
419
+ const bound = session.sourceTargetRegistry.resolve(
420
+ operation._compiledSourceId,
421
+ session.document
422
+ );
423
+ if (bound.paragraph) {
424
+ outputText = extractCanonicalParagraphText(bound.paragraph, {
425
+ revisionView: 'accepted'
426
+ });
427
+ }
428
+ }
429
+ localizedChange = buildLocalizedReplacementChange(
430
+ operation._localizedReplacementCompilation,
431
+ outputText,
432
+ resolutionCapture.resolvedTarget || {},
433
+ operation._speculativeContext || null
434
+ );
435
+ }
378
436
  if (operation.captureKey && session.captureTable) {
379
437
  session.captureTable.set(
380
438
  operation.captureKey,
@@ -426,8 +484,9 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
426
484
  ...result,
427
485
  operationType: operation.operationKind,
428
486
  authorUsed,
429
- receipt: operationReceipt,
430
- ...resolutionCapture
487
+ receipt: operationReceipt,
488
+ ...(localizedChange ? { change: localizedChange } : {}),
489
+ ...resolutionCapture
431
490
  };
432
491
  } catch (error) {
433
492
  session.restoreSavepoint(savepoint);
@@ -5,6 +5,8 @@
5
5
  * logic can operate on one internal shape.
6
6
  */
7
7
 
8
+ import { validateExactReplacementRequests } from './localized-replacement-compiler.js';
9
+
8
10
  const SUPPORTED_OPERATION_TYPES = new Set([
9
11
  'redline',
10
12
  'replace',
@@ -215,11 +217,60 @@ export function validateDocumentOperation(operation) {
215
217
  };
216
218
  }
217
219
 
218
- if (normalized.operationKind === 'redline' && typeof normalized.modified !== 'string') {
219
- return {
220
- valid: false,
221
- error: { code: 'INVALID_OPERATION', message: 'Redline operations require a string "modified" field.' }
222
- };
220
+ if (normalized.operationKind === 'redline') {
221
+ const hasModified = typeof normalized.modified === 'string';
222
+ const hasReplacements = normalized.replacements !== undefined;
223
+ if (hasModified === hasReplacements) {
224
+ return {
225
+ valid: false,
226
+ error: {
227
+ code: 'INVALID_OPERATION',
228
+ message: 'Redline operations require exactly one of string "modified" or non-empty "replacements".'
229
+ }
230
+ };
231
+ }
232
+ if (hasReplacements) {
233
+ if (rawType != null && rawType !== '' && !['redline', 'replace'].includes(rawType)) {
234
+ return {
235
+ valid: false,
236
+ error: {
237
+ code: 'INVALID_OPERATION',
238
+ message: 'Localized replacements are supported only for redline or replace operations.'
239
+ }
240
+ };
241
+ }
242
+ if (
243
+ target.revisionView !== 'accepted'
244
+ || target.captureRef
245
+ || target.occurrence != null
246
+ || normalized.captureKey
247
+ || normalized.targetEndDescriptor
248
+ || normalized.targetEndRef != null
249
+ || /\r|\n/.test(target.text || '')
250
+ ) {
251
+ return {
252
+ valid: false,
253
+ error: {
254
+ code: 'INVALID_OPERATION',
255
+ message: 'Localized replacements v1 require one batch-start accepted-view paragraph target without captures, target occurrence, or a target range.'
256
+ }
257
+ };
258
+ }
259
+ const replacementValidation = validateExactReplacementRequests(normalized.replacements);
260
+ if (!replacementValidation.ok) {
261
+ return { valid: false, error: replacementValidation.error };
262
+ }
263
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
264
+ return {
265
+ valid: false,
266
+ error: {
267
+ code: 'INVALID_OPERATION',
268
+ message: 'Localized replacements v1 cannot insert paragraph breaks.'
269
+ }
270
+ };
271
+ }
272
+ normalized.replacements = replacementValidation.replacements;
273
+ }
223
274
  }
224
275
 
225
276
  if (normalized.operationKind === 'rejected-insert') {
@@ -0,0 +1,304 @@
1
+ import { normalizeErrorWithRecovery } from './error-recovery.js';
2
+
3
+ function invalidReplacement(message, field, invalidCode) {
4
+ return normalizeErrorWithRecovery({
5
+ code: invalidCode,
6
+ message,
7
+ ...(field ? { field } : {}),
8
+ recovery: {
9
+ action: 'change_request',
10
+ sameArgumentsSafe: false,
11
+ requiresReinspection: false,
12
+ requiresUserAuthorization: false
13
+ }
14
+ });
15
+ }
16
+
17
+ function patchError(code, message, field, recoveryAction, details = {}) {
18
+ return normalizeErrorWithRecovery({
19
+ code,
20
+ message,
21
+ field,
22
+ ...details,
23
+ recovery: {
24
+ action: recoveryAction,
25
+ sameArgumentsSafe: false,
26
+ requiresReinspection: false,
27
+ requiresUserAuthorization: false
28
+ }
29
+ });
30
+ }
31
+
32
+ function occurrenceOffsets(sourceText, find) {
33
+ const offsets = [];
34
+ let from = 0;
35
+ while (from <= sourceText.length - find.length) {
36
+ const start = sourceText.indexOf(find, from);
37
+ if (start < 0) break;
38
+ offsets.push(start);
39
+ from = start + 1;
40
+ }
41
+ return offsets;
42
+ }
43
+
44
+ function offsetCandidate(sourceText, start, length) {
45
+ const excerptStart = Math.max(0, start - 40);
46
+ const excerptEnd = Math.min(sourceText.length, start + length + 40);
47
+ return {
48
+ start,
49
+ end: start + length,
50
+ excerpt: sourceText.slice(excerptStart, excerptEnd)
51
+ };
52
+ }
53
+
54
+ function boundedExcerpt(text, start, end, limit = 120) {
55
+ const source = String(text ?? '');
56
+ const safeStart = Math.max(0, Math.min(source.length, start));
57
+ const safeEnd = Math.max(safeStart, Math.min(source.length, end));
58
+ const center = Math.floor((safeStart + safeEnd) / 2);
59
+ let excerptStart = Math.max(0, center - Math.floor(limit / 2));
60
+ let excerptEnd = Math.min(source.length, excerptStart + limit);
61
+ excerptStart = Math.max(0, excerptEnd - limit);
62
+ const prefix = excerptStart > 0 ? '…' : '';
63
+ const suffix = excerptEnd < source.length ? '…' : '';
64
+ const available = Math.max(0, limit - prefix.length - suffix.length);
65
+ return `${prefix}${source.slice(excerptStart, excerptStart + available)}${suffix}`;
66
+ }
67
+
68
+ /** Validate and normalize exact replacement requests without resolving source ranges. */
69
+ export function validateExactReplacementRequests(
70
+ replacements,
71
+ field = 'replacements',
72
+ { invalidCode = 'INVALID_OPERATION' } = {}
73
+ ) {
74
+ if (!Array.isArray(replacements) || replacements.length === 0) {
75
+ return {
76
+ ok: false,
77
+ error: invalidReplacement('replacements must be a non-empty array.', field, invalidCode)
78
+ };
79
+ }
80
+
81
+ const normalized = [];
82
+ for (let index = 0; index < replacements.length; index += 1) {
83
+ const replacement = replacements[index];
84
+ const itemField = `${field}[${index}]`;
85
+ if (!replacement || typeof replacement !== 'object' || Array.isArray(replacement)) {
86
+ return {
87
+ ok: false,
88
+ error: invalidReplacement(`${itemField} must be an object.`, itemField, invalidCode)
89
+ };
90
+ }
91
+ if (typeof replacement.find !== 'string' || replacement.find.length === 0) {
92
+ return {
93
+ ok: false,
94
+ error: invalidReplacement(
95
+ `${itemField}.find must be a non-empty string.`,
96
+ `${itemField}.find`,
97
+ invalidCode
98
+ )
99
+ };
100
+ }
101
+ if (typeof replacement.replace !== 'string') {
102
+ return {
103
+ ok: false,
104
+ error: invalidReplacement(
105
+ `${itemField}.replace must be a string.`,
106
+ `${itemField}.replace`,
107
+ invalidCode
108
+ )
109
+ };
110
+ }
111
+ if (replacement.find === replacement.replace) {
112
+ return {
113
+ ok: false,
114
+ error: invalidReplacement(
115
+ `${itemField} must change the matched text.`,
116
+ itemField,
117
+ invalidCode
118
+ )
119
+ };
120
+ }
121
+ const occurrence = replacement.occurrence == null ? null : Number(replacement.occurrence);
122
+ if (occurrence != null && (!Number.isInteger(occurrence) || occurrence < 1)) {
123
+ return {
124
+ ok: false,
125
+ error: invalidReplacement(
126
+ `${itemField}.occurrence must be a positive integer.`,
127
+ `${itemField}.occurrence`,
128
+ invalidCode
129
+ )
130
+ };
131
+ }
132
+ normalized.push({
133
+ find: replacement.find,
134
+ replace: replacement.replace,
135
+ ...(occurrence == null ? {} : { occurrence })
136
+ });
137
+ }
138
+
139
+ return { ok: true, replacements: normalized };
140
+ }
141
+
142
+ /**
143
+ * Compile exact, source-relative replacement intents into complete desired text.
144
+ * All source ranges are resolved before mutation, so replacements are simultaneous.
145
+ */
146
+ export function compileExactReplacements(
147
+ sourceText,
148
+ replacements,
149
+ field = 'replacements',
150
+ options = {}
151
+ ) {
152
+ const invalidCode = options.invalidCode || 'INVALID_OPERATION';
153
+ if (typeof sourceText !== 'string') {
154
+ return {
155
+ ok: false,
156
+ error: invalidReplacement(
157
+ 'Localized replacements require string source text.',
158
+ field,
159
+ invalidCode
160
+ )
161
+ };
162
+ }
163
+ const validation = validateExactReplacementRequests(replacements, field, { invalidCode });
164
+ if (!validation.ok) return validation;
165
+
166
+ const resolved = [];
167
+ for (let index = 0; index < validation.replacements.length; index += 1) {
168
+ const replacement = validation.replacements[index];
169
+ const itemField = `${field}[${index}]`;
170
+ const occurrence = replacement.occurrence ?? null;
171
+ const offsets = occurrenceOffsets(sourceText, replacement.find);
172
+ if (offsets.length === 0 || (occurrence != null && occurrence > offsets.length)) {
173
+ return {
174
+ ok: false,
175
+ error: patchError(
176
+ 'PATCH_SOURCE_NOT_FOUND',
177
+ occurrence == null
178
+ ? `Exact patch source was not found: "${replacement.find}".`
179
+ : `Occurrence ${occurrence} of exact patch source was not found: "${replacement.find}".`,
180
+ itemField,
181
+ 'change_patch',
182
+ { matchCount: offsets.length }
183
+ )
184
+ };
185
+ }
186
+ if (occurrence == null && offsets.length > 1) {
187
+ return {
188
+ ok: false,
189
+ error: patchError(
190
+ 'AMBIGUOUS_PATCH_SOURCE',
191
+ `Exact patch source matched ${offsets.length} locations; provide occurrence.`,
192
+ itemField,
193
+ 'choose_occurrence',
194
+ { candidates: offsets.map(start => offsetCandidate(sourceText, start, replacement.find.length)) }
195
+ )
196
+ };
197
+ }
198
+
199
+ const start = offsets[(occurrence || 1) - 1];
200
+ resolved.push({
201
+ requestIndex: index,
202
+ start,
203
+ end: start + replacement.find.length,
204
+ find: replacement.find,
205
+ replace: replacement.replace,
206
+ occurrence: occurrence || 1
207
+ });
208
+ }
209
+
210
+ resolved.sort((left, right) => left.start - right.start || left.end - right.end || left.requestIndex - right.requestIndex);
211
+ const unique = [];
212
+ for (const replacement of resolved) {
213
+ const previous = unique.at(-1);
214
+ if (previous && replacement.start === previous.start && replacement.end === previous.end) {
215
+ if (replacement.replace !== previous.replace) {
216
+ return {
217
+ ok: false,
218
+ error: patchError(
219
+ 'CONFLICTING_PATCHES',
220
+ `Localized replacements ${previous.requestIndex + 1} and ${replacement.requestIndex + 1} assign different text to the same source range.`,
221
+ field,
222
+ 'combine_patches',
223
+ { replacementIndexes: [previous.requestIndex + 1, replacement.requestIndex + 1] }
224
+ )
225
+ };
226
+ }
227
+ continue;
228
+ }
229
+ if (previous && replacement.start < previous.end) {
230
+ return {
231
+ ok: false,
232
+ error: patchError(
233
+ 'OVERLAPPING_PATCHES',
234
+ `Localized replacements ${previous.requestIndex + 1} and ${replacement.requestIndex + 1} overlap.`,
235
+ field,
236
+ 'combine_patches',
237
+ { replacementIndexes: [previous.requestIndex + 1, replacement.requestIndex + 1] }
238
+ )
239
+ };
240
+ }
241
+ unique.push(replacement);
242
+ }
243
+
244
+ let cursor = 0;
245
+ let desiredText = '';
246
+ for (const replacement of unique) {
247
+ desiredText += sourceText.slice(cursor, replacement.start);
248
+ desiredText += replacement.replace;
249
+ cursor = replacement.end;
250
+ }
251
+ desiredText += sourceText.slice(cursor);
252
+
253
+ return {
254
+ ok: true,
255
+ desiredText,
256
+ replacements: unique.map(replacement => ({
257
+ requestIndex: replacement.requestIndex + 1,
258
+ start: replacement.start,
259
+ end: replacement.end,
260
+ find: replacement.find,
261
+ replace: replacement.replace,
262
+ occurrence: replacement.occurrence,
263
+ removedLength: replacement.end - replacement.start,
264
+ insertedLength: replacement.replace.length
265
+ }))
266
+ };
267
+ }
268
+
269
+ /** Build a compact receipt-like summary from actual source and output text. */
270
+ export function buildLocalizedReplacementChange(compilation, outputText, target = {}, context = null) {
271
+ if (!compilation || typeof compilation !== 'object') return null;
272
+ const sourceText = String(compilation.sourceText ?? '');
273
+ const desiredText = String(compilation.desiredText ?? '');
274
+ const replacements = Array.isArray(compilation.replacements) ? compilation.replacements : [];
275
+ let delta = 0;
276
+ const applied = replacements.map(replacement => {
277
+ const afterStart = replacement.start + delta;
278
+ const afterEnd = afterStart + replacement.replace.length;
279
+ delta += replacement.replace.length - (replacement.end - replacement.start);
280
+ return {
281
+ find: replacement.find,
282
+ replace: replacement.replace,
283
+ occurrence: replacement.occurrence,
284
+ beforeExcerpt: boundedExcerpt(sourceText, replacement.start, replacement.end),
285
+ afterExcerpt: boundedExcerpt(desiredText, afterStart, afterEnd)
286
+ };
287
+ });
288
+ return {
289
+ kind: 'localized_replacement',
290
+ committed: true,
291
+ finalDisposition: 'applied',
292
+ target: {
293
+ ...(target.paragraphId ? { paragraphId: target.paragraphId } : {}),
294
+ ...(Number.isInteger(target.index) ? { index: target.index } : {}),
295
+ ...(target.fingerprint ? { fingerprint: target.fingerprint } : {}),
296
+ ...(context?.humanReference ? { humanReference: context.humanReference } : {})
297
+ },
298
+ ...(context ? { context: { ...context } } : {}),
299
+ replacements: applied,
300
+ verification: {
301
+ acceptedViewMatchesCompiledText: typeof outputText === 'string' && outputText === desiredText
302
+ }
303
+ };
304
+ }
@@ -9,15 +9,23 @@ import {
9
9
  normalizeTargetDescriptor,
10
10
  validateDocumentOperation
11
11
  } from './document-operation-contract.js';
12
+ import { compileExactReplacements } from './localized-replacement-compiler.js';
12
13
 
13
14
  const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert']);
14
15
  const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
15
16
 
16
17
  function normalizedError(error) {
18
+ const details = {};
19
+ if (error && typeof error === 'object') {
20
+ for (const [key, value] of Object.entries(error)) {
21
+ if (key === 'name' || key === 'stack' || key === 'message' || key === 'code') continue;
22
+ details[key] = value;
23
+ }
24
+ }
17
25
  return {
18
26
  code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
19
27
  message: error?.message || String(error),
20
- ...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
28
+ ...details
21
29
  };
22
30
  }
23
31
 
@@ -266,6 +274,30 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
266
274
  compiled._compiledResolvedBy = start.resolvedBy;
267
275
  if (binding.warnings.length > 0) compiled._compiledWarnings = binding.warnings;
268
276
 
277
+ if (Array.isArray(operation.replacements)) {
278
+ const patchCompilation = compileExactReplacements(
279
+ start.metadata.text,
280
+ operation.replacements
281
+ );
282
+ if (!patchCompilation.ok) {
283
+ throw Object.assign(new Error(patchCompilation.error.message), patchCompilation.error);
284
+ }
285
+ compiled.modified = patchCompilation.desiredText;
286
+ delete compiled.replacements;
287
+ compiled._localizedReplacementCompilation = {
288
+ sourceText: start.metadata.text,
289
+ desiredText: patchCompilation.desiredText,
290
+ replacements: patchCompilation.replacements
291
+ };
292
+ const canonicalValidation = validateDocumentOperation(compiled);
293
+ if (!canonicalValidation.valid) {
294
+ throw Object.assign(
295
+ new Error(canonicalValidation.error.message),
296
+ canonicalValidation.error
297
+ );
298
+ }
299
+ }
300
+
269
301
  const targetEndDescriptor = operation.targetEndDescriptor
270
302
  || (operation.targetEndRef != null
271
303
  ? normalizeTargetDescriptor(null, operation.targetEndRef, operation.targetDescriptor.revisionView)
@@ -136,11 +136,16 @@ export function preflightOperations(documentXml, operations, author, options = {
136
136
  let commentsRequired = false;
137
137
  let numberingRequired = false;
138
138
 
139
- for (let index = 0; index < sourceOperations.length; index++) {
140
- const sourceOperation = sourceOperations[index];
141
- const validation = validateDocumentOperation(sourceOperation);
142
- const fallbackOperation = normalizeDocumentOperation(sourceOperation);
143
- const operation = validation.operation || fallbackOperation;
139
+ for (let index = 0; index < sourceOperations.length; index++) {
140
+ const sourceOperation = sourceOperations[index];
141
+ const compiledBinding = batchCompilation.bindings[index];
142
+ const compiledOperation = batchCompilation.compiledOperations[index];
143
+ const operationInput = !compiledBinding?.error && compiledOperation?._localizedReplacementCompilation
144
+ ? compiledOperation
145
+ : sourceOperation;
146
+ const validation = validateDocumentOperation(operationInput);
147
+ const fallbackOperation = normalizeDocumentOperation(sourceOperation);
148
+ const operation = validation.operation || fallbackOperation;
144
149
  const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
145
150
  authorsUsed.add(authorUsed);
146
151
 
@@ -187,7 +192,6 @@ export function preflightOperations(documentXml, operations, author, options = {
187
192
  continue;
188
193
  }
189
194
 
190
- const compiledBinding = batchCompilation.bindings[index];
191
195
  if (compiledBinding?.error) {
192
196
  results.push({
193
197
  index: index + 1,
@@ -31,6 +31,12 @@ export interface RejectedTextInsertionAnchor {
31
31
  offset: number;
32
32
  }
33
33
 
34
+ export interface LocalizedReplacement {
35
+ find: string;
36
+ replace: string;
37
+ occurrence?: number;
38
+ }
39
+
34
40
  export interface DocumentOperationBase {
35
41
  operationId?: string;
36
42
  captureKey?: string;
@@ -43,9 +49,7 @@ export interface DocumentOperationBase {
43
49
  insertionAffinity?: InsertionAffinity;
44
50
  }
45
51
 
46
- export interface RedlineDocumentOperation extends DocumentOperationBase {
47
- type: 'redline' | 'replace' | 'format' | 'list-change' | 'table-reconciliation' | 'insert';
48
- modified: string;
52
+ interface RedlineDocumentOperationCommon extends DocumentOperationBase {
49
53
  structuredContent?: boolean;
50
54
  targetEnd?: ParagraphTargetDescriptor;
51
55
  targetEndRef?: number | string | null;
@@ -53,6 +57,24 @@ export interface RedlineDocumentOperation extends DocumentOperationBase {
53
57
  anchor?: RejectedTextInsertionAnchor;
54
58
  }
55
59
 
60
+ export type RedlineDocumentOperation =
61
+ | (RedlineDocumentOperationCommon & {
62
+ type: 'redline' | 'replace';
63
+ modified: string;
64
+ replacements?: never;
65
+ })
66
+ | (RedlineDocumentOperationCommon & {
67
+ type: 'redline' | 'replace';
68
+ modified?: never;
69
+ /** Exact, case-sensitive replacements in one batch-start accepted-view paragraph. */
70
+ replacements: LocalizedReplacement[];
71
+ })
72
+ | (RedlineDocumentOperationCommon & {
73
+ type: 'format' | 'list-change' | 'table-reconciliation' | 'insert';
74
+ modified: string;
75
+ replacements?: never;
76
+ });
77
+
56
78
  export interface RestoreDocumentOperation extends DocumentOperationBase {
57
79
  type: 'restore';
58
80
  modified: string | string[];
@@ -190,6 +212,34 @@ export interface MutationReceipt {
190
212
  warnings: string[];
191
213
  }
192
214
 
215
+ export interface LocalizedReplacementChange {
216
+ kind: 'localized_replacement';
217
+ committed: boolean;
218
+ finalDisposition: 'applied' | 'rolled_back';
219
+ target: {
220
+ paragraphId?: string;
221
+ index?: number;
222
+ fingerprint?: string;
223
+ humanReference?: string;
224
+ };
225
+ context?: {
226
+ search?: string;
227
+ range?: string;
228
+ anchorMatchCount?: number;
229
+ humanReference?: string;
230
+ };
231
+ replacements: Array<{
232
+ find: string;
233
+ replace: string;
234
+ occurrence?: number;
235
+ beforeExcerpt: string;
236
+ afterExcerpt: string;
237
+ }>;
238
+ verification: {
239
+ acceptedViewMatchesCompiledText: boolean;
240
+ };
241
+ }
242
+
193
243
  export interface DocumentOperationResult {
194
244
  documentXml: string;
195
245
  hasChanges: boolean;
@@ -207,6 +257,7 @@ export interface DocumentOperationResult {
207
257
  resolvedTarget?: ResolvedDocumentTarget;
208
258
  resolvedAnchor?: ResolvedCommentAnchor;
209
259
  receipt?: MutationReceipt;
260
+ change?: LocalizedReplacementChange;
210
261
  }
211
262
 
212
263
  export interface BatchOperationItemResult {
@@ -221,6 +272,7 @@ export interface BatchOperationItemResult {
221
272
  warnings?: string[];
222
273
  error?: RedlineError;
223
274
  receipt?: MutationReceipt;
275
+ change?: LocalizedReplacementChange;
224
276
  }
225
277
 
226
278
  export interface DocumentOperationBatchResult {