euredact 0.1.0 → 0.3.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.
package/README.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # euredact
2
2
 
3
- Fast, zero-dependency European PII detection and redaction for Node.js.
3
+ **European PII detection and redaction for Node.js / TypeScript**
4
4
 
5
- Detects and redacts personal data across **31 European countries** using rule-based pattern matching with checksum validation and context-aware false-positive suppression.
5
+ ---
6
+
7
+ Fast, zero-dependency European PII detection and redaction SDK. Detects and
8
+ redacts personal data across **31 European countries** using rule-based pattern
9
+ matching with checksum validation, context-aware false-positive suppression,
10
+ and priority-based deduplication.
6
11
 
7
12
  ## Install
8
13
 
@@ -26,64 +31,374 @@ console.log(result.detections);
26
31
  // [{ entityType: "NATIONAL_ID", text: "123456782", start: 12, end: 21, ... }, ...]
27
32
  ```
28
33
 
29
- ## API
34
+ ## Features
35
+
36
+ - **31 European countries** with country-specific patterns
37
+ - **25+ PII entity types:** national IDs, IBANs, phone numbers, email, VAT
38
+ numbers, license plates, credit cards, BIC/SWIFT, VIN, IMEI, GPS coordinates,
39
+ UUIDs, social handles, IP/IPv6, MAC addresses, secrets/API keys, and more
40
+ - **Secret/API key detection:** known-prefix patterns for AWS, GitHub, Stripe,
41
+ OpenAI, Slack, JWT, SendGrid, plus Shannon entropy-based detection for generic
42
+ high-entropy tokens near context keywords
43
+ - **Custom patterns:** register your own regex patterns for domain-specific PII
44
+ - **Checksum validation:** IBAN mod-97, Luhn (credit cards/IMEI), and 30+
45
+ country-specific validators
46
+ - **Priority-aware deduplication:** validated (checksum) > custom > regex-only
47
+ - **Context-aware:** keyword proximity checks and structural detection (JSON field
48
+ names, CSV headers) for ambiguous patterns
49
+ - **Zero runtime dependencies**
50
+ - **ESM and CommonJS** dual-published
51
+
52
+ ### Supported Countries
53
+
54
+ | Region | Countries |
55
+ |---|---|
56
+ | Western Europe | AT, BE, CH, DE, FR, LU, NL |
57
+ | Southern Europe | CY, EL, ES, IT, MT, PT |
58
+ | Northern Europe | DK, EE, FI, IS, LT, LV, NO, SE |
59
+ | Eastern Europe | BG, CZ, HR, HU, PL, RO, SI, SK |
60
+ | British Isles | IE, UK |
61
+
62
+ ## API Reference
63
+
64
+ ### Module-Level Functions
65
+
66
+ #### `redact(text, options?)`
30
67
 
31
- ### `redact(text, options?)`
68
+ ```ts
69
+ function redact(text: string, options?: RedactOptions): RedactResult;
70
+ ```
32
71
 
33
- Returns a `RedactResult` with the redacted text and a list of detections.
72
+ Main entry point. Detects and redacts PII in the given text.
34
73
 
35
74
  ```ts
36
75
  interface RedactOptions {
37
- countries?: string[]; // Country codes (e.g. ["NL", "BE"]) — recommended for best precision
38
- pseudonymize?: boolean; // Replace with consistent pseudonyms (EMAIL_1, EMAIL_2, ...)
39
- detectDates?: boolean; // Include DOB/date-of-death detections (off by default)
40
- cache?: boolean; // Enable result caching (default: true)
76
+ countries?: string[] | null; // Country codes (e.g. ["NL", "BE"]) — null loads all
77
+ referentialIntegrity?: boolean; // Replace with consistent labels (default: false)
78
+ detectDates?: boolean; // Include DOB/date-of-death detections (default: false)
79
+ cache?: boolean; // Enable result caching (default: true)
41
80
  }
42
81
  ```
43
82
 
44
- ### `redactBatch(texts, options?)`
83
+ | Parameter | Default | Description |
84
+ |---|---|---|
85
+ | `countries` | `null` | ISO 3166-1 alpha-2 codes to restrict detection. `null` loads all 31 countries. |
86
+ | `referentialIntegrity` | `false` | Replace PII with consistent labels instead of entity-type labels. |
87
+ | `detectDates` | `false` | Include date-of-birth and date-of-death detections. Off by default. |
88
+ | `cache` | `true` | Cache results for identical inputs. |
45
89
 
46
- Process multiple texts efficiently. Loads country configs once.
90
+ #### `redactBatch(texts, options?)`
47
91
 
48
- ### `availableCountries()`
92
+ ```ts
93
+ function redactBatch(texts: string[], options?: RedactOptions): RedactResult[];
94
+ ```
49
95
 
50
- Returns the list of supported country codes.
96
+ Process multiple texts efficiently. Loads country configs once. Returns results
97
+ in the same order as the input.
51
98
 
52
- ## Supported Countries
99
+ #### `addCustomPattern(name, pattern)`
53
100
 
54
- AT, BE, BG, CH, CY, CZ, DE, DK, EE, EL, ES, FI, FR, HR, HU, IE, IS, IT, LT, LU, LV, MT, NL, NO, PL, PT, RO, SE, SI, SK, UK
101
+ ```ts
102
+ function addCustomPattern(name: string, pattern: string): void;
103
+ ```
55
104
 
56
- ## Detected PII Types
105
+ Register a custom regex pattern. Matches are reported with `name` as the entity
106
+ type. See [Custom Patterns](#custom-patterns) below.
57
107
 
58
- | Category | Examples |
59
- |----------|---------|
60
- | `NATIONAL_ID` | BSN, Personalausweis, NIR, CPR, personnummer, ... |
61
- | `IBAN` | Country-specific IBAN with ISO 13616 checksum |
62
- | `PHONE` | National and international formats per country |
63
- | `EMAIL` | RFC 5322 simplified |
64
- | `POSTAL_CODE` | Country-specific formats with context validation |
65
- | `TAX_ID` | Steuer-ID, NIF, NIP, ... with checksums |
66
- | `CREDIT_CARD` | Visa, Mastercard, Amex with Luhn check |
67
- | `LICENSE_PLATE` | Country-specific formats |
68
- | `PASSPORT` | Context-required per country |
69
- | `VAT` | EU VAT numbers |
70
- | `BIC` | SWIFT/BIC codes |
71
- | `VIN` | ISO 3779 vehicle identification |
72
- | `IP_ADDRESS` | IPv4 and IPv6 |
73
- | `MAC_ADDRESS` | Colon, dash, and Cisco dot notation |
74
- | `UUID` | RFC 4122 versions 1-5 |
75
- | `IMEI` | 15-digit with Luhn check |
76
- | `GPS_COORDINATES` | Decimal degrees with 4+ decimal places |
77
- | `SOCIAL_HANDLE` | @username format |
78
- | `DOB` | Date of birth (context-required, opt-in) |
108
+ #### `availableCountries()`
79
109
 
80
- ## Performance
110
+ ```ts
111
+ function availableCountries(): string[];
112
+ ```
81
113
 
82
- - **0.02ms** per redaction (typical text)
83
- - **Zero runtime dependencies**
84
- - **86KB** total package size
85
- - Checksum validators eliminate false positives at the pattern level
86
- - Context-aware suppressors filter currency amounts, units, references, and legal citations
114
+ Returns a sorted list of supported ISO country codes.
115
+
116
+ ### Instance-Based API (`EuRedact` Class)
117
+
118
+ For applications that need isolated instances with separate caches and custom
119
+ pattern registrations:
120
+
121
+ ```ts
122
+ import { EuRedact } from "euredact";
123
+
124
+ const instance = new EuRedact();
125
+ instance.addCustomPattern("CASE_REF", "CASE-\\d{8}");
126
+
127
+ const result = instance.redact("See CASE-20260401 for details", {
128
+ countries: ["NL", "BE"],
129
+ });
130
+ console.log(result.redactedText);
131
+ // "See [CASE_REF] for details"
132
+ ```
133
+
134
+ The `EuRedact` class exposes: `redact()`, `redactBatch()`, and
135
+ `addCustomPattern()`.
136
+
137
+ ### Return Types
138
+
139
+ #### `RedactResult`
140
+
141
+ ```ts
142
+ interface RedactResult {
143
+ redactedText: string; // The input text with PII replaced
144
+ detections: Detection[]; // All PII spans found
145
+ source: string; // Detection backend ("rules")
146
+ degraded: boolean; // True if the engine fell back to a simpler mode
147
+ }
148
+ ```
149
+
150
+ #### `Detection`
151
+
152
+ ```ts
153
+ interface Detection {
154
+ entityType: EntityType | string; // PII category (enum or custom name)
155
+ start: number; // Start offset in the original text
156
+ end: number; // End offset (exclusive)
157
+ text: string; // The matched substring
158
+ source: DetectionSource; // "rules" or "cloud"
159
+ country: string | null; // ISO code or null for shared/custom patterns
160
+ confidence: string; // Confidence level
161
+ }
162
+ ```
163
+
164
+ #### `EntityType`
165
+
166
+ String enum with all supported PII categories:
167
+
168
+ ```
169
+ NAME ADDRESS BANK_ACCOUNT BIC
170
+ CREDIT_CARD PHONE EMAIL DOB
171
+ DATE_OF_DEATH NATIONAL_ID SSN TAX_ID
172
+ PASSPORT DRIVERS_LICENSE RESIDENCE_PERMIT LICENSE_PLATE
173
+ VIN VAT POSTAL_CODE IP_ADDRESS
174
+ IPV6_ADDRESS MAC_ADDRESS HEALTH_INSURANCE HEALTHCARE_PROVIDER
175
+ CHAMBER_OF_COMMERCE IMEI GPS_COORDINATES UUID
176
+ SOCIAL_HANDLE SECRET OTHER
177
+ ```
178
+
179
+ For custom patterns, `entityType` is a plain string (e.g. `"EMPLOYEE_ID"`).
180
+
181
+ ## Country codes
182
+
183
+ `countries` accepts **ISO 3166-1 alpha-2** codes. The two EU/VAT spellings are
184
+ accepted as equivalents: `GB`/`UK` and `GR`/`EL`. Codes are case-insensitive
185
+ and whitespace-tolerant.
186
+
187
+ An **unrecognised** code does not throw — it logs an `[euredact]` warning and
188
+ detection continues with the shared, country-independent patterns. Throwing on
189
+ an unknown locale invites callers to wrap the call in `try/catch` and skip
190
+ redaction entirely, failing open with unredacted PII.
191
+
192
+ ## Country-independent detection
193
+
194
+ `countries` narrows which country-specific patterns run. It does **not** gate
195
+ identifiers that carry their own evidence:
196
+
197
+ | Type | Why it ignores `countries` |
198
+ |---|---|
199
+ | `BANK_ACCOUNT` (IBAN) | carries an ISO 3166 country code and a mod-97 checksum |
200
+ | `PHONE` (with `+` prefix) | an E.164 country prefix is self-identifying |
201
+ | `EMAIL`, `CREDIT_CARD`, `VIN`, `IMEI`, `IP_ADDRESS`, `UUID`, `SECRET` | no national form to gate on |
202
+
203
+ ```ts
204
+ redact("Rekening: BE68 5390 0754 7034", { countries: ["AT"] });
205
+ // -> 'Rekening: [BANK_ACCOUNT]'
206
+ ```
207
+
208
+ ## BIC detection
209
+
210
+ BIC is the only bank identifier here with **no check digit**, and characters
211
+ 5-6 of ordinary uppercase words are frequently valid ISO 3166 country codes
212
+ (`DRINGEND` -> `GE`, `HOSPITAL` -> `IT`). Detection is therefore gated:
213
+
214
+ | Stage | Condition | Result |
215
+ |---|---|---|
216
+ | Gate 0 | the token also occurs as an ordinary lowercase word in the same document | never emitted |
217
+ | Tier 1 | registry hit on the BIC6 institution+country prefix | emitted |
218
+ | Gate 2 | heading / shouted-word shape | never emitted |
219
+ | Tier 2 | `BIC`/`SWIFT` keyword, an IBAN, or a bank block in the enclosing line, record or paragraph | emitted |
220
+ | — | none of the above | never emitted |
221
+
222
+ The package bundles **no licensed BIC data** — only a small seed list of BIC6
223
+ prefixes compiled from publicly published bank data. Deployments holding a
224
+ licensed directory install it at startup:
225
+
226
+ ```ts
227
+ import { setBicRegistry } from "euredact";
228
+
229
+ setBicRegistry(["ABNANL2A", "INGBNL2A", "BBRUBE"]); // iterable
230
+ setBicRegistry(bic => myDirectory.has(bic)); // membership callable
231
+ setBicRegistry(null); // remove
232
+ ```
233
+
234
+ The registry is an **accept** signal, never a filter: a code missing from it
235
+ falls through to the context gate and is still detected when banking context is
236
+ present. A stale list costs a little recall on bare, contextless BICs — it
237
+ never causes a leak. Annual review is sufficient.
238
+
239
+ ## `IBAN` is now `BANK_ACCOUNT`
240
+
241
+ The canonical type name is `BANK_ACCOUNT`; `IBAN` was a legacy alias.
242
+ `detection.entityType` is now `"BANK_ACCOUNT"` and the placeholder written into
243
+ redacted text is `[BANK_ACCOUNT]`.
244
+
245
+ `EntityType.IBAN` is kept as an alias with the same value, so
246
+ `EntityType.IBAN === EntityType.BANK_ACCOUNT` and code referring to the member
247
+ keeps working. Code matching the *string* `"IBAN"` — or the `[IBAN]`
248
+ placeholder — must be updated. `LEGACY_TYPE_ALIASES` publishes the mapping.
249
+
250
+
251
+ ## Custom Patterns
252
+
253
+ Register domain-specific PII patterns at runtime:
254
+
255
+ ```ts
256
+ import { addCustomPattern, redact } from "euredact";
257
+
258
+ // Register patterns
259
+ addCustomPattern("EMPLOYEE_ID", "EMP-\\d{6}");
260
+ addCustomPattern("CASE_REF", "CASE-\\d{8}");
261
+
262
+ // They are detected alongside built-in PII
263
+ const result = redact(
264
+ "Employee EMP-123456, email jan@example.com, ref CASE-20260401"
265
+ );
266
+ console.log(result.redactedText);
267
+ // "Employee [EMPLOYEE_ID], email [EMAIL], ref [CASE_REF]"
268
+ ```
269
+
270
+ ### How Custom Patterns Work
271
+
272
+ - `name` becomes the entity type in detections and replacement tags
273
+ - `pattern` is a JavaScript regular expression (same syntax as `RegExp`)
274
+ - Custom patterns are always active regardless of the `countries` option
275
+ - In overlap resolution, custom patterns have higher priority than built-in
276
+ regex-only patterns but lower priority than built-in patterns with a passing
277
+ checksum validator
278
+
279
+ ### Instance Isolation
280
+
281
+ Custom patterns registered on the module-level function apply to the shared
282
+ singleton. For isolated registrations, use separate `EuRedact` instances:
283
+
284
+ ```ts
285
+ import { EuRedact } from "euredact";
286
+
287
+ const a = new EuRedact();
288
+ a.addCustomPattern("EMPLOYEE_ID", "EMP-\\d{6}");
289
+
290
+ const b = new EuRedact();
291
+ b.addCustomPattern("CASE_REF", "CASE-\\d{8}");
292
+ ```
293
+
294
+ ## Secret and API Key Detection
295
+
296
+ Built-in detection for API keys, tokens, and passwords. Always active -- no
297
+ configuration required.
298
+
299
+ ### Known-Prefix Patterns
300
+
301
+ | Pattern | Description |
302
+ |---|---|
303
+ | `AKIA...` | AWS Access Key ID |
304
+ | `ghp_`, `gho_`, `ghs_`, `github_pat_` | GitHub tokens (PAT, OAuth, app, server) |
305
+ | `sk_live_`, `pk_live_`, `sk_test_`, `pk_test_` | Stripe secret and publishable keys |
306
+ | `sk-`, `sk-ant-` | OpenAI and Anthropic API keys |
307
+ | `xoxb-`, `xoxp-`, `xoxa-`, `xoxs-` | Slack tokens |
308
+ | `eyJ...` (3-part base64url) | JWT tokens |
309
+ | `SG.` | SendGrid API keys |
310
+
311
+ ```ts
312
+ const result = redact("My API key is sk-proj-abc123def456ghi789jkl0");
313
+ console.log(result.redactedText);
314
+ // "My API key is [SECRET]"
315
+ ```
316
+
317
+ ### Entropy-Based Detection
318
+
319
+ High-entropy strings (32+ alphanumeric characters) near context keywords like
320
+ `key`, `token`, `secret`, `password`, `credential`, `auth`, or `bearer`
321
+ (including 12 European language translations) are flagged via Shannon entropy
322
+ analysis:
323
+
324
+ ```ts
325
+ redact("The api_key is xK9mPqR7vLnW2bFjY8cGhT4sDfAeU6iO").redactedText;
326
+ // "The api_key is [SECRET]"
327
+
328
+ // Without a context keyword -- not flagged:
329
+ redact("identifier: xK9mPqR7vLnW2bFjY8cGhT4sDfAeU6iO").redactedText;
330
+ // "identifier: xK9mPqR7vLnW2bFjY8cGhT4sDfAeU6iO"
331
+ ```
332
+
333
+ ## Country Hints
334
+
335
+ When you know which countries appear in your data, pass them explicitly:
336
+
337
+ ```ts
338
+ const result = redact(text, { countries: ["NL", "BE"] });
339
+ ```
340
+
341
+ This restricts pattern matching to Dutch and Belgian rules (plus shared patterns
342
+ and custom patterns). Benefits:
343
+
344
+ - **Fewer false positives.** A 9-digit number that passes the Dutch BSN checksum
345
+ won't also be tested against unrelated country patterns.
346
+ - **Faster.** Fewer patterns to compile and scan.
347
+
348
+ ## Referential Integrity
349
+
350
+ When `referentialIntegrity: true`, each unique PII value is mapped to a consistent
351
+ label:
352
+
353
+ ```ts
354
+ const result = redact(
355
+ "BSN 123456782 en later weer 123456782",
356
+ { countries: ["NL"], referentialIntegrity: true }
357
+ );
358
+ console.log(result.redactedText);
359
+ // "BSN NATIONAL_ID_1 en later weer NATIONAL_ID_1"
360
+ ```
361
+
362
+ ## Architecture
363
+
364
+ ```
365
+ Input text
366
+ |
367
+ v
368
+ [Normalizer] -- Unicode normalization, whitespace cleanup
369
+ |
370
+ v
371
+ [Pass 1: Pattern Matching] -- All country + shared + custom regexes
372
+ |
373
+ v
374
+ [Pass 2a: Validation] -- Checksum validators (mod-97, Luhn, entropy, ...)
375
+ | Failed-validation spans become suppression zones
376
+ v
377
+ [Pass 2b: Suppression] -- Remove false positives (currency amounts, units,
378
+ | references, overlapping matches in suppression zones)
379
+ v
380
+ [Deduplication] -- Priority-aware: validated > custom > regex-only
381
+ | Within same tier, longer span wins
382
+ v
383
+ [Replacement] -- Right-to-left substitution with [ENTITY_TYPE] labels
384
+ | or labels
385
+ v
386
+ RedactResult
387
+ ```
388
+
389
+ ### Suppression Zones
390
+
391
+ When a regex matches a pattern that has a checksum validator but the checksum
392
+ fails, the matched span becomes a "suppression zone." Any purely regex-based
393
+ detection fully contained within that zone is suppressed as a false positive.
394
+
395
+ ### Deduplication Priority
396
+
397
+ 1. **Validated patterns** (checksum passes) -- highest priority
398
+ 2. **Custom patterns** (registered via `addCustomPattern()`)
399
+ 3. **Regex-only patterns** (no validator) -- lowest priority
400
+
401
+ Within the same tier, the longer span wins.
87
402
 
88
403
  ## CommonJS
89
404
 
@@ -91,6 +406,14 @@ AT, BE, BG, CH, CY, CZ, DE, DK, EE, EL, ES, FI, FR, HR, HU, IE, IS, IT, LT, LU,
91
406
  const { redact } = require("euredact");
92
407
  ```
93
408
 
409
+ ## Performance
410
+
411
+ | Metric | Value |
412
+ |---|---|
413
+ | Latency per redaction | ~0.02 ms |
414
+ | Package size | ~86 KB |
415
+ | Runtime dependencies | 0 |
416
+
94
417
  ## License
95
418
 
96
419
  Apache-2.0
package/dist/cjs/cache.js CHANGED
@@ -21,10 +21,10 @@ class ResultCache {
21
21
  key(text, countries, mode) {
22
22
  const sorted = [...countries].sort();
23
23
  const raw = `${text}|${sorted.join("|")}|${mode}`;
24
- // Use multiple FNV-1a hashes on different slices for reduced collisions
24
+ // Include raw length to reduce collision risk alongside dual FNV-1a hashes
25
25
  const h1 = fnv1aHash(raw);
26
26
  const h2 = fnv1aHash(raw.slice(Math.floor(raw.length / 2)) + raw.slice(0, Math.floor(raw.length / 2)));
27
- return h1 + h2;
27
+ return `${raw.length}:${h1}${h2}`;
28
28
  }
29
29
  get(key) {
30
30
  if (!this.enabled)
package/dist/cjs/index.js CHANGED
@@ -1,16 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.COUNTRY_CONFIGS = exports.EuRedact = exports.DetectionSource = exports.EntityType = void 0;
3
+ exports.SEED_BIC6_PREFIXES = exports.getBicRegistry = exports.setBicRegistry = exports.COUNTRY_CONFIGS = exports.EuRedact = exports.LEGACY_TYPE_ALIASES = exports.DetectionSource = exports.EntityType = void 0;
4
4
  exports.availableCountries = availableCountries;
5
5
  exports.redact = redact;
6
+ exports.addCustomPattern = addCustomPattern;
7
+ exports.clear = clear;
6
8
  exports.redactBatch = redactBatch;
7
9
  var types_js_1 = require("./types.js");
8
10
  Object.defineProperty(exports, "EntityType", { enumerable: true, get: function () { return types_js_1.EntityType; } });
9
11
  Object.defineProperty(exports, "DetectionSource", { enumerable: true, get: function () { return types_js_1.DetectionSource; } });
12
+ Object.defineProperty(exports, "LEGACY_TYPE_ALIASES", { enumerable: true, get: function () { return types_js_1.LEGACY_TYPE_ALIASES; } });
10
13
  var sdk_js_1 = require("./sdk.js");
11
14
  Object.defineProperty(exports, "EuRedact", { enumerable: true, get: function () { return sdk_js_1.EuRedact; } });
12
15
  var index_js_1 = require("./rules/countries/index.js");
13
16
  Object.defineProperty(exports, "COUNTRY_CONFIGS", { enumerable: true, get: function () { return index_js_1.COUNTRY_CONFIGS; } });
17
+ var bicRegistry_js_1 = require("./rules/bicRegistry.js");
18
+ Object.defineProperty(exports, "setBicRegistry", { enumerable: true, get: function () { return bicRegistry_js_1.setBicRegistry; } });
19
+ Object.defineProperty(exports, "getBicRegistry", { enumerable: true, get: function () { return bicRegistry_js_1.getBicRegistry; } });
20
+ Object.defineProperty(exports, "SEED_BIC6_PREFIXES", { enumerable: true, get: function () { return bicRegistry_js_1.SEED_BIC6_PREFIXES; } });
14
21
  const sdk_js_2 = require("./sdk.js");
15
22
  const index_js_2 = require("./rules/countries/index.js");
16
23
  let _instance = null;
@@ -25,6 +32,12 @@ function availableCountries() {
25
32
  function redact(text, options) {
26
33
  return getInstance().redact(text, options);
27
34
  }
35
+ function addCustomPattern(name, pattern) {
36
+ getInstance().addCustomPattern(name, pattern);
37
+ }
38
+ function clear() {
39
+ getInstance().clear();
40
+ }
28
41
  function redactBatch(texts, options) {
29
42
  return getInstance().redactBatch(texts, options);
30
43
  }
@@ -9,27 +9,25 @@ function normalize(text) {
9
9
  return [normalized, buildOffsetMapping(text, normalized)];
10
10
  }
11
11
  function buildOffsetMapping(original, normalized) {
12
- const nfdOriginal = original.normalize("NFD");
13
- const nfdNormalized = normalized.normalize("NFD");
14
- // Build: normalized index -> original index via NFD as intermediate
12
+ // Precompute cumulative NFD lengths for normalized characters
13
+ const normNfdCum = [0];
14
+ for (let i = 0; i < normalized.length; i++) {
15
+ normNfdCum.push(normNfdCum[i] + normalized[i].normalize("NFD").length);
16
+ }
17
+ // Precompute cumulative NFD lengths for original characters
18
+ const origNfdCum = [0];
19
+ for (let i = 0; i < original.length; i++) {
20
+ origNfdCum.push(origNfdCum[i] + original[i].normalize("NFD").length);
21
+ }
22
+ // Two-pointer scan: map each normalized position to its original position
15
23
  const normToOrig = [];
24
+ let origPtr = 0;
16
25
  for (let normIdx = 0; normIdx < normalized.length; normIdx++) {
17
- const nfdOfNormChar = normalized[normIdx].normalize("NFD");
18
- let nfdStart = 0;
19
- for (let i = 0; i < normIdx; i++) {
20
- nfdStart += normalized[i].normalize("NFD").length;
21
- }
22
- let targetOrig = 0;
23
- let running = 0;
24
- for (let oi = 0; oi < original.length; oi++) {
25
- const nfdOc = original[oi].normalize("NFD");
26
- if (running + nfdOc.length > nfdStart) {
27
- targetOrig = oi;
28
- break;
29
- }
30
- running += nfdOc.length;
26
+ const nfdStart = normNfdCum[normIdx];
27
+ while (origPtr + 1 < original.length && origNfdCum[origPtr + 1] <= nfdStart) {
28
+ origPtr++;
31
29
  }
32
- normToOrig.push(targetOrig);
30
+ normToOrig.push(origPtr);
33
31
  }
34
32
  return normToOrig;
35
33
  }