euredact 0.3.0 → 0.3.1

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.
@@ -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
+ ]);
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.shouldSuppress = shouldSuppress;
4
4
  const types_js_1 = require("../types.js");
5
5
  const bicRegistry_js_1 = require("./bicRegistry.js");
6
+ const deDistricts_js_1 = require("./deDistricts.js");
6
7
  const CONTEXT_CHARS = 150;
7
8
  function getContext(text, start, end) {
8
9
  const ctxStart = Math.max(0, start - CONTEXT_CHARS);
@@ -120,6 +121,12 @@ function suppressPhoneDateOverlap(_text, match) {
120
121
  return false;
121
122
  return DATE_PATTERN_FULL.test(match.text.trim());
122
123
  }
124
+ // Standards and classification prefixes that are plate-shaped once a letter and
125
+ // digits follow: "ATC-N06", "ICD-O3". None is a German district code, but the
126
+ // guard is still gated on the absence of a plate cue, so a genuine plate that
127
+ // happens to collide is not lost.
128
+ const STANDARDS_PREFIX = new Set(["ICD", "ISO", "DIN", "IEC", "RFC", "DSM", "ATC", "MDR"]);
129
+ const PLATE_CUE_NEAR = /(?:Kennzeichen|Nummernschild|Kfz|Fahrzeug|amtliche[sn]?\s+Kennz|nummerplaat|plaque\s+d'immatriculation|license\s+plate|number\s+plate)/i;
123
130
  const NOT_CITY_CODES = new Set([
124
131
  "ID", "NR", "NO", "ST", "DR", "MR", "MS", "HR", "FR", "IM", "IN", "OR", "IF", "IS", "IT", "AT", "AD", "AG", "AV",
125
132
  "BE", "DE", "EU", "NL", "LU", "WS", "SS", "IP",
@@ -138,6 +145,13 @@ function suppressPlateInCompound(text, match) {
138
145
  }
139
146
  const matched = match.text.trim();
140
147
  const parts = matched.split(/[\s\-]+/);
148
+ // A standards or classification reference, not a plate — unless a plate cue
149
+ // nearby says otherwise.
150
+ if (parts.length > 0 && STANDARDS_PREFIX.has(parts[0].toUpperCase())) {
151
+ const [b, a] = getContext(text, match.start, match.end);
152
+ if (!PLATE_CUE_NEAR.test(b + a))
153
+ return true;
154
+ }
141
155
  if (parts.length > 0 && NOT_CITY_CODES.has(parts[0])) {
142
156
  const afterChar = match.end < text.length ? text[match.end] : "";
143
157
  const beforeChar = match.start > 0 ? text[match.start - 1] : "";
@@ -160,6 +174,31 @@ function suppressPlateInCompound(text, match) {
160
174
  }
161
175
  return false;
162
176
  }
177
+ /**
178
+ * Reject a German plate whose district code is not a real one.
179
+ *
180
+ * The district-code set is closed, which makes it a whitelist rather than the
181
+ * open-ended blocklist of standards prefixes: "REF-A12", "SYS-B3", "KTO-A1"
182
+ * and every other document reference of that shape fail it without needing to
183
+ * be enumerated. Applied as a tier — an unknown code still emits when a plate
184
+ * cue is nearby, so a missing code costs recall only in the absence of any
185
+ * other evidence.
186
+ */
187
+ function suppressDePlateUnknownDistrict(text, match) {
188
+ if (match.patternDef.entityType !== types_js_1.EntityType.LICENSE_PLATE)
189
+ return false;
190
+ if (match.countryCode !== "DE")
191
+ return false;
192
+ const parts = match.text.trim().split(/[\s\-]+/);
193
+ if (parts.length === 0 || !parts[0])
194
+ return false;
195
+ if (deDistricts_js_1.DE_DISTRICT_CODES.has(parts[0].toUpperCase()))
196
+ return false;
197
+ const [before, after] = getContext(text, match.start, match.end);
198
+ if (PLATE_CUE_NEAR.test(before + after))
199
+ return false;
200
+ return true;
201
+ }
163
202
  function suppressNatidAsPassport(text, match) {
164
203
  if (match.patternDef.entityType !== types_js_1.EntityType.NATIONAL_ID)
165
204
  return false;
@@ -429,7 +468,7 @@ const TYPE_SUPPRESSORS = {
429
468
  [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
430
469
  [types_js_1.EntityType.BIC]: [suppressBicWithoutEvidence],
431
470
  [types_js_1.EntityType.BANK_ACCOUNT]: [suppressReference],
432
- [types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound],
471
+ [types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict],
433
472
  [types_js_1.EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
434
473
  };
435
474
  function shouldSuppress(text, match) {
@@ -214,7 +214,13 @@ const DE = {
214
214
  p(EntityType.PHONE, String.raw `\b0[1-9]\d{1,4}[\s/\-]?\d{3,8}\b`),
215
215
  p(EntityType.PHONE, String.raw `\+49\s?\d{2,5}[\s/\-]?\d{3,8}\b`),
216
216
  p(EntityType.PASSPORT, String.raw `\b[CFGHJK][0-9A-Z]{8,9}\b`, null, "", ["Reisepass", "passport", "Passnummer", "Reisepassnummer", "Reisepass Nummer", "Reisepass-Nr", "Pass Nr"], true),
217
- p(EntityType.LICENSE_PLATE, String.raw `\b[A-ZÄÖÜ]{1,3}[\s\-]?[A-Z]{1,2}[\s\-]?\d{1,4}[EH]?\b`),
217
+ // The separator after the city code is mandatory. When it was optional, a
218
+ // contiguous letter run split across both groups and the hyphen was
219
+ // consumed as the *second* separator, so any "LETTERS-DIGITS" token
220
+ // matched: ICD-10 parsed as city "IC" + letters "D" + "-" + "10", and
221
+ // ISO-9001, COVID-19, RFC-2822, POL-2025 likewise. A real plate always
222
+ // separates the city code: "B-AB 1234", "M-XY 99".
223
+ p(EntityType.LICENSE_PLATE, String.raw `\b[A-ZÄÖÜ]{1,3}[\s\-][A-Z]{1,2}[\s\-]?\d{1,4}[EH]?\b`),
218
224
  p(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),
219
225
  p(EntityType.CHAMBER_OF_COMMERCE, String.raw `\bHR[AB]\s?\d{4,6}\b`),
220
226
  p(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,16 @@
1
+ /**
2
+ * German vehicle registration district codes (Unterscheidungszeichen).
3
+ *
4
+ * The set is closed: codes are fixed by the Fahrzeug-Zulassungsverordnung and
5
+ * maintained by the Kraftfahrt-Bundesamt, changing only when a district is
6
+ * created, merged, or a historic code is reinstated. That makes membership a
7
+ * usable precision signal — unlike the open-ended blocklist of standards
8
+ * prefixes it replaces, which only ever catches acronyms someone thought of.
9
+ *
10
+ * Used as a **tier**, not a filter: code on this list -> emit; code absent but
11
+ * a plate cue nearby -> emit; neither -> reject. So a missing code costs
12
+ * recall only where there is also no cue. Some entries are also standards
13
+ * prefixes — DIN is Dinslaken as well as the standards body — which is why the
14
+ * standards guard is kept alongside this one.
15
+ */
16
+ export declare const DE_DISTRICT_CODES: ReadonlySet<string>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * German vehicle registration district codes (Unterscheidungszeichen).
3
+ *
4
+ * The set is closed: codes are fixed by the Fahrzeug-Zulassungsverordnung and
5
+ * maintained by the Kraftfahrt-Bundesamt, changing only when a district is
6
+ * created, merged, or a historic code is reinstated. That makes membership a
7
+ * usable precision signal — unlike the open-ended blocklist of standards
8
+ * prefixes it replaces, which only ever catches acronyms someone thought of.
9
+ *
10
+ * Used as a **tier**, not a filter: code on this list -> emit; code absent but
11
+ * a plate cue nearby -> emit; neither -> reject. So a missing code costs
12
+ * recall only where there is also no cue. Some entries are also standards
13
+ * prefixes — DIN is Dinslaken as well as the standards body — which is why the
14
+ * standards guard is kept alongside this one.
15
+ */
16
+ export const DE_DISTRICT_CODES = new Set([
17
+ "A", "AA", "AB", "ABG", "AC", "AE", "AH", "AIB", "AIC", "AK", "AL", "ALF",
18
+ "ALS", "ALZ", "AM", "AN", "ANA", "ANG", "ANK", "AP", "APD", "AR", "ARN",
19
+ "ART", "AS", "ASD", "ASL", "ASZ", "AT", "AU", "AUR", "AW", "AZ", "AZE",
20
+ "AÖ", "B", "BA", "BAD", "BAR", "BB", "BBG", "BBL", "BC", "BCH", "BD",
21
+ "BE", "BED", "BEI", "BEL", "BER", "BF", "BG", "BGD", "BGL", "BH", "BI",
22
+ "BID", "BIN", "BIR", "BIT", "BIW", "BK", "BKS", "BL", "BLB", "BLK", "BM",
23
+ "BN", "BNA", "BO", "BOG", "BOH", "BOR", "BOT", "BP", "BR", "BRA", "BRB",
24
+ "BRG", "BRI", "BRK", "BRL", "BRV", "BS", "BSB", "BSK", "BT", "BTF", "BU",
25
+ "BUL", "BUR", "BW", "BWL", "BYL", "BZ", "BZA", "BÖ", "BÜD", "BÜS", "BÜZ",
26
+ "C", "CA", "CAS", "CB", "CE", "CHA", "CHL", "CLP", "CLZ", "CO", "COC",
27
+ "COE", "CR", "CT", "CUX", "CW", "D", "DA", "DAH", "DAN", "DAU", "DB",
28
+ "DBR", "DD", "DE", "DEG", "DEL", "DG", "DGF", "DH", "DI", "DIL", "DIN",
29
+ "DIZ", "DKB", "DL", "DLG", "DM", "DN", "DO", "DON", "DS", "DT", "DU",
30
+ "DUD", "DW", "DZ", "DÜW", "E", "EA", "EB", "EBE", "EBN", "EBS", "ECK",
31
+ "ED", "EE", "EF", "EG", "EH", "EHI", "EI", "EIC", "EIH", "EIL", "EIN",
32
+ "EIS", "EL", "EM", "EMD", "EMS", "EN", "ER", "ERB", "ERH", "ERK", "ES",
33
+ "ESA", "ESB", "ESW", "EU", "EUT", "EW", "F", "FAL", "FB", "FD", "FDB",
34
+ "FDS", "FEU", "FF", "FFB", "FG", "FH", "FI", "FKB", "FL", "FLÖ", "FN",
35
+ "FO", "FOR", "FR", "FRG", "FRI", "FRW", "FS", "FT", "FTL", "FW", "FZ",
36
+ "FÜ", "FÜS", "G", "GA", "GAN", "GAP", "GC", "GD", "GDB", "GE", "GEL",
37
+ "GEM", "GEO", "GER", "GF", "GG", "GHA", "GHC", "GI", "GK", "GL", "GLA",
38
+ "GM", "GMN", "GN", "GNT", "GOA", "GOH", "GP", "GR", "GRA", "GRH", "GRI",
39
+ "GRM", "GRS", "GRZ", "GS", "GT", "GTH", "GUB", "GUN", "GV", "GVM", "GW",
40
+ "GZ", "GÖ", "GÜ", "H", "HA", "HAB", "HAL", "HAM", "HAS", "HB", "HBN",
41
+ "HBS", "HC", "HCH", "HD", "HDH", "HDL", "HE", "HEB", "HEF", "HEI", "HEL",
42
+ "HER", "HET", "HF", "HG", "HGN", "HGW", "HH", "HHM", "HI", "HIG", "HIP",
43
+ "HK", "HL", "HM", "HMU", "HN", "HO", "HOG", "HOH", "HOL", "HOM", "HOR",
44
+ "HOT", "HP", "HR", "HRO", "HS", "HSK", "HST", "HU", "HUS", "HV", "HVL",
45
+ "HW", "HWI", "HX", "HY", "HZ", "HÖS", "HÜN", "IGB", "IK", "IL", "ILL",
46
+ "IN", "IS", "IZ", "J", "JB", "JE", "JEV", "JL", "JÜL", "K", "KA", "KAR",
47
+ "KB", "KC", "KE", "KEH", "KEL", "KEM", "KF", "KG", "KH", "KI", "KIB",
48
+ "KK", "KL", "KLE", "KLT", "KLZ", "KM", "KN", "KO", "KOZ", "KR", "KRU",
49
+ "KS", "KT", "KU", "KUS", "KW", "KY", "KYF", "KÖN", "KÖT", "KÜN", "L",
50
+ "LA", "LAN", "LAT", "LAU", "LB", "LBS", "LBZ", "LC", "LD", "LDK", "LDS",
51
+ "LE", "LEO", "LER", "LEV", "LF", "LG", "LH", "LI", "LIB", "LIF", "LIN",
52
+ "LIP", "LK", "LL", "LM", "LN", "LOH", "LOS", "LP", "LR", "LS", "LSA",
53
+ "LSN", "LSZ", "LU", "LUK", "LWL", "LÖ", "LÖB", "LÜD", "LÜN", "M", "MA",
54
+ "MAB", "MAI", "MAK", "MAL", "MAR", "MB", "MC", "MD", "ME", "MED", "MEG",
55
+ "MEI", "MEK", "MEL", "MEP", "MER", "MES", "MET", "MG", "MGH", "MGN", "MH",
56
+ "MHL", "MI", "MIL", "MK", "MKK", "ML", "MM", "MN", "MO", "MOD", "MOL",
57
+ "MON", "MOS", "MQ", "MR", "MS", "MSP", "MST", "MT", "MTK", "MTL", "MVL",
58
+ "MW", "MY", "MYK", "MZ", "MZG", "MÜ", "MÜB", "MÜL", "MÜN", "MÜR", "N",
59
+ "NAB", "NAI", "NAU", "NB", "ND", "NDH", "NE", "NEA", "NEB", "NEC", "NEN",
60
+ "NES", "NEU", "NEW", "NF", "NH", "NI", "NIB", "NK", "NL", "NM", "NMB",
61
+ "NMS", "NOH", "NOL", "NOM", "NOR", "NP", "NR", "NRW", "NRÜ", "NT", "NU",
62
+ "NVP", "NW", "NWM", "NY", "NZ", "NÖ", "OA", "OAL", "OB", "OBB", "OBG",
63
+ "OC", "OCH", "OD", "OE", "OF", "OG", "OH", "OHA", "OHV", "OHZ", "OK",
64
+ "OL", "OLD", "OP", "OPR", "OR", "OS", "OSL", "OTT", "OTW", "OVI", "OVL",
65
+ "OVP", "OZ", "P", "PA", "PAF", "PAN", "PAR", "PB", "PCH", "PE", "PEG",
66
+ "PER", "PF", "PI", "PIR", "PK", "PL", "PLÖ", "PM", "PN", "PR", "PRÜ",
67
+ "PS", "PW", "PZ", "QFT", "QLB", "R", "RA", "RC", "RD", "RDG", "RE", "REG",
68
+ "REH", "REI", "RG", "RH", "RI", "RID", "RIE", "RL", "RM", "RN", "RO",
69
+ "ROD", "ROF", "ROH", "ROK", "ROL", "ROS", "ROT", "ROW", "RPL", "RS",
70
+ "RSL", "RT", "RU", "RV", "RW", "RWL", "RY", "RZ", "RÜD", "RÜG", "S", "SA",
71
+ "SAB", "SAD", "SAL", "SAN", "SAO", "SAW", "SB", "SBG", "SBK", "SC", "SCZ",
72
+ "SDH", "SDL", "SDT", "SE", "SEB", "SEE", "SEF", "SEL", "SF", "SFA", "SFB",
73
+ "SFT", "SG", "SGH", "SH", "SHA", "SHG", "SHK", "SHL", "SHS", "SI", "SIG",
74
+ "SIM", "SK", "SL", "SLE", "SLF", "SLG", "SLK", "SLN", "SLS", "SLZ", "SLÜ",
75
+ "SM", "SMÜ", "SN", "SNH", "SO", "SOB", "SOG", "SOK", "SOL", "SON", "SP",
76
+ "SPB", "SPN", "SPR", "SR", "SRB", "SRO", "ST", "STA", "STB", "STD", "STE",
77
+ "STH", "STL", "STO", "SU", "SUL", "SW", "SWA", "SWZ", "SY", "SZ", "SZB",
78
+ "SÄK", "SÖM", "SÜW", "TBB", "TE", "TF", "TG", "TGO", "THL", "THW", "TIR",
79
+ "TO", "TP", "TR", "TS", "TT", "TUT", "TÖL", "TÖN", "TÜ", "UE", "UEM",
80
+ "UER", "UFF", "UH", "UL", "UM", "UN", "USI", "V", "VAI", "VB", "VEC",
81
+ "VER", "VIB", "VIE", "VIT", "VK", "VL", "VOF", "VOH", "VR", "VS", "W",
82
+ "WA", "WAF", "WAK", "WAM", "WAN", "WAR", "WAT", "WB", "WBS", "WD", "WDA",
83
+ "WE", "WEB", "WEG", "WEI", "WEL", "WEM", "WEN", "WER", "WES", "WF", "WG",
84
+ "WHV", "WI", "WIL", "WIS", "WIT", "WIZ", "WK", "WL", "WLG", "WM", "WMS",
85
+ "WN", "WND", "WO", "WOB", "WOH", "WOL", "WOR", "WOS", "WR", "WRN", "WS",
86
+ "WSF", "WST", "WSW", "WT", "WTL", "WTM", "WUG", "WUM", "WUN", "WUR", "WW",
87
+ "WZ", "WZL", "WÜ", "X", "Y", "Z", "ZE", "ZEL", "ZI", "ZIG", "ZP", "ZR",
88
+ "ZS", "ZW", "ZZ", "ÖHR", "ÜB",
89
+ ]);
@@ -1,5 +1,6 @@
1
1
  import { EntityType } from "../types.js";
2
2
  import { isRegisteredBic } from "./bicRegistry.js";
3
+ import { DE_DISTRICT_CODES } from "./deDistricts.js";
3
4
  const CONTEXT_CHARS = 150;
4
5
  function getContext(text, start, end) {
5
6
  const ctxStart = Math.max(0, start - CONTEXT_CHARS);
@@ -117,6 +118,12 @@ function suppressPhoneDateOverlap(_text, match) {
117
118
  return false;
118
119
  return DATE_PATTERN_FULL.test(match.text.trim());
119
120
  }
121
+ // Standards and classification prefixes that are plate-shaped once a letter and
122
+ // digits follow: "ATC-N06", "ICD-O3". None is a German district code, but the
123
+ // guard is still gated on the absence of a plate cue, so a genuine plate that
124
+ // happens to collide is not lost.
125
+ const STANDARDS_PREFIX = new Set(["ICD", "ISO", "DIN", "IEC", "RFC", "DSM", "ATC", "MDR"]);
126
+ const PLATE_CUE_NEAR = /(?:Kennzeichen|Nummernschild|Kfz|Fahrzeug|amtliche[sn]?\s+Kennz|nummerplaat|plaque\s+d'immatriculation|license\s+plate|number\s+plate)/i;
120
127
  const NOT_CITY_CODES = new Set([
121
128
  "ID", "NR", "NO", "ST", "DR", "MR", "MS", "HR", "FR", "IM", "IN", "OR", "IF", "IS", "IT", "AT", "AD", "AG", "AV",
122
129
  "BE", "DE", "EU", "NL", "LU", "WS", "SS", "IP",
@@ -135,6 +142,13 @@ function suppressPlateInCompound(text, match) {
135
142
  }
136
143
  const matched = match.text.trim();
137
144
  const parts = matched.split(/[\s\-]+/);
145
+ // A standards or classification reference, not a plate — unless a plate cue
146
+ // nearby says otherwise.
147
+ if (parts.length > 0 && STANDARDS_PREFIX.has(parts[0].toUpperCase())) {
148
+ const [b, a] = getContext(text, match.start, match.end);
149
+ if (!PLATE_CUE_NEAR.test(b + a))
150
+ return true;
151
+ }
138
152
  if (parts.length > 0 && NOT_CITY_CODES.has(parts[0])) {
139
153
  const afterChar = match.end < text.length ? text[match.end] : "";
140
154
  const beforeChar = match.start > 0 ? text[match.start - 1] : "";
@@ -157,6 +171,31 @@ function suppressPlateInCompound(text, match) {
157
171
  }
158
172
  return false;
159
173
  }
174
+ /**
175
+ * Reject a German plate whose district code is not a real one.
176
+ *
177
+ * The district-code set is closed, which makes it a whitelist rather than the
178
+ * open-ended blocklist of standards prefixes: "REF-A12", "SYS-B3", "KTO-A1"
179
+ * and every other document reference of that shape fail it without needing to
180
+ * be enumerated. Applied as a tier — an unknown code still emits when a plate
181
+ * cue is nearby, so a missing code costs recall only in the absence of any
182
+ * other evidence.
183
+ */
184
+ function suppressDePlateUnknownDistrict(text, match) {
185
+ if (match.patternDef.entityType !== EntityType.LICENSE_PLATE)
186
+ return false;
187
+ if (match.countryCode !== "DE")
188
+ return false;
189
+ const parts = match.text.trim().split(/[\s\-]+/);
190
+ if (parts.length === 0 || !parts[0])
191
+ return false;
192
+ if (DE_DISTRICT_CODES.has(parts[0].toUpperCase()))
193
+ return false;
194
+ const [before, after] = getContext(text, match.start, match.end);
195
+ if (PLATE_CUE_NEAR.test(before + after))
196
+ return false;
197
+ return true;
198
+ }
160
199
  function suppressNatidAsPassport(text, match) {
161
200
  if (match.patternDef.entityType !== EntityType.NATIONAL_ID)
162
201
  return false;
@@ -426,7 +465,7 @@ const TYPE_SUPPRESSORS = {
426
465
  [EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
427
466
  [EntityType.BIC]: [suppressBicWithoutEvidence],
428
467
  [EntityType.BANK_ACCOUNT]: [suppressReference],
429
- [EntityType.LICENSE_PLATE]: [suppressPlateInCompound],
468
+ [EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict],
430
469
  [EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
431
470
  };
432
471
  export function shouldSuppress(text, match) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euredact",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "European PII redaction SDK -- fast, zero-dependency, rule-based detection and redaction of personal data across 31 European countries",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -53,12 +53,13 @@
53
53
  "build:cjs": "tsc -p tsconfig.cjs.json",
54
54
  "postbuild:cjs": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
55
55
  "build": "rm -rf dist && npm run build:esm && npm run build:cjs",
56
- "test": "npm run test:spec",
56
+ "test": "npm run test:spec && npm run test:conformance",
57
57
  "benchmark": "node --import tsx src/__tests__/benchmark.ts",
58
58
  "prepublishOnly": "npm run build",
59
59
  "test:spec": "node --import tsx src/__tests__/spec.ts",
60
60
  "test:eval": "node --import tsx src/__tests__/run.ts",
61
- "test:eval:full": "node --max-old-space-size=8192 --import tsx src/__tests__/evalFull.ts"
61
+ "test:eval:full": "node --max-old-space-size=8192 --import tsx src/__tests__/evalFull.ts",
62
+ "test:conformance": "node --import tsx src/__tests__/conformance.ts"
62
63
  },
63
64
  "devDependencies": {
64
65
  "typescript": "^5.4.0",