@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.
@@ -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 diffs = computeWordDiffs(fullText, modifiedText, diffOptions);
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
- for (let i = 0; i < diffs.length; i++) {
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.trim().length > 0) {
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.trim().length > 0) {
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.trim().length > 0) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- applyToParagraphByExactText
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 (operation.operationKind !== 'comment_reply' && operation.targetDescriptor?.revisionView === 'rejected') {
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 = isRecord(source.targetEnd)
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 (normalized.operationKind !== 'comment_reply' && !nonEmptyString(target.text) && target.index == null && !target.paragraphId && !target.captureRef) {
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,