@ansonlai/docx-redline-js 0.7.1 → 0.8.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.
@@ -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)
@@ -5,6 +5,8 @@
5
5
  * revision tokens over package entries or document parts.
6
6
  */
7
7
 
8
+ import { sha256 } from '../core/sha256.js';
9
+
8
10
  const textEncoder = new TextEncoder();
9
11
 
10
12
  /**
@@ -145,6 +147,8 @@ export function buildRevisionTokenFraming({ scope, entries = [] }) {
145
147
  /**
146
148
  * Calculates a SHA-256 revision token asynchronously using Web Crypto or a custom digest function.
147
149
  *
150
+ * Defaults to the built-in pure-JS SHA-256 implementation when Web Crypto is unavailable.
151
+ *
148
152
  * @param {{ scope: string, entries: Array<any>, digestFn?: (bytes: Uint8Array) => Promise<string> }} options
149
153
  * @returns {Promise<{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }>}
150
154
  */
@@ -159,7 +163,7 @@ export async function computeRevisionToken({ scope, entries = [], digestFn = nul
159
163
  const hashBytes = new Uint8Array(hashBuf);
160
164
  hashHex = Array.from(hashBytes).map(b => b.toString(16).padStart(2, '0')).join('');
161
165
  } else {
162
- throw new Error('No crypto provider available for SHA-256 revision token computation.');
166
+ hashHex = sha256(framing);
163
167
  }
164
168
 
165
169
  return {
@@ -172,17 +176,17 @@ export async function computeRevisionToken({ scope, entries = [], digestFn = nul
172
176
  }
173
177
 
174
178
  /**
175
- * Calculates a SHA-256 revision token synchronously using a provided digest function.
179
+ * Calculates a SHA-256 revision token synchronously using pure-JS SHA-256 or a custom digest function.
176
180
  *
177
- * @param {{ scope: string, entries: Array<any>, digestFn: (bytes: Uint8Array) => string }} options
181
+ * @param {{ scope: string, entries: Array<any>, digestFn?: (bytes: Uint8Array) => string }} options
178
182
  * @returns {{ algorithm: 'sha256', version: number, scope: string, value: string, coveredParts: string[] }}
179
183
  */
180
- export function computeRevisionTokenSync({ scope, entries = [], digestFn }) {
181
- if (typeof digestFn !== 'function') {
182
- throw new TypeError('computeRevisionTokenSync requires a synchronous digestFn.');
184
+ export function computeRevisionTokenSync({ scope, entries = [], digestFn = null }) {
185
+ if (digestFn != null && typeof digestFn !== 'function') {
186
+ throw new TypeError('computeRevisionTokenSync requires a synchronous digestFn when specified.');
183
187
  }
184
188
  const { framing, coveredParts, version } = buildRevisionTokenFraming({ scope, entries });
185
- const hashHex = digestFn(framing);
189
+ const hashHex = typeof digestFn === 'function' ? digestFn(framing) : sha256(framing);
186
190
  return {
187
191
  algorithm: 'sha256',
188
192
  version,
@@ -252,6 +252,7 @@ export interface LocalizedReplacementChange {
252
252
  find: string;
253
253
  replace: string;
254
254
  occurrence?: number;
255
+ matchMode?: 'space_equivalent';
255
256
  beforeExcerpt: string;
256
257
  afterExcerpt: string;
257
258
  }>;