euredact 0.3.0 → 0.3.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.
package/README.md CHANGED
@@ -43,7 +43,11 @@ console.log(result.detections);
43
43
  - **Custom patterns:** register your own regex patterns for domain-specific PII
44
44
  - **Checksum validation:** IBAN mod-97, Luhn (credit cards/IMEI), and 30+
45
45
  country-specific validators
46
- - **Priority-aware deduplication:** validated (checksum) > custom > regex-only
46
+ - **Priority-aware deduplication:** validated (checksum, corroborated by the
47
+ document's country) > custom > regex-only
48
+ - **Country self-detection:** infers a document's countries from the entities
49
+ that carry one, so an ambiguous value resolves without the caller naming a
50
+ country — and `countries` never gates what is looked for
47
51
  - **Context-aware:** keyword proximity checks and structural detection (JSON field
48
52
  names, CSV headers) for ambiguous patterns
49
53
  - **Zero runtime dependencies**
@@ -73,10 +77,16 @@ Main entry point. Detects and redacts PII in the given text.
73
77
 
74
78
  ```ts
75
79
  interface RedactOptions {
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)
80
+ countries?: string[] | null; // Scope. Flags anything attributed elsewhere as
81
+ // outOfScope. Never gates what is looked for.
82
+ countryHint?: string[] | null; // A prior only — resolves ambiguity without
83
+ // narrowing scope or flagging anything
84
+ context?: DocumentContext | null; // Share country evidence across the chunks
85
+ // of one document (see "Chunked documents")
86
+ chunkOffset?: number; // Where this chunk starts in the document
87
+ referentialIntegrity?: boolean; // Replace with consistent labels (default: false)
88
+ detectDates?: boolean; // Include DOB/date-of-death detections (default: false)
89
+ cache?: boolean; // Enable result caching (default: true)
80
90
  }
81
91
  ```
82
92
 
@@ -158,6 +168,28 @@ interface Detection {
158
168
  source: DetectionSource; // "rules" or "cloud"
159
169
  country: string | null; // ISO code or null for shared/custom patterns
160
170
  confidence: string; // Confidence level
171
+ countryConfidence?: number; // How strongly the document supports `country`,
172
+ // in [0, 1]. 0 means the attribution rests on
173
+ // a checksum alone.
174
+ outOfScope?: boolean; // Attributed outside the declared `countries`.
175
+ // Flagged, never dropped.
176
+ }
177
+ ```
178
+
179
+ #### `RedactResult`
180
+
181
+ ```ts
182
+ interface RedactResult {
183
+ redactedText: string;
184
+ detections: Detection[];
185
+ source: string;
186
+ degraded: boolean;
187
+
188
+ // Country inference — see "Country-independent detection"
189
+ inferredCountries: Array<[string, number]>; // [country, confidence], strongest first
190
+ evidence: CountryEvidence[]; // every signal, with the span behind it
191
+ detectionMode: string; // "declared" if countries was passed,
192
+ // "inferred" otherwise
161
193
  }
162
194
  ```
163
195
 
@@ -191,20 +223,107 @@ redaction entirely, failing open with unredacted PII.
191
223
 
192
224
  ## Country-independent detection
193
225
 
194
- `countries` narrows which country-specific patterns run. It does **not** gate
195
- identifiers that carry their own evidence:
226
+ **`countries` never gates detection.** Every pattern runs on every document,
227
+ whatever you pass. The country you declare decides *how a match is labelled*,
228
+ never *whether it is found*.
196
229
 
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 |
230
+ This is the engine's central invariant, enforced by
231
+ `src/__tests__/inference.ts`: no value of `countries` may change which spans are
232
+ detected. A wrong or missing country cannot cause a miss — silent recall loss is
233
+ invisible in testing and surfaces in a breach report, whereas a false positive
234
+ is recoverable.
235
+
236
+ It was not always so. `countries: ["BE"]` used to make a valid Dutch BSN vanish
237
+ entirely, because the Dutch patterns were never run:
238
+
239
+ ```ts
240
+ redact("Werknemer met BSN 111222333", { countries: ["BE"] });
241
+ // before: 'Werknemer met BSN 111222333' <- leaked
242
+ // now: 'Werknemer met BSN [NATIONAL_ID]'
243
+ ```
244
+
245
+ Entities found outside the countries you declared are **flagged, not dropped**:
246
+
247
+ ```ts
248
+ const [det] = redact("BSN 111222333", { countries: ["BE"] }).detections;
249
+ det.outOfScope; // true — detected, masked, and marked as outside your scope
250
+ ```
251
+
252
+ So a Belgian IBAN in a document processed with `countries: ["AT"]` is still
253
+ detected:
202
254
 
203
255
  ```ts
204
256
  redact("Rekening: BE68 5390 0754 7034", { countries: ["AT"] });
205
257
  // -> 'Rekening: [BANK_ACCOUNT]'
206
258
  ```
207
259
 
260
+ ### Which country a value belongs to
261
+
262
+ Because every pattern runs, the same digits often match several countries'
263
+ schemes. 36.6% of national-ID values in our corpus validate under more than one
264
+ country's checksum, so the digits alone cannot decide it — the *document* does.
265
+
266
+ The engine infers the document's countries from entities that carry their
267
+ country in the string, then uses that to resolve the ambiguity:
268
+
269
+ ```ts
270
+ redact("Bereikbaar op telefoon 0612345678, mail jan@test.nl");
271
+ // inferredCountries: [["NL", 0.98]] detections: [PHONE (NL), EMAIL]
272
+
273
+ redact("Kontakt: 0612345678, e-mail jens@test.dk");
274
+ // inferredCountries: [["DK", 0.98]] detections: [NATIONAL_ID (DK), EMAIL]
275
+ ```
276
+
277
+ Identical digits, different answer — `0612345678` is both a valid Dutch mobile
278
+ number and a valid Danish CPR. Only the surrounding document distinguishes them.
279
+
280
+ Every inference is auditable: `result.evidence` lists each signal, its weight,
281
+ and the span that produced it, in document order.
282
+
283
+ | Signal | Weight (log-odds) | Measured reliability |
284
+ |---|---:|---|
285
+ | `e164Prefix` | 4.00 (capped) | 41,402 / 41,402 |
286
+ | `bicCountry` | 4.00 (capped) | 2,588 / 2,588 |
287
+ | `emailTld` | 4.00 (capped) | 97,865 / 98,949 |
288
+ | `vatPrefix` | 2.84 | 19,022 / 20,136 |
289
+ | `ibanPrefix` | 1.94 | 110,572 / 126,428 |
290
+
291
+ Weights are derived from the corpus, not chosen by hand, and are kept identical
292
+ to the Python SDK's. The IBAN prefix being weakest is real: a Belgian IBAN in a
293
+ Dutch invoice is ordinary, so an account's country is only weak evidence about
294
+ the document's.
295
+
296
+ Confidences are per-country and do **not** sum to 1 — document countries are not
297
+ mutually exclusive. A Belgian supplier invoicing a German customer is genuinely
298
+ both.
299
+
300
+ ## Chunked documents
301
+
302
+ A long document is usually redacted in pieces. Each piece is scanned
303
+ independently, so a chunk carrying no country signal of its own infers nothing —
304
+ even when page 1 identified the document beyond doubt.
305
+
306
+ ```ts
307
+ import { DocumentContext } from "euredact";
308
+
309
+ const ctx = new DocumentContext();
310
+ let offset = 0;
311
+ for (const page of pages) {
312
+ const result = redact(page, { context: ctx, chunkOffset: offset });
313
+ offset += page.length;
314
+ }
315
+
316
+ // page 1: "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
317
+ // page 7: "Telefoon 0612345678" -> PHONE (NL), not NATIONAL_ID (DK)
318
+ ```
319
+
320
+ `chunkOffset` rebases spans recorded in the context so they point into the whole
321
+ document; returned detections stay relative to the chunk you passed in. Caching
322
+ is disabled automatically while a context is in use, because the result then
323
+ depends on evidence the text alone does not determine.
324
+
325
+ Reuse a context only for chunks of the **same** document.
326
+
208
327
  ## BIC detection
209
328
 
210
329
  BIC is the only bank identifier here with **no check digit**, and characters
@@ -332,18 +451,35 @@ redact("identifier: xK9mPqR7vLnW2bFjY8cGhT4sDfAeU6iO").redactedText;
332
451
 
333
452
  ## Country Hints
334
453
 
335
- When you know which countries appear in your data, pass them explicitly:
454
+ Two options tell the engine about country, and neither restricts what is looked
455
+ for:
456
+
457
+ | Option | Meaning |
458
+ |---|---|
459
+ | `countries` | **Scope.** Resolves ambiguity, and flags anything attributed elsewhere as `outOfScope`. |
460
+ | `countryHint` | **Prior only.** Resolves ambiguity without narrowing scope or flagging anything. |
461
+
462
+ ```ts
463
+ // You know this batch is Swedish, but don't want foreign PII marked out of scope:
464
+ redact(text, { countryHint: ["SE"] });
465
+
466
+ // You want anything non-Swedish flagged for review:
467
+ redact(text, { countries: ["SE"] });
468
+ ```
469
+
470
+ Declaring a country helps where a value is genuinely ambiguous and the document
471
+ carries no other signal:
336
472
 
337
473
  ```ts
338
- const result = redact(text, { countries: ["NL", "BE"] });
474
+ redact("Telefon: 0708787668", { countryHint: ["SE"] }).detections[0];
475
+ // PHONE / SE — without the hint this is a valid Danish CPR and nothing says otherwise
339
476
  ```
340
477
 
341
- This restricts pattern matching to Dutch and Belgian rules (plus shared patterns
342
- and custom patterns). Benefits:
478
+ `result.detectionMode` reports which happened: `"declared"` if you passed
479
+ `countries`, `"inferred"` otherwise.
343
480
 
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.
481
+ Passing neither is safe, and is the right default for mixed-origin data: the
482
+ engine infers what it can and reports it in `result.inferredCountries`.
347
483
 
348
484
  ## Referential Integrity
349
485
 
@@ -372,13 +508,17 @@ Input text
372
508
  |
373
509
  v
374
510
  [Pass 2a: Validation] -- Checksum validators (mod-97, Luhn, entropy, ...)
375
- | Failed-validation spans become suppression zones
511
+ | Failed spans are recorded, per entity type
512
+ v
513
+ [Evidence] -- Which countries does this document belong to? From IBAN
514
+ | prefixes, +CC codes, VAT prefixes, BIC, email ccTLDs
376
515
  v
377
516
  [Pass 2b: Suppression] -- Remove false positives (currency amounts, units,
378
- | references, overlapping matches in suppression zones)
517
+ | references). Failed checksums demote same-type
518
+ | matches rather than deleting them
379
519
  v
380
- [Deduplication] -- Priority-aware: validated > custom > regex-only
381
- | Within same tier, longer span wins
520
+ [Deduplication] -- Priority-aware, country-evidence-weighted
521
+ | Longer span outranks declared country
382
522
  v
383
523
  [Replacement] -- Right-to-left substitution with [ENTITY_TYPE] labels
384
524
  | or labels
@@ -389,16 +529,60 @@ RedactResult
389
529
  ### Suppression Zones
390
530
 
391
531
  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.
532
+ fails, the span is recorded. A validator-less match of **the same entity type**
533
+ contained in it is *demoted* below every other candidate — not deleted. A failed
534
+ checksum is evidence against that type, not against the span, and demotion can
535
+ never silence a detection the way deletion could.
394
536
 
395
537
  ### Deduplication Priority
396
538
 
397
- 1. **Validated patterns** (checksum passes) -- highest priority
398
- 2. **Custom patterns** (registered via `addCustomPattern()`)
399
- 3. **Regex-only patterns** (no validator) -- lowest priority
539
+ When multiple patterns match overlapping spans, the engine resolves conflicts
540
+ using a priority system:
541
+
542
+ | Tier | What |
543
+ |---:|---|
544
+ | 3 | **Validated** — a checksum validator passes *and* the document corroborates its country |
545
+ | 2 | **Custom patterns** registered via `addCustomPattern()` |
546
+ | 1 | **Regex-only**, and validated patterns whose country the document does not corroborate |
547
+ | 0 | **Postal codes** — a bare digit run, the weakest evidence in the engine |
548
+ | -1 | **Demoted** — a validator-less match inside a failed checksum of *its own type* |
549
+
550
+ Within a tier, ranking is: longer span, then stronger country evidence, then
551
+ whether the country was declared. **Span length outranks country** deliberately:
552
+ preferring the declared country over the longest match truncates entities — with
553
+ `countries: ["BE"]` the Belgian phone pattern claimed 11 of the 14 characters of
554
+ `06 12 34 56 78` and left three digits exposed. Country can change *which*
555
+ country is attributed, never *what* is masked.
556
+
557
+ Two tiers are less obvious than they look:
558
+
559
+ - **A passing checksum does not automatically win.** A weak checksum fits by
560
+ luck — a mod-11 scheme accepts a random number about one time in eleven — so a
561
+ validated candidate from a country the document shows no trace of drops to
562
+ tier 1. Entities that carry their own country vouch for themselves (an IBAN
563
+ emits evidence for its own country), so a foreign IBAN in a domestic invoice
564
+ keeps tier 3.
565
+ - **Nothing is deleted for failing a checksum.** A failed checksum demotes
566
+ rather than removes, and only candidates of *the same entity type*: it is
567
+ evidence against that type, not against the span. Deleting instead removed 454
568
+ detections across the corpus, of which 454 overlapped real labelled PII.
569
+
570
+ ## Performance
571
+
572
+ Measured on one core, all 31 countries loaded, `detectDates: true`. Cost scales
573
+ with document length, so the input size is stated rather than averaged away.
574
+
575
+ | Input | Latency | Throughput |
576
+ |---|---:|---:|
577
+ | Short record (186 chars) | 155 µs | 6,462 docs/s |
578
+ | Real document (3,424 chars) | 1.56 ms | 643 docs/s |
400
579
 
401
- Within the same tier, the longer span wins.
580
+ No optional accelerator is needed or offered. The Python SDK ships an
581
+ `[fast]` extra (RE2 / Aho-Corasick) because CPython's regex engine is the
582
+ bottleneck there; V8's has literal prefilters that make it unnecessary here.
583
+ Measured on the same 611 documents, this SDK runs about 3× faster than the
584
+ accelerated Python path, so adding a native addon — and with it the loss of
585
+ bundler, edge-runtime and Deno compatibility — would buy nothing.
402
586
 
403
587
  ## CommonJS
404
588
 
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SEED_BIC6_PREFIXES = exports.getBicRegistry = exports.setBicRegistry = exports.COUNTRY_CONFIGS = exports.EuRedact = exports.LEGACY_TYPE_ALIASES = exports.DetectionSource = exports.EntityType = void 0;
3
+ exports.SEED_BIC6_PREFIXES = exports.getBicRegistry = exports.setBicRegistry = exports.COUNTRY_CONFIGS = exports.EuRedact = exports.DocumentContext = exports.LEGACY_TYPE_ALIASES = exports.DetectionSource = exports.EntityType = void 0;
4
4
  exports.availableCountries = availableCountries;
5
5
  exports.redact = redact;
6
6
  exports.addCustomPattern = addCustomPattern;
@@ -10,6 +10,8 @@ var types_js_1 = require("./types.js");
10
10
  Object.defineProperty(exports, "EntityType", { enumerable: true, get: function () { return types_js_1.EntityType; } });
11
11
  Object.defineProperty(exports, "DetectionSource", { enumerable: true, get: function () { return types_js_1.DetectionSource; } });
12
12
  Object.defineProperty(exports, "LEGACY_TYPE_ALIASES", { enumerable: true, get: function () { return types_js_1.LEGACY_TYPE_ALIASES; } });
13
+ var context_js_1 = require("./rules/context.js");
14
+ Object.defineProperty(exports, "DocumentContext", { enumerable: true, get: function () { return context_js_1.DocumentContext; } });
13
15
  var sdk_js_1 = require("./sdk.js");
14
16
  Object.defineProperty(exports, "EuRedact", { enumerable: true, get: function () { return sdk_js_1.EuRedact; } });
15
17
  var index_js_1 = require("./rules/countries/index.js");
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ /**
3
+ * Country evidence shared across the chunks of one document.
4
+ *
5
+ * A long document is usually redacted in pieces. Each piece is scanned
6
+ * independently, so each infers its own country — and a chunk that happens to
7
+ * contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
8
+ * when the first page identified the document beyond doubt:
9
+ *
10
+ * page 1 "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
11
+ * page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
12
+ *
13
+ * Scanned alone, page 7 has nothing to go on. Sharing a context across the two
14
+ * carries page 1's evidence forward, and the number resolves as a Dutch phone.
15
+ *
16
+ * Spans are rebased by `chunkOffset` on the way in, so evidence collected from
17
+ * a chunk points at offsets in the whole document rather than the chunk.
18
+ *
19
+ * A context influences **scoring only**. It can change which national scheme is
20
+ * attributed to a value. It can never change which spans are found, so a
21
+ * context carried over from the wrong document cannot cause a miss.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.DocumentContext = void 0;
25
+ class DocumentContext {
26
+ // Deduped on the same key evidence.collect() uses, so re-running a chunk — a
27
+ // retry, an overlapping window — cannot let one entity vote twice and
28
+ // outweigh the rest of the document.
29
+ seen = new Set();
30
+ items = [];
31
+ /** Record evidence from a chunk starting at `chunkOffset`. */
32
+ add(evidence, chunkOffset = 0) {
33
+ for (const ev of evidence) {
34
+ const span = [ev.span[0] + chunkOffset, ev.span[1] + chunkOffset];
35
+ const key = `${span[0]}:${span[1]}:${ev.source}`;
36
+ if (this.seen.has(key))
37
+ continue;
38
+ this.seen.add(key);
39
+ this.items.push(chunkOffset === 0 ? ev : { ...ev, span });
40
+ }
41
+ }
42
+ /** Everything recorded so far, spans rebased to the whole document. */
43
+ evidence() {
44
+ return [...this.items];
45
+ }
46
+ get size() {
47
+ return this.items.length;
48
+ }
49
+ }
50
+ exports.DocumentContext = DocumentContext;
@@ -217,7 +217,13 @@ const DE = {
217
217
  p(types_js_1.EntityType.PHONE, String.raw `\b0[1-9]\d{1,4}[\s/\-]?\d{3,8}\b`),
218
218
  p(types_js_1.EntityType.PHONE, String.raw `\+49\s?\d{2,5}[\s/\-]?\d{3,8}\b`),
219
219
  p(types_js_1.EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9A-Z]{8,9}\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
220
- p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[A-ZÄÖÜ]{1,3}[\s\-]?[A-Z]{1,2}[\s\-]?\d{1,4}[EH]?\b`),
220
+ // The separator after the city code is mandatory. When it was optional, a
221
+ // contiguous letter run split across both groups and the hyphen was
222
+ // consumed as the *second* separator, so any "LETTERS-DIGITS" token
223
+ // matched: ICD-10 parsed as city "IC" + letters "D" + "-" + "10", and
224
+ // ISO-9001, COVID-19, RFC-2822, POL-2025 likewise. A real plate always
225
+ // separates the city code: "B-AB 1234", "M-XY 99".
226
+ p(types_js_1.EntityType.LICENSE_PLATE, String.raw `\b[A-ZÄÖÜ]{1,3}[\s\-][A-Z]{1,2}[\s\-]?\d{1,4}[EH]?\b`),
221
227
  p(types_js_1.EntityType.POSTAL_CODE, String.raw `\b\d{5}\b`, null, "", ["PLZ", "Postleitzahl", "postal code", "postcode", "Anschrift", "Adresse", "Wohnort", "Ort", "Stadt", "wohnt", "wohnhaft", "Straße", "Str.", "Weg", "Platz", "Allee", "Ring"], true),
222
228
  p(types_js_1.EntityType.CHAMBER_OF_COMMERCE, String.raw `\bHR[AB]\s?\d{4,6}\b`),
223
229
  p(types_js_1.EntityType.SSN, String.raw `\b\d{2}[\s]?\d{6}[\s]?[A-Z][\s]?\d{3}\b`, null, "", ["Rentenversicherung", "RV-Nummer", "Sozialversicherung", "SV-Nummer", "Versicherungsnummer", "Rentenversicherungsnummer", "Sozialversicherungsnummer"], true),
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * German vehicle registration district codes (Unterscheidungszeichen).
4
+ *
5
+ * The set is closed: codes are fixed by the Fahrzeug-Zulassungsverordnung and
6
+ * maintained by the Kraftfahrt-Bundesamt, changing only when a district is
7
+ * created, merged, or a historic code is reinstated. That makes membership a
8
+ * usable precision signal — unlike the open-ended blocklist of standards
9
+ * prefixes it replaces, which only ever catches acronyms someone thought of.
10
+ *
11
+ * Used as a **tier**, not a filter: code on this list -> emit; code absent but
12
+ * a plate cue nearby -> emit; neither -> reject. So a missing code costs
13
+ * recall only where there is also no cue. Some entries are also standards
14
+ * prefixes — DIN is Dinslaken as well as the standards body — which is why the
15
+ * standards guard is kept alongside this one.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.DE_DISTRICT_CODES = void 0;
19
+ exports.DE_DISTRICT_CODES = new Set([
20
+ "A", "AA", "AB", "ABG", "AC", "AE", "AH", "AIB", "AIC", "AK", "AL", "ALF",
21
+ "ALS", "ALZ", "AM", "AN", "ANA", "ANG", "ANK", "AP", "APD", "AR", "ARN",
22
+ "ART", "AS", "ASD", "ASL", "ASZ", "AT", "AU", "AUR", "AW", "AZ", "AZE",
23
+ "AÖ", "B", "BA", "BAD", "BAR", "BB", "BBG", "BBL", "BC", "BCH", "BD",
24
+ "BE", "BED", "BEI", "BEL", "BER", "BF", "BG", "BGD", "BGL", "BH", "BI",
25
+ "BID", "BIN", "BIR", "BIT", "BIW", "BK", "BKS", "BL", "BLB", "BLK", "BM",
26
+ "BN", "BNA", "BO", "BOG", "BOH", "BOR", "BOT", "BP", "BR", "BRA", "BRB",
27
+ "BRG", "BRI", "BRK", "BRL", "BRV", "BS", "BSB", "BSK", "BT", "BTF", "BU",
28
+ "BUL", "BUR", "BW", "BWL", "BYL", "BZ", "BZA", "BÖ", "BÜD", "BÜS", "BÜZ",
29
+ "C", "CA", "CAS", "CB", "CE", "CHA", "CHL", "CLP", "CLZ", "CO", "COC",
30
+ "COE", "CR", "CT", "CUX", "CW", "D", "DA", "DAH", "DAN", "DAU", "DB",
31
+ "DBR", "DD", "DE", "DEG", "DEL", "DG", "DGF", "DH", "DI", "DIL", "DIN",
32
+ "DIZ", "DKB", "DL", "DLG", "DM", "DN", "DO", "DON", "DS", "DT", "DU",
33
+ "DUD", "DW", "DZ", "DÜW", "E", "EA", "EB", "EBE", "EBN", "EBS", "ECK",
34
+ "ED", "EE", "EF", "EG", "EH", "EHI", "EI", "EIC", "EIH", "EIL", "EIN",
35
+ "EIS", "EL", "EM", "EMD", "EMS", "EN", "ER", "ERB", "ERH", "ERK", "ES",
36
+ "ESA", "ESB", "ESW", "EU", "EUT", "EW", "F", "FAL", "FB", "FD", "FDB",
37
+ "FDS", "FEU", "FF", "FFB", "FG", "FH", "FI", "FKB", "FL", "FLÖ", "FN",
38
+ "FO", "FOR", "FR", "FRG", "FRI", "FRW", "FS", "FT", "FTL", "FW", "FZ",
39
+ "FÜ", "FÜS", "G", "GA", "GAN", "GAP", "GC", "GD", "GDB", "GE", "GEL",
40
+ "GEM", "GEO", "GER", "GF", "GG", "GHA", "GHC", "GI", "GK", "GL", "GLA",
41
+ "GM", "GMN", "GN", "GNT", "GOA", "GOH", "GP", "GR", "GRA", "GRH", "GRI",
42
+ "GRM", "GRS", "GRZ", "GS", "GT", "GTH", "GUB", "GUN", "GV", "GVM", "GW",
43
+ "GZ", "GÖ", "GÜ", "H", "HA", "HAB", "HAL", "HAM", "HAS", "HB", "HBN",
44
+ "HBS", "HC", "HCH", "HD", "HDH", "HDL", "HE", "HEB", "HEF", "HEI", "HEL",
45
+ "HER", "HET", "HF", "HG", "HGN", "HGW", "HH", "HHM", "HI", "HIG", "HIP",
46
+ "HK", "HL", "HM", "HMU", "HN", "HO", "HOG", "HOH", "HOL", "HOM", "HOR",
47
+ "HOT", "HP", "HR", "HRO", "HS", "HSK", "HST", "HU", "HUS", "HV", "HVL",
48
+ "HW", "HWI", "HX", "HY", "HZ", "HÖS", "HÜN", "IGB", "IK", "IL", "ILL",
49
+ "IN", "IS", "IZ", "J", "JB", "JE", "JEV", "JL", "JÜL", "K", "KA", "KAR",
50
+ "KB", "KC", "KE", "KEH", "KEL", "KEM", "KF", "KG", "KH", "KI", "KIB",
51
+ "KK", "KL", "KLE", "KLT", "KLZ", "KM", "KN", "KO", "KOZ", "KR", "KRU",
52
+ "KS", "KT", "KU", "KUS", "KW", "KY", "KYF", "KÖN", "KÖT", "KÜN", "L",
53
+ "LA", "LAN", "LAT", "LAU", "LB", "LBS", "LBZ", "LC", "LD", "LDK", "LDS",
54
+ "LE", "LEO", "LER", "LEV", "LF", "LG", "LH", "LI", "LIB", "LIF", "LIN",
55
+ "LIP", "LK", "LL", "LM", "LN", "LOH", "LOS", "LP", "LR", "LS", "LSA",
56
+ "LSN", "LSZ", "LU", "LUK", "LWL", "LÖ", "LÖB", "LÜD", "LÜN", "M", "MA",
57
+ "MAB", "MAI", "MAK", "MAL", "MAR", "MB", "MC", "MD", "ME", "MED", "MEG",
58
+ "MEI", "MEK", "MEL", "MEP", "MER", "MES", "MET", "MG", "MGH", "MGN", "MH",
59
+ "MHL", "MI", "MIL", "MK", "MKK", "ML", "MM", "MN", "MO", "MOD", "MOL",
60
+ "MON", "MOS", "MQ", "MR", "MS", "MSP", "MST", "MT", "MTK", "MTL", "MVL",
61
+ "MW", "MY", "MYK", "MZ", "MZG", "MÜ", "MÜB", "MÜL", "MÜN", "MÜR", "N",
62
+ "NAB", "NAI", "NAU", "NB", "ND", "NDH", "NE", "NEA", "NEB", "NEC", "NEN",
63
+ "NES", "NEU", "NEW", "NF", "NH", "NI", "NIB", "NK", "NL", "NM", "NMB",
64
+ "NMS", "NOH", "NOL", "NOM", "NOR", "NP", "NR", "NRW", "NRÜ", "NT", "NU",
65
+ "NVP", "NW", "NWM", "NY", "NZ", "NÖ", "OA", "OAL", "OB", "OBB", "OBG",
66
+ "OC", "OCH", "OD", "OE", "OF", "OG", "OH", "OHA", "OHV", "OHZ", "OK",
67
+ "OL", "OLD", "OP", "OPR", "OR", "OS", "OSL", "OTT", "OTW", "OVI", "OVL",
68
+ "OVP", "OZ", "P", "PA", "PAF", "PAN", "PAR", "PB", "PCH", "PE", "PEG",
69
+ "PER", "PF", "PI", "PIR", "PK", "PL", "PLÖ", "PM", "PN", "PR", "PRÜ",
70
+ "PS", "PW", "PZ", "QFT", "QLB", "R", "RA", "RC", "RD", "RDG", "RE", "REG",
71
+ "REH", "REI", "RG", "RH", "RI", "RID", "RIE", "RL", "RM", "RN", "RO",
72
+ "ROD", "ROF", "ROH", "ROK", "ROL", "ROS", "ROT", "ROW", "RPL", "RS",
73
+ "RSL", "RT", "RU", "RV", "RW", "RWL", "RY", "RZ", "RÜD", "RÜG", "S", "SA",
74
+ "SAB", "SAD", "SAL", "SAN", "SAO", "SAW", "SB", "SBG", "SBK", "SC", "SCZ",
75
+ "SDH", "SDL", "SDT", "SE", "SEB", "SEE", "SEF", "SEL", "SF", "SFA", "SFB",
76
+ "SFT", "SG", "SGH", "SH", "SHA", "SHG", "SHK", "SHL", "SHS", "SI", "SIG",
77
+ "SIM", "SK", "SL", "SLE", "SLF", "SLG", "SLK", "SLN", "SLS", "SLZ", "SLÜ",
78
+ "SM", "SMÜ", "SN", "SNH", "SO", "SOB", "SOG", "SOK", "SOL", "SON", "SP",
79
+ "SPB", "SPN", "SPR", "SR", "SRB", "SRO", "ST", "STA", "STB", "STD", "STE",
80
+ "STH", "STL", "STO", "SU", "SUL", "SW", "SWA", "SWZ", "SY", "SZ", "SZB",
81
+ "SÄK", "SÖM", "SÜW", "TBB", "TE", "TF", "TG", "TGO", "THL", "THW", "TIR",
82
+ "TO", "TP", "TR", "TS", "TT", "TUT", "TÖL", "TÖN", "TÜ", "UE", "UEM",
83
+ "UER", "UFF", "UH", "UL", "UM", "UN", "USI", "V", "VAI", "VB", "VEC",
84
+ "VER", "VIB", "VIE", "VIT", "VK", "VL", "VOF", "VOH", "VR", "VS", "W",
85
+ "WA", "WAF", "WAK", "WAM", "WAN", "WAR", "WAT", "WB", "WBS", "WD", "WDA",
86
+ "WE", "WEB", "WEG", "WEI", "WEL", "WEM", "WEN", "WER", "WES", "WF", "WG",
87
+ "WHV", "WI", "WIL", "WIS", "WIT", "WIZ", "WK", "WL", "WLG", "WM", "WMS",
88
+ "WN", "WND", "WO", "WOB", "WOH", "WOL", "WOR", "WOS", "WR", "WRN", "WS",
89
+ "WSF", "WST", "WSW", "WT", "WTL", "WTM", "WUG", "WUM", "WUN", "WUR", "WW",
90
+ "WZ", "WZL", "WÜ", "X", "Y", "Z", "ZE", "ZEL", "ZI", "ZIG", "ZP", "ZR",
91
+ "ZS", "ZW", "ZZ", "ÖHR", "ÜB",
92
+ ]);