@ansonlai/docx-redline-js 0.7.0 → 0.7.2

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.
@@ -223,7 +223,37 @@ function describeTargetTextMatch(actualText, requestedText) {
223
223
  requestedExcerpt: escapeInvisibleText(requested)
224
224
  };
225
225
  }
226
-
226
+
227
+ function resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options) {
228
+ const revisionView = options?.targetDescriptor?.revisionView === 'rejected'
229
+ ? 'rejected'
230
+ : 'accepted';
231
+ const viewMetadata = metadata?.revisionView === revisionView ? metadata : null;
232
+ const index = viewMetadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1;
233
+ const paragraphId = viewMetadata?.paragraphId ?? getParagraphId(paragraph);
234
+ const inTable = viewMetadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl');
235
+ const text = viewMetadata?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
236
+ const fingerprint = viewMetadata?.fingerprint ?? createParagraphFingerprint(paragraph, {
237
+ text,
238
+ index,
239
+ paragraphId,
240
+ inTable,
241
+ revisionView
242
+ });
243
+ return {
244
+ index,
245
+ paragraphId,
246
+ text,
247
+ fingerprint,
248
+ revisionView,
249
+ inTable,
250
+ targetTextMatch: describeTargetTextMatch(
251
+ text,
252
+ options?.targetDescriptor?.text ?? options?.targetDescriptor?.exactText ?? targetText
253
+ )
254
+ };
255
+ }
256
+
227
257
  function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeContext = null, options = {}) {
228
258
  const onInfo = typeof options?.onInfo === 'function' ? options.onInfo : () => { };
229
259
  const onWarn = typeof options?.onWarn === 'function' ? options.onWarn : () => { };
@@ -239,17 +269,7 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
239
269
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
240
270
  Object.assign(options._resolutionCapture, {
241
271
  resolvedBy: resolved.resolvedBy,
242
- resolvedTarget: {
243
- index: metadata?.index ?? getDocumentParagraphNodes(xmlDoc).indexOf(paragraph) + 1,
244
- paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
245
- text: metadata?.text ?? getParagraphText(paragraph),
246
- fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
247
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
248
- targetTextMatch: describeTargetTextMatch(
249
- metadata?.text ?? getParagraphText(paragraph),
250
- options?.targetDescriptor?.exactText ?? targetText
251
- )
252
- }
272
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
253
273
  });
254
274
  }
255
275
  return resolved;
@@ -263,17 +283,7 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
263
283
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
264
284
  Object.assign(options._resolutionCapture, {
265
285
  resolvedBy: resolved.resolvedBy,
266
- resolvedTarget: {
267
- index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
268
- paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
269
- text: metadata?.text ?? getParagraphText(paragraph),
270
- fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
271
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
272
- targetTextMatch: describeTargetTextMatch(
273
- metadata?.text ?? getParagraphText(paragraph),
274
- options?.targetDescriptor?.exactText ?? targetText
275
- )
276
- }
286
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
277
287
  });
278
288
  }
279
289
  return resolved;
@@ -301,19 +311,9 @@ function resolveTargetParagraph(xmlDoc, targetText, targetRef, opType, runtimeCo
301
311
  if (options?._resolutionCapture && resolved?.paragraph) {
302
312
  const paragraph = resolved.paragraph;
303
313
  const metadata = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
304
- Object.assign(options._resolutionCapture, {
305
- resolvedBy: resolved.resolvedBy,
306
- resolvedTarget: {
307
- index: metadata?.index ?? Array.from(xmlDoc.getElementsByTagNameNS(NS_W, 'p')).indexOf(paragraph) + 1,
308
- paragraphId: metadata?.paragraphId ?? getParagraphId(paragraph),
309
- text: metadata?.text ?? getParagraphText(paragraph),
310
- fingerprint: metadata?.fingerprint ?? createParagraphFingerprint(paragraph),
311
- inTable: metadata?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
312
- targetTextMatch: describeTargetTextMatch(
313
- metadata?.text ?? getParagraphText(paragraph),
314
- options?.targetDescriptor?.exactText ?? targetText
315
- )
316
- }
314
+ Object.assign(options._resolutionCapture, {
315
+ resolvedBy: resolved.resolvedBy,
316
+ resolvedTarget: resolvedTargetDiagnostics(xmlDoc, paragraph, metadata, targetText, options)
317
317
  });
318
318
  }
319
319
  return resolved;
@@ -41,6 +41,65 @@ function occurrenceOffsets(sourceText, find) {
41
41
  return offsets;
42
42
  }
43
43
 
44
+ function equivalentSpace(left, right) {
45
+ return (left === ' ' || left === '\u00a0')
46
+ && (right === ' ' || right === '\u00a0');
47
+ }
48
+
49
+ function spaceEquivalentOffsets(sourceText, find) {
50
+ const offsets = [];
51
+ for (let start = 0; start <= sourceText.length - find.length; start += 1) {
52
+ let matches = true;
53
+ for (let offset = 0; offset < find.length; offset += 1) {
54
+ const sourceCharacter = sourceText[start + offset];
55
+ const findCharacter = find[offset];
56
+ if (sourceCharacter !== findCharacter && !equivalentSpace(sourceCharacter, findCharacter)) {
57
+ matches = false;
58
+ break;
59
+ }
60
+ }
61
+ if (matches) offsets.push(start);
62
+ }
63
+ return offsets;
64
+ }
65
+
66
+ /** Resolve exact offsets first, then the narrow ASCII-space/NBSP equivalent fallback. */
67
+ export function localizedReplacementOffsets(sourceText, find) {
68
+ const exact = occurrenceOffsets(sourceText, find);
69
+ if (exact.length > 0) return { offsets: exact, matchMode: 'exact' };
70
+ return { offsets: spaceEquivalentOffsets(sourceText, find), matchMode: 'space_equivalent' };
71
+ }
72
+
73
+ function preserveEquivalentBoundarySpaces(sourceSlice, find, replace) {
74
+ const effective = replace.split('');
75
+ let prefix = 0;
76
+ while (
77
+ prefix < find.length
78
+ && prefix < replace.length
79
+ && (find[prefix] === replace[prefix] || equivalentSpace(find[prefix], replace[prefix]))
80
+ ) {
81
+ if (equivalentSpace(sourceSlice[prefix], replace[prefix])) effective[prefix] = sourceSlice[prefix];
82
+ prefix += 1;
83
+ }
84
+ let sourceOffset = find.length - 1;
85
+ let replacementOffset = replace.length - 1;
86
+ while (
87
+ sourceOffset >= prefix
88
+ && replacementOffset >= prefix
89
+ && (
90
+ find[sourceOffset] === replace[replacementOffset]
91
+ || equivalentSpace(find[sourceOffset], replace[replacementOffset])
92
+ )
93
+ ) {
94
+ if (equivalentSpace(sourceSlice[sourceOffset], replace[replacementOffset])) {
95
+ effective[replacementOffset] = sourceSlice[sourceOffset];
96
+ }
97
+ sourceOffset -= 1;
98
+ replacementOffset -= 1;
99
+ }
100
+ return effective.join('');
101
+ }
102
+
44
103
  function offsetCandidate(sourceText, start, length) {
45
104
  const excerptStart = Math.max(0, start - 40);
46
105
  const excerptEnd = Math.min(sourceText.length, start + length + 40);
@@ -168,15 +227,15 @@ export function compileExactReplacements(
168
227
  const replacement = validation.replacements[index];
169
228
  const itemField = `${field}[${index}]`;
170
229
  const occurrence = replacement.occurrence ?? null;
171
- const offsets = occurrenceOffsets(sourceText, replacement.find);
230
+ const { offsets, matchMode } = localizedReplacementOffsets(sourceText, replacement.find);
172
231
  if (offsets.length === 0 || (occurrence != null && occurrence > offsets.length)) {
173
232
  return {
174
233
  ok: false,
175
234
  error: patchError(
176
235
  'PATCH_SOURCE_NOT_FOUND',
177
236
  occurrence == null
178
- ? `Exact patch source was not found: "${replacement.find}".`
179
- : `Occurrence ${occurrence} of exact patch source was not found: "${replacement.find}".`,
237
+ ? `Patch source was not found: "${replacement.find}".`
238
+ : `Occurrence ${occurrence} of patch source was not found: "${replacement.find}".`,
180
239
  itemField,
181
240
  'change_patch',
182
241
  { matchCount: offsets.length }
@@ -188,7 +247,7 @@ export function compileExactReplacements(
188
247
  ok: false,
189
248
  error: patchError(
190
249
  'AMBIGUOUS_PATCH_SOURCE',
191
- `Exact patch source matched ${offsets.length} locations; provide occurrence.`,
250
+ `Patch source matched ${offsets.length} locations; provide occurrence.`,
192
251
  itemField,
193
252
  'choose_occurrence',
194
253
  { candidates: offsets.map(start => offsetCandidate(sourceText, start, replacement.find.length)) }
@@ -197,12 +256,17 @@ export function compileExactReplacements(
197
256
  }
198
257
 
199
258
  const start = offsets[(occurrence || 1) - 1];
259
+ const sourceSlice = sourceText.slice(start, start + replacement.find.length);
200
260
  resolved.push({
201
261
  requestIndex: index,
202
262
  start,
203
263
  end: start + replacement.find.length,
204
264
  find: replacement.find,
205
265
  replace: replacement.replace,
266
+ effectiveReplace: matchMode === 'space_equivalent'
267
+ ? preserveEquivalentBoundarySpaces(sourceSlice, replacement.find, replacement.replace)
268
+ : replacement.replace,
269
+ matchMode,
206
270
  occurrence: occurrence || 1
207
271
  });
208
272
  }
@@ -212,7 +276,7 @@ export function compileExactReplacements(
212
276
  for (const replacement of resolved) {
213
277
  const previous = unique.at(-1);
214
278
  if (previous && replacement.start === previous.start && replacement.end === previous.end) {
215
- if (replacement.replace !== previous.replace) {
279
+ if (replacement.effectiveReplace !== previous.effectiveReplace) {
216
280
  return {
217
281
  ok: false,
218
282
  error: patchError(
@@ -245,7 +309,7 @@ export function compileExactReplacements(
245
309
  let desiredText = '';
246
310
  for (const replacement of unique) {
247
311
  desiredText += sourceText.slice(cursor, replacement.start);
248
- desiredText += replacement.replace;
312
+ desiredText += replacement.effectiveReplace;
249
313
  cursor = replacement.end;
250
314
  }
251
315
  desiredText += sourceText.slice(cursor);
@@ -259,9 +323,10 @@ export function compileExactReplacements(
259
323
  end: replacement.end,
260
324
  find: replacement.find,
261
325
  replace: replacement.replace,
326
+ matchMode: replacement.matchMode,
262
327
  occurrence: replacement.occurrence,
263
328
  removedLength: replacement.end - replacement.start,
264
- insertedLength: replacement.replace.length
329
+ insertedLength: replacement.effectiveReplace.length
265
330
  }))
266
331
  };
267
332
  }
@@ -280,6 +345,7 @@ export function buildLocalizedReplacementChange(compilation, outputText, target
280
345
  return {
281
346
  find: replacement.find,
282
347
  replace: replacement.replace,
348
+ ...(replacement.matchMode === 'space_equivalent' ? { matchMode: replacement.matchMode } : {}),
283
349
  occurrence: replacement.occurrence,
284
350
  beforeExcerpt: boundedExcerpt(sourceText, replacement.start, replacement.end),
285
351
  afterExcerpt: boundedExcerpt(desiredText, afterStart, afterEnd)
@@ -296,6 +296,15 @@ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
296
296
  canonicalValidation.error
297
297
  );
298
298
  }
299
+ } else if (operation.operationKind === 'restore' && sourceOperation.modified === undefined) {
300
+ compiled.modified = start.metadata.text;
301
+ const canonicalValidation = validateDocumentOperation(compiled);
302
+ if (!canonicalValidation.valid) {
303
+ throw Object.assign(
304
+ new Error(canonicalValidation.error.message),
305
+ canonicalValidation.error
306
+ );
307
+ }
299
308
  }
300
309
 
301
310
  const targetEndDescriptor = operation.targetEndDescriptor
@@ -75,13 +75,32 @@ export type RedlineDocumentOperation =
75
75
  replacements?: never;
76
76
  });
77
77
 
78
- export interface RestoreDocumentOperation extends DocumentOperationBase {
78
+ interface RestoreDocumentOperationCommon extends DocumentOperationBase {
79
79
  type: 'restore';
80
- modified: string | string[];
81
- targetEnd?: ParagraphTargetDescriptor;
82
- targetEndRef?: number | string | null;
83
80
  }
84
81
 
82
+ export type RestoreDocumentOperation =
83
+ | (RestoreDocumentOperationCommon & {
84
+ modified: string | string[];
85
+ replacements?: never;
86
+ targetEnd?: ParagraphTargetDescriptor;
87
+ targetEndRef?: number | string | null;
88
+ })
89
+ | (RestoreDocumentOperationCommon & {
90
+ modified?: never;
91
+ /** Exact, case-sensitive replacements in one strong rejected-view paragraph. */
92
+ replacements: LocalizedReplacement[];
93
+ targetEnd?: never;
94
+ targetEndRef?: never;
95
+ })
96
+ | (RestoreDocumentOperationCommon & {
97
+ /** Omit modified to restore one strong rejected-view paragraph verbatim. */
98
+ modified?: never;
99
+ replacements?: never;
100
+ targetEnd?: never;
101
+ targetEndRef?: never;
102
+ });
103
+
85
104
  export interface DeleteDocumentOperation extends DocumentOperationBase {
86
105
  type: 'delete';
87
106
  modified?: '';
@@ -155,6 +174,7 @@ export interface ResolvedDocumentTarget {
155
174
  paragraphId: string | null;
156
175
  text: string;
157
176
  fingerprint?: string;
177
+ revisionView?: 'accepted' | 'rejected';
158
178
  inTable?: boolean;
159
179
  targetTextMatch?: {
160
180
  mode: 'exact' | 'space_equivalent' | 'normalized';
@@ -232,6 +252,7 @@ export interface LocalizedReplacementChange {
232
252
  find: string;
233
253
  replace: string;
234
254
  occurrence?: number;
255
+ matchMode?: 'space_equivalent';
235
256
  beforeExcerpt: string;
236
257
  afterExcerpt: string;
237
258
  }>;