euredact 0.5.1 → 0.7.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.
Files changed (57) hide show
  1. package/NOTICE +11 -0
  2. package/README.md +658 -45
  3. package/dist/cjs/cache.js +2 -8
  4. package/dist/cjs/cloud/account.js +180 -0
  5. package/dist/cjs/cloud/batches.js +469 -0
  6. package/dist/cjs/cloud/client.js +131 -18
  7. package/dist/cjs/cloud/config.js +28 -2
  8. package/dist/cjs/cloud/errors.js +39 -2
  9. package/dist/cjs/cloud/index.js +16 -1
  10. package/dist/cjs/index.js +9 -1
  11. package/dist/cjs/platform.browser.js +98 -0
  12. package/dist/cjs/platform.js +140 -0
  13. package/dist/cjs/rules/bicGleif.js +57 -0
  14. package/dist/cjs/rules/bicRegistry.js +37 -11
  15. package/dist/cjs/rules/countries/index.js +138 -48
  16. package/dist/cjs/rules/cues.js +29 -9
  17. package/dist/cjs/rules/engine.js +14 -6
  18. package/dist/cjs/rules/suppressors.js +418 -23
  19. package/dist/cjs/rules/validators.js +103 -0
  20. package/dist/cjs/sdk.js +150 -44
  21. package/dist/cjs/types.js +4 -1
  22. package/dist/esm/cache.js +2 -8
  23. package/dist/esm/cloud/account.d.ts +155 -0
  24. package/dist/esm/cloud/account.js +175 -0
  25. package/dist/esm/cloud/batches.d.ts +166 -0
  26. package/dist/esm/cloud/batches.js +464 -0
  27. package/dist/esm/cloud/client.d.ts +90 -5
  28. package/dist/esm/cloud/client.js +126 -26
  29. package/dist/esm/cloud/config.d.ts +6 -0
  30. package/dist/esm/cloud/config.js +27 -2
  31. package/dist/esm/cloud/errors.d.ts +26 -1
  32. package/dist/esm/cloud/errors.js +35 -1
  33. package/dist/esm/cloud/index.d.ts +4 -2
  34. package/dist/esm/cloud/index.js +4 -2
  35. package/dist/esm/index.d.ts +2 -2
  36. package/dist/esm/index.js +1 -1
  37. package/dist/esm/platform.browser.d.ts +27 -0
  38. package/dist/esm/platform.browser.js +92 -0
  39. package/dist/esm/platform.d.ts +20 -0
  40. package/dist/esm/platform.js +101 -0
  41. package/dist/esm/rules/bicGleif.d.ts +4 -0
  42. package/dist/esm/rules/bicGleif.js +53 -0
  43. package/dist/esm/rules/bicRegistry.d.ts +25 -11
  44. package/dist/esm/rules/bicRegistry.js +36 -11
  45. package/dist/esm/rules/countries/index.js +138 -48
  46. package/dist/esm/rules/cues.d.ts +1 -1
  47. package/dist/esm/rules/cues.js +29 -9
  48. package/dist/esm/rules/engine.js +14 -6
  49. package/dist/esm/rules/suppressors.d.ts +1 -0
  50. package/dist/esm/rules/suppressors.js +417 -22
  51. package/dist/esm/rules/validators.d.ts +14 -0
  52. package/dist/esm/rules/validators.js +100 -0
  53. package/dist/esm/sdk.d.ts +63 -19
  54. package/dist/esm/sdk.js +148 -44
  55. package/dist/esm/types.d.ts +58 -2
  56. package/dist/esm/types.js +4 -1
  57. package/package.json +12 -4
@@ -242,12 +242,20 @@ export class RuleEngine {
242
242
  if (this.matcher.validate(m)) {
243
243
  validated.push({ match: m, hasValidValidator: m.patternDef.validator !== null });
244
244
  }
245
- else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
246
- failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
247
- etype: String(m.patternDef.entityType) });
248
- if (RESCUE_TARGETS.has(String(m.patternDef.entityType))
249
- && cuedType(text, m.start) === m.patternDef.entityType)
245
+ else if (m.patternDef.validator !== null) {
246
+ const cued = RESCUE_TARGETS.has(String(m.patternDef.entityType))
247
+ && cuedType(text, m.start) === m.patternDef.entityType;
248
+ if (!m.patternDef.requiresContext) {
249
+ failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
250
+ etype: String(m.patternDef.entityType) });
251
+ if (cued)
252
+ rescued.push(m);
253
+ }
254
+ else if (cued) {
255
+ // A label-gated pattern is rescued too, but only by a cue touching the
256
+ // value; it casts no demotion zone (rules-engine#75).
250
257
  rescued.push(m);
258
+ }
251
259
  }
252
260
  }
253
261
  // Evidence pass: work out which countries this document belongs to, from
@@ -457,7 +465,7 @@ export class RuleEngine {
457
465
  // the value is filed as a Cypriot identity number. Dropping it also clears
458
466
  // outOfScope, which would otherwise claim the detection belongs to a
459
467
  // country the caller did not declare while naming no country at all.
460
- const retyped = retypedBy(text, match.start, entityType, countryScores.get(match.countryCode) ?? 0);
468
+ const retyped = retypedBy(text, match.start, entityType, countryScores.get(match.countryCode) ?? 0, match.countryCode);
461
469
  if (retyped !== null) {
462
470
  entityType = retyped;
463
471
  country = null;
@@ -6,6 +6,7 @@ export interface RawMatch {
6
6
  patternDef: PatternDef;
7
7
  countryCode: string;
8
8
  }
9
+ export declare const MONTH_NAMES: string;
9
10
  /**
10
11
  * Per-document scratch space for suppressors that would otherwise re-derive the
11
12
  * same whole-document facts for every candidate.
@@ -1,5 +1,5 @@
1
1
  import { EntityType } from "../types.js";
2
- import { isRegisteredBic } from "./bicRegistry.js";
2
+ import { isKnownInstitution, isRegisteredBic } from "./bicRegistry.js";
3
3
  import { DE_DISTRICT_CODES } from "./deDistricts.js";
4
4
  const CONTEXT_CHARS = 150;
5
5
  function getContext(text, start, end) {
@@ -21,10 +21,11 @@ const CURRENCY_WORD = "EUR|USD|GBP|CHF|ISK|SEK|NOK|DKK|PLN|CZK|HUF|RON|BGN|HRK|"
21
21
  const CURRENCY_AFTER = new RegExp(`^\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
22
22
  const CURRENCY_COMMA_AFTER = new RegExp(`^[.,]\\d{1,2}\\s*(?:(?:${CURRENCY_SYMBOL})|(?:${CURRENCY_WORD})${_UWB})`, "i");
23
23
  const CURRENCY_BEFORE = new RegExp(`(?:(?:${CURRENCY_SYMBOL})|\\b(?:${CURRENCY_WORD}))\\s*$`, "i");
24
- const AMOUNT_LABEL_BEFORE = /\b(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð|Importe|Importo|Valore|Wartość|Kwota|Částka|Összeg|Sum|Beloop|Prix|Preis|Price|Loyer|Miete|Huur|Rent|Betrag)\s*(?:\w+\s*)?:?\s*$/i;
24
+ const AMOUNT_LABEL_BEFORE = /\b(?:Montant|Beløb|Summa|Summe|Bedrag|Amount|Total|TTC|inkl|Upphæð|Importe|Importo|Valore|Wartość|Kwota|Částka|Összeg|Sum|Beloop|Prix|Preis|Price|Loyer|Miete|Huur|Rent|Betrag)(?:\s+\w+)?\s*:?\s*$/i; // second word separate: not "Rent"+"enversicherungsnummer" (rules-engine#111)
25
25
  // Use Unicode-aware word boundary via \p{L} negative lookahead instead of \b
26
26
  // because JS \b is ASCII-only and fails on Unicode letters (e.g. "München" → \bm\b matches M before ü)
27
- const UNIT_AFTER = /^\s*(?:kg|km|cm|mm|m[²³]|ml|mg|GB|MB|KB|TB|%|jaar|maanden|weken|dagen|uur|minuten|seconden|stuks|pcs|pieces|ans|mois|semaines|jours|heures|Jahre|Monate|Wochen|Tage|Stunden)(?![a-zA-Z\u00C0-\u024F\u0400-\u04FF])/i;
27
+ // Horizontal space only: a unit is on the number's own line (rules-engine#51).
28
+ const UNIT_AFTER = /^[ \t]*(?:kg|km|cm|mm|m[²³]|ml|mg|GB|MB|KB|TB|%|jaar|maanden|weken|dagen|uur|minuten|seconden|stuks|pcs|pieces|ans|mois|semaines|jours|heures|Jahre|Monate|Wochen|Tage|Stunden)(?![a-zA-Z\u00C0-\u024F\u0400-\u04FF])/i;
28
29
  // Same missing-boundary flaw: "ref" matched the tail of "kortref".
29
30
  const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|factuurnummer|invoice\s*(?:nr|number|no)?|bestelnummer|order\s*(?:nr|number|no)?|kenmerk|ordernummer|Aktenzeichen|numéro\s*de\s*(?:dossier|facture|commande)|bestellnummer|Rechnungsnummer|artikelnr|article\s*no|contract\s*(?:nr|number|no)?|pagina|page|Seite|blz\.?|Facture\s*n[°o]?|Faktura\s*n[°or]\.?|Lasku\s*n[°or]o?\.?|Rechnung\s*(?:Nr|n[°o])?|faktura\s*(?:nr|n[°o])?|bestilling\s*(?:nr|n[°o])?|bestelling\s*n[°or]\.?|Reikningur\s*nr|ticket|incident\s*(?:report|nr|no)?|case\s*(?:nr|no|id)?|zaaknummer|meldingsnummer|Vorgangsnummer|Vorgang|Störungsmeldung|saksnummer|ärendenummer|sagsnummer|asianumero|Factuur\s*n[ro]?\.?|Nota\s*n[ro]?\.?)\s*[:.#]?\s*$/i;
30
31
  // "#" before a number marks a reference in every language in scope — a ticket,
@@ -32,9 +33,19 @@ const REFERENCE_BEFORE = /\b(?:dossier|ref\.?|referentie|reference|référence|f
32
33
  const HASH_BEFORE = /#\s*$/;
33
34
  // A short uppercase tag hyphenated to the number: "IR-43433", "INC-2024".
34
35
  const REF_PREFIX_BEFORE = /(?:^|[\s([])[A-Z]{2,5}-$/;
36
+ // The cross-border address form, "CH-8004 Zürich": a country code hyphenated to
37
+ // a postal code, told apart from a reference tag by position -- it opens an
38
+ // address line and a place name follows (rules-engine#58).
39
+ const COUNTRY_PREFIX_OPENS_LINE = /(?:^|\n|,)[ \t]*([A-Z]{2})-$/;
40
+ const POSTAL_PREFIX_COUNTRIES = new Set([
41
+ "AT", "BE", "BG", "CH", "CY", "CZ", "DE", "DK", "EE", "EL", "ES", "FI",
42
+ "FR", "GB", "GR", "HR", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV",
43
+ "MT", "NL", "NO", "PL", "PT", "RO", "SE", "SI", "SK", "UK",
44
+ ]);
35
45
  const LEGAL_BEFORE = /(?:Art(?:ikel|icle|\.)|§|Artikel|Section|Sectie|Afdeling|paragraaf|Absatz|alinéa|punt|point|Punkt|lid)\s*$/i;
36
- const MATH_BEFORE = /[=+\-×÷*/]\s*$/;
37
- const MATH_AFTER = /^\s*[=+\-×÷*/]/;
46
+ // An operator on the same line only: a bullet or "---" on the next line is not a minus sign (rules-engine#91).
47
+ const MATH_BEFORE = /[=+\-×÷*/][^\S\r\n]*$/;
48
+ const MATH_AFTER = /^[^\S\r\n]*[=+\-×÷*/]/;
38
49
  // "A-1010", "B-2000", "L-1234", "CH-8000", "D-10115": a country prefix on a
39
50
  // postal code, not a minus sign.
40
51
  const COUNTRY_PREFIX_HYPHEN = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}-\s*$/;
@@ -60,10 +71,11 @@ const NUMERIC_TYPES = new Set([EntityType.PHONE, EntityType.NATIONAL_ID, EntityT
60
71
  // Month names in the languages this engine covers. Long, but it is data: the
61
72
  // alternative is a proximity heuristic, and proximity is what caused the defect
62
73
  // this guards against. Kept in sync with the Python SDK's suppressors.py.
63
- const MONTH_NAMES = "january|february|march|april|may|june|july|august|september|october|november|december" +
74
+ export const MONTH_NAMES = "january|february|march|april|may|june|july|august|september|october|november|december" +
64
75
  "|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec" +
65
76
  "|januar|jänner|februar|märz|mai|juni|juli|oktober|dezember|dez|okt|mrz" +
66
77
  "|janvier|février|mars|avril|juin|juillet|août|aout|septembre|octobre|novembre|décembre|decembre" +
78
+ "|janv|févr|fevr|avr|juil|déc" +
67
79
  "|januari|februari|maart|mei|augustus|mrt" +
68
80
  "|gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|dicembre" +
69
81
  "|enero|febrero|abril|mayo|junio|julio|septiembre|octubre|noviembre|diciembre" +
@@ -285,7 +297,19 @@ function suppressReference(text, match) {
285
297
  if (REFERENCE_BEFORE.test(before))
286
298
  return true;
287
299
  const adjacent = text.slice(Math.max(0, match.start - 8), match.start);
288
- return HASH_BEFORE.test(adjacent) || REF_PREFIX_BEFORE.test(adjacent);
300
+ if (HASH_BEFORE.test(adjacent))
301
+ return true;
302
+ if (!REF_PREFIX_BEFORE.test(adjacent))
303
+ return false;
304
+ return !(match.patternDef.entityType === EntityType.POSTAL_CODE &&
305
+ isCountryPrefixedAddress(text, match.start, match.end));
306
+ }
307
+ /** "CH-8004 Zürich" on an address line, not the reference "IT-20431". */
308
+ function isCountryPrefixedAddress(text, start, end) {
309
+ const found = COUNTRY_PREFIX_OPENS_LINE.exec(text.slice(Math.max(0, start - 12), start));
310
+ return (found !== null &&
311
+ POSTAL_PREFIX_COUNTRIES.has(found[1]) &&
312
+ CITY_AFTER.test(text.slice(end, end + 24)));
289
313
  }
290
314
  function suppressLegal(text, match) {
291
315
  const applicable = LEGAL_TYPES;
@@ -309,14 +333,105 @@ function suppressSequential(_text, match) {
309
333
  const clean = match.text.replace(/[\s.\-]/g, "");
310
334
  return SEQUENTIAL_PATTERNS.test(clean);
311
335
  }
336
+ // ── The engine's own output ───────────────────────────────────────────────
337
+ //
338
+ // Redaction has to be idempotent: running it over a document it already
339
+ // produced must leave the placeholders it wrote alone. It did not.
340
+ // `[POSTAL_CODE]` is thirteen characters of mixed case with an underscore, so
341
+ // the entropy-based SECRET rule read it as a credential and a second pass
342
+ // replaced it with `[SECRET]` (rules-engine#33).
343
+ //
344
+ // All three emitted forms are guarded, because all three come back as input in
345
+ // a re-processing pipeline: `[TYPE]`, `TYPE_1` (referentialIntegrity) and
346
+ // `TYPE_K7Q2` (tokenize -- where a false detection breaks restore()).
347
+ //
348
+ // Only real entity-type names count. Guarding any bracketed upper-case token
349
+ // would be simpler and wrong: `[AKIAIOSFODNN7EXAMPLE]` is one, and also a live
350
+ // AWS key. A placeholder for a *custom* pattern is therefore not covered.
351
+ // The closing bracket is optional: another pattern's span need not agree with
352
+ // this one about where the marker ends. The assigned-secret rule stops before
353
+ // sentence punctuation (rules-engine#35), so it claims "[POSTAL_CODE" without
354
+ // the "]". The opening bracket is still required.
355
+ const PLACEHOLDER = /^(?:\[([A-Z][A-Z0-9_]*)\]?|([A-Z][A-Z0-9_]*?)_(?:\d+|[A-HJ-NP-Z2-9]{4}))$/;
356
+ const KNOWN_TYPE_NAMES = new Set(Object.values(EntityType));
357
+ function suppressRedactionPlaceholder(_text, match) {
358
+ const found = PLACEHOLDER.exec(match.text);
359
+ if (found === null)
360
+ return false;
361
+ const name = found[1] ?? found[2];
362
+ return KNOWN_TYPE_NAMES.has(name);
363
+ }
312
364
  const RECENT_YEAR = /^(?:19[5-9]\d|20[0-3]\d)$/;
365
+ // Any year a written date can carry: the date-construction test applies to all of them (rules-engine#111).
366
+ const DATE_YEAR = /^(?:1[89]\d\d|20\d\d)$/;
313
367
  const POSTAL_CONTEXT_NEAR = /(?:postcode|postal|code\s*postal|PLZ|Postleitzahl|postnummer|postinumero|póstnúmer|zip|straat|straße|strasse|rue\s|via\s|calle\s|rua\s|ulica|utca|street|avenue|laan\s|weg\s|plein|adres|adresse|address|woonplaats|wonende|woonachtig|gevestigd|domicili|demeurant|résidant|residant|bosatt|bopæl|wohnhaft|ansässig|stad\b|ville\b|city\b|Stadt|città|ciudad|cidade|miasto|město|város)/i;
314
368
  const DATE_KEYWORD_NEAR = /(?:født|fødselsdato|fødsel|Fødselsdato|född|födelsedatum|födelsedag|syntynyt|syntymäaika|fæddur|fæðingardagur|geboren|geboortedatum|Geburtsdatum|nascido|nacido|data di nascita|nato il|nata il|Tiltr[æa]delsesdato|Tiltredelsesdato|datum|date\b|Datum|jaar|year|année|Jahr|since|sinds|depuis|seit|\d{2}\.\d{2}\.|(?:januar|februar|marts|april|maj|juni|juli|august|september|oktober|november|december|januari|februari|mars|mei|juin|juillet|août))/i;
369
+ // A sentence boundary: a full stop, question or exclamation mark followed by
370
+ // space, or a line break. Written so it cannot fire inside "11.03.1970", where
371
+ // the periods are not followed by whitespace -- that date is one of the false
372
+ // positives this gate has to reject.
373
+ const SENTENCE_BREAK = /[.!?][ \t\n]|\n/;
374
+ const SENTENCE_BREAK_G = /[.!?][ \t\n]|\n/g;
375
+ // The value's own sentence, bounded either side.
376
+ function sentenceAround(text, start, end, reach = 160) {
377
+ let before = text.slice(Math.max(0, start - reach), start);
378
+ let after = text.slice(end, end + reach);
379
+ let lastEnd = -1;
380
+ SENTENCE_BREAK_G.lastIndex = 0;
381
+ for (let m = SENTENCE_BREAK_G.exec(before); m !== null; m = SENTENCE_BREAK_G.exec(before)) {
382
+ lastEnd = m.index + m[0].length;
383
+ }
384
+ if (lastEnd >= 0)
385
+ before = before.slice(lastEnd);
386
+ const found = SENTENCE_BREAK.exec(after);
387
+ if (found !== null)
388
+ after = after.slice(0, found.index);
389
+ return before + after;
390
+ }
391
+ // A postal label whose only distance from the value is punctuation:
392
+ // "PLZ: 2011", "code postal : 2011", "Woonplaats: 1950".
393
+ const POSTAL_LABEL_TOUCHING = new RegExp(`(?:${POSTAL_CONTEXT_NEAR.source})[\\s:.\\-]{0,4}$`, "i");
394
+ // A capitalised place name after the code: "2000 Antwerpen". A following label
395
+ // ("1970 Fødselsnummer:") is excluded by the colon.
396
+ // `\p{L}\p{N}_` rather than `\w`: JS `\w` is ASCII-only, so "8004 Zürich" failed
397
+ // at the ü here while Python's Unicode `\w` accepted it (rules-engine#58).
398
+ const CITY_AFTER = /^[ \t](?![\p{L}\p{N}_]+\s*:)[A-ZÀ-ÞŁŠŽ][\p{L}\p{N}_\-']{2,}/u;
399
+ // Every position is local to the value or inside its own sentence. Proximity to
400
+ // an address *word* anywhere in a 300-character window is what the old rescue
401
+ // asked, and that is a neighbourhood rather than a position (rules-engine#32).
402
+ /**
403
+ * The comma of an address line: the word before it is a place name or a number.
404
+ * "myocardial infarction, 2016" is a year in a list (rules-engine#111).
405
+ */
406
+ const ADDRESS_COMMA = /(?<!\S)(?:[A-ZÀ-Þ\d][^\s,]*|[^\s,]*\]),[ \t]*$/;
407
+ /**
408
+ * A redaction placeholder: "[POSTAL_CODE]" holds the words the address test
409
+ * looks for, so on masked text it made every year in its sentence a postcode
410
+ * (rules-engine#111). A masked value is no address word.
411
+ */
412
+ const MASKED_VALUE = /\[[A-Z][A-Z_]*\]/g;
413
+ function yearSitsInAddressStructure(text, start, end) {
414
+ const before = text.slice(Math.max(0, start - 28), start);
415
+ // "Amsterdam, 2026" -- the comma of an address line.
416
+ if (ADDRESS_COMMA.test(before))
417
+ return true;
418
+ // "PLZ: 2011" -- a postal label with nothing but punctuation between.
419
+ if (POSTAL_LABEL_TOUCHING.test(before))
420
+ return true;
421
+ // A capitalised place name immediately after.
422
+ if (CITY_AFTER.test(text.slice(end, end + 24)))
423
+ return true;
424
+ // An address word in the value's own sentence. "Te huur: Lange Nieuwstraat
425
+ // 12, rustige ligging in 2018, vlakbij openbaar vervoer." is an Antwerp
426
+ // postal code in prose; the false positives put the year and the address in
427
+ // different sentences.
428
+ return POSTAL_CONTEXT_NEAR.test(sentenceAround(text, start, end).replace(MASKED_VALUE, " "));
429
+ }
315
430
  function suppressYearAsPostal(text, match) {
316
431
  if (match.patternDef.entityType !== EntityType.POSTAL_CODE)
317
432
  return false;
318
433
  const clean = match.text.trim();
319
- if (!RECENT_YEAR.test(clean))
434
+ if (!DATE_YEAR.test(clean))
320
435
  return false;
321
436
  // A date construction *touching* the candidate settles it, whatever the rest
322
437
  // of the document says. Without this, the postal-context test below rescued
@@ -328,14 +443,14 @@ function suppressYearAsPostal(text, match) {
328
443
  return true;
329
444
  if (DATE_AFTER.test(text.slice(match.end, match.end + 8)))
330
445
  return true;
446
+ if (!RECENT_YEAR.test(clean))
447
+ return false;
331
448
  const [before, after] = getContext(text, match.start, match.end);
332
449
  const context = before + after;
333
- // Keep as postal code if postal/address context nearby
334
- if (POSTAL_CONTEXT_NEAR.test(context))
335
- return false;
336
- // Keep if preceded by comma+space (address pattern: "Amsterdam, 2026")
337
- const immediateBefore = text.slice(Math.max(0, match.start - 3), match.start);
338
- if (/,\s*$/.test(immediateBefore))
450
+ // Keep as postal code only when the value sits in address *structure* --
451
+ // see yearSitsInAddressStructure for why a 300-character window of address
452
+ // words was the defect rather than the keyword list.
453
+ if (yearSitsInAddressStructure(text, match.start, match.end))
339
454
  return false;
340
455
  // Suppress if date keyword nearby (birth date, employment date, etc.)
341
456
  if (DATE_KEYWORD_NEAR.test(context))
@@ -379,12 +494,26 @@ const TOWN_AFTER = /^\s*[A-ZÀ-ÞĀ-Ž]/;
379
494
  * between the marker and the number, so allow exactly one.
380
495
  */
381
496
  const EXTENSION_BEFORE = /(?<![A-Za-z0-9_])(?:toest(?:el)?|ext|extension|poste|durchwahl|doorkiesnummer|tst|nebenstelle)\.?\s*(?:[a-zà-ÿ]{2,10}\s*)?[:.]?\s*$/i;
497
+ /**
498
+ * What else a bare digit run after these is (rules-engine#111): a head count
499
+ * ("Employees: 214"), a standard ("ISO/IEC 27001"), a product model with an
500
+ * inner capital ("Dell OptiPlex 7090"). Not the digits after "[PHONE]": the
501
+ * rest of a number an earlier pass cut short, safer masked as a postcode.
502
+ */
503
+ const COUNT_LABEL_BEFORE = /(?<![A-Za-z0-9_])(?:employees|headcount|staff|mitarbeiter(?:innen)?|beschäftigte|werknemers|medewerkers|employés|salariés|effectifs?|dipendenti|empleados|pracowników|zaměstnanců)\s*[:=]?\s*$/i;
504
+ const NOT_POSTAL_BEFORE = /(?<![A-Za-z0-9_])(?:(?:ISO(?:\/IEC)?|IEC|EN|DIN(?:\s+EN)?|NEN(?:-EN)?|NF\s+EN|BS(?:\s+EN)?|PN-EN|ÖNORM)[\s\-]*|[A-Za-z]*[a-z][A-Z][A-Za-z]*\s+)$/;
505
+ /** A thousands group of an amount: the "750" of "£28,750,000". */
506
+ const GROUP_BEFORE = /\d[,.]$/;
507
+ const GROUP_AFTER = /^[,.]\d{3}(?!\d)/;
382
508
  /** Suppress a postal code the word in front of it rules out. */
383
509
  function suppressPostalAfterDisqualifyingWord(text, match) {
384
510
  if (match.patternDef.entityType !== EntityType.POSTAL_CODE)
385
511
  return false;
386
512
  const before = text.slice(Math.max(0, match.start - 24), match.start);
387
- if (EXTENSION_BEFORE.test(before))
513
+ if (EXTENSION_BEFORE.test(before) || COUNT_LABEL_BEFORE.test(before)
514
+ || NOT_POSTAL_BEFORE.test(before))
515
+ return true;
516
+ if (GROUP_BEFORE.test(before) && GROUP_AFTER.test(text.slice(match.end, match.end + 5)))
388
517
  return true;
389
518
  return RECENT_YEAR.test(match.text.trim())
390
519
  && (YEAR_WORD_BEFORE.test(before) || YEAR_VERB_BEFORE.test(before))
@@ -395,10 +524,18 @@ function suppressPhoneServiceNumber(_text, match) {
395
524
  return false;
396
525
  return SERVICE_NUMBER.test(match.text);
397
526
  }
398
- function suppressPhoneDateOverlap(_text, match) {
527
+ // A date and the hour of a time, "07.10.2026 08" out of "07.10.2026 08:42":
528
+ // the French phone pattern took it as five pairs (rules-engine#101). Only when
529
+ // the minutes follow, so the stamp is certain.
530
+ const DATE_THEN_HOUR = /^\d{2}([-/.])\d{2}\1\d{4}\s+\d{1,2}$/;
531
+ const MINUTES_AFTER = /^:\d{2}(?!\d)/;
532
+ function suppressPhoneDateOverlap(text, match) {
399
533
  if (match.patternDef.entityType !== EntityType.PHONE)
400
534
  return false;
401
- return DATE_PATTERN_FULL.test(match.text.trim());
535
+ const candidate = match.text.trim();
536
+ if (DATE_PATTERN_FULL.test(candidate))
537
+ return true;
538
+ return DATE_THEN_HOUR.test(candidate) && MINUTES_AFTER.test(text.slice(match.end, match.end + 3));
402
539
  }
403
540
  // Standards and classification prefixes that are plate-shaped once a letter and
404
541
  // digits follow: "ATC-N06", "ICD-O3". None is a German district code, but the
@@ -411,6 +548,16 @@ const NOT_CITY_CODES = new Set([
411
548
  "BE", "DE", "EU", "NL", "LU", "WS", "SS", "IP",
412
549
  ]);
413
550
  const CURRENCY_PLATE = /^(?:EUR|USD|GBP|CHF|SEK|NOK|DKK|ISK|CZK|PLN|HUF|RON|BGN|HRK)\s/i;
551
+ const ALNUM = /[\p{L}\p{N}]/u;
552
+ /** Characters that join a token to more of an identifier (rules-engine#50, #81). */
553
+ const ID_CONNECTORS = "-/._+";
554
+ /** A reference marker directly before the token: "#FR-S2", "№ …", "n° …". */
555
+ const REFERENCE_MARK_BEFORE = /(?:#|№|(?<![A-Za-z])[nN][°º])\s?$/;
556
+ /** A connector glues [start, end) to a letter or digit outside it. */
557
+ function joinedToMore(text, start, end) {
558
+ return ((end + 1 < text.length && ID_CONNECTORS.includes(text[end]) && ALNUM.test(text[end + 1])) ||
559
+ (start >= 2 && ID_CONNECTORS.includes(text[start - 1]) && ALNUM.test(text[start - 2])));
560
+ }
414
561
  function suppressPlateInCompound(text, match) {
415
562
  if (match.patternDef.entityType !== EntityType.LICENSE_PLATE)
416
563
  return false;
@@ -422,6 +569,18 @@ function suppressPlateInCompound(text, match) {
422
569
  if (/[A-Za-zÄÖÜäöüß]{2,}-$/.test(before))
423
570
  return true;
424
571
  }
572
+ // A plate is a token of its own: joined by a connector to more of an
573
+ // identifier ("TF-284-KL-00874", "FR-S2/2026") it is a segment of a reference
574
+ // (rules-engine#50, #81). A spaced dash or slash does not join.
575
+ if (joinedToMore(text, match.start, match.end))
576
+ return true;
577
+ // After a reference marker ("Ref #FR-S2") it is a reference, unless a plate
578
+ // cue says otherwise (rules-engine#81).
579
+ if (REFERENCE_MARK_BEFORE.test(text.slice(Math.max(0, match.start - 4), match.start))) {
580
+ const [b, a] = getContext(text, match.start, match.end);
581
+ if (!PLATE_CUE_NEAR.test(b + a))
582
+ return true;
583
+ }
425
584
  const matched = match.text.trim();
426
585
  const parts = matched.split(/[\s\-]+/);
427
586
  // A standards or classification reference, not a plate — unless a plate cue
@@ -506,6 +665,35 @@ function suppressPostalInsideIban(text, match) {
506
665
  }
507
666
  return false;
508
667
  }
668
+ // An IBAN-shaped run: two letters, two check digits, then grouped
669
+ // alphanumerics. Deliberately not checksum-gated -- the point is to describe the
670
+ // run a human wrote as one account number, including the ones that fail their
671
+ // checksum, because those are exactly the ones no structured detector claims.
672
+ const ACCOUNT_RUN = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{2,4}){2,8}\b/g;
673
+ // "Compte: FR76 3000 4008 0300 0109 5374" fails the IBAN checksum, so no
674
+ // BANK_ACCOUNT candidate survives and the Danish eight-digit phone pattern took
675
+ // "3000 4008" out of the middle of it, leaving the rest in the clear
676
+ // (rules-engine#30). POSTAL_CODE has carried its own version of this guard
677
+ // since 0.3.3, in suppressPostalInsideIban.
678
+ function suppressPhoneInsideAccountRun(text, match) {
679
+ if (match.patternDef.entityType !== EntityType.PHONE)
680
+ return false;
681
+ const lineStart = text.lastIndexOf("\n", match.start) + 1;
682
+ let lineEnd = text.indexOf("\n", match.end);
683
+ if (lineEnd < 0)
684
+ lineEnd = text.length;
685
+ const line = text.slice(lineStart, lineEnd);
686
+ ACCOUNT_RUN.lastIndex = 0;
687
+ for (let m = ACCOUNT_RUN.exec(line); m !== null; m = ACCOUNT_RUN.exec(line)) {
688
+ const runStart = lineStart + m.index;
689
+ const runEnd = runStart + m[0].length;
690
+ if (runStart <= match.start && match.end <= runEnd
691
+ && runEnd - runStart > match.end - match.start) {
692
+ return true;
693
+ }
694
+ }
695
+ return false;
696
+ }
509
697
  function suppressPostalAsHouseNumber(text, match) {
510
698
  if (match.patternDef.entityType !== EntityType.POSTAL_CODE)
511
699
  return false;
@@ -722,10 +910,34 @@ function suppressBicWithoutEvidence(text, match, scratch) {
722
910
  // The token itself is blanked out so it cannot vouch for itself.
723
911
  const unit = structuralUnit(text, match.start, match.end).split(token).join(" ".repeat(token.length));
724
912
  if (BIC_KEYWORD.test(unit) || IBAN_SHAPE.test(unit) || BANK_BLOCK.test(unit)) {
913
+ if (bicShapedWord(text, match.start, match.end, token))
914
+ return true;
725
915
  return occursAsLowercaseWord(text, token, scratch);
726
916
  }
727
917
  return true;
728
918
  }
919
+ /** A personal title right before the token: "Dr. HOFFMANN", "Mme JANSSENS". */
920
+ const TITLE_BEFORE = /(?<![A-Za-z])(?:dr|prof|mr|mrs|ms|mme|mlle|m|herr|frau|dhr|mevr|sig|sra?)\.?\s+$/i;
921
+ const LETTER = /\p{L}/u;
922
+ /** A BIC/SWIFT label touching the token: "BIC: ", "SWIFT-Code:\n", "Code SWIFT : ", "BIC Bank A: ". */
923
+ const BIC_LABEL_BEFORE = /(?<![A-Za-z])(?:bic|swift)(?:[\s/\-]*(?:code|swift|bic))?(?:\s+bank\s+\w{1,3})?[\s:.\-()]*$/i;
924
+ /**
925
+ * A registry miss sitting where only a word or a name can (rules-engine#57):
926
+ * joined to a word by a hyphen ("NGUYEN-HOFFMANN"), right after a personal
927
+ * title ("Dr. HOFFMANN"), or letters only (eight, or eleven without an `XXX`
928
+ * branch: "BETALING", "MAANDELIJKS") that the GLEIF mapping does not know and
929
+ * no BIC/SWIFT label touches.
930
+ */
931
+ function bicShapedWord(text, start, end, token) {
932
+ if ((start >= 2 && text[start - 1] === "-" && LETTER.test(text[start - 2])) ||
933
+ (text[end] === "-" && end + 1 < text.length && LETTER.test(text[end + 1])))
934
+ return true;
935
+ if (TITLE_BEFORE.test(text.slice(Math.max(0, start - 8), start)))
936
+ return true;
937
+ return /^[A-Za-z]+$/.test(token) && !(token.length === 11 && token.endsWith("XXX"))
938
+ && !isKnownInstitution(token)
939
+ && !BIC_LABEL_BEFORE.test(text.slice(Math.max(0, start - 30), start));
940
+ }
729
941
  // ── Postal code: digits belonging to a longer identifier ────────────────
730
942
  // An identifier label immediately before the digits. A postal code is never
731
943
  // introduced this way; an SVNr, policy number or service number always is.
@@ -733,12 +945,26 @@ function suppressBicWithoutEvidence(text, match, scratch) {
733
945
  // Repeated per alternative, the engine retried the same unbounded prefix five
734
946
  // times per starting position — the most expensive single regex in the engine
735
947
  // at 197 ms per 1 MB document. Same language, 8x faster on the worst case.
948
+ // An identifier cue that is really a postal label -- see the use below.
949
+ const POSTAL_LABEL_CUE = /^post/i;
736
950
  const ID_CUE_BEFORE = /(?:[\w\-]*(?:Nr|N[°ºo]|Nummer|Numero|Numéro)|No|number|num|Kennzahl|Aktenzeichen|Az|e-?card|Polizze|Police|Policen)\.?\s*:?\s*$/i;
737
951
  // An international dialling prefix earlier on the same line, with nothing but
738
952
  // number punctuation in between: these digits belong to the phone detector.
739
953
  const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
740
954
  // A country prefix on a postal code — "A-1010 Wien", "B-2000", "L-1234".
741
- const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}$/;
955
+ // The boundary excludes a hyphen, which is what makes this a *prefix* rather
956
+ // than a segment. It did not, so any `XX-NNNNN` tail of a hyphenated reference
957
+ // read as a country-prefixed address: in "PV-2026-LU-09143" the `LU` is
958
+ // preceded by `-` and the German five-digit postal pattern kept `09143`
959
+ // (rules-engine#31).
960
+ const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9\-])([A-Z]{1,3})$/;
961
+ // Postal country prefixes and the length of that country's postcode: any one or two capitals
962
+ // used to pass, so "K-40817" and "B-51823" were country-prefixed postcodes (rules-engine#111).
963
+ const POSTAL_PREFIX_DIGITS = { A: 4, AT: 4, B: 4, BE: 4, BG: 4, CH: 4, CY: 4, CZ: 5, D: 5, DE: 5, DK: 4, E: 5, EE: 5, ES: 5, F: 5, FI: 5, FIN: 5, FR: 5, GR: 5, H: 4, HR: 5, I: 5, IT: 5, L: 4, LT: 5, LU: 4, LV: 4, N: 4, NL: 4, NO: 4, P: 4, PL: 5, PT: 4, RO: 6, S: 5, SE: 5, SI: 4, SK: 5, SLO: 4 };
964
+ function countryPrefixed(before, digits) {
965
+ const m = COUNTRY_PREFIXED.exec(before);
966
+ return m !== null && POSTAL_PREFIX_DIGITS[m[1]] === digits.length;
967
+ }
742
968
  /**
743
969
  * Suppress bare digit runs that belong to a longer number, not an address.
744
970
  *
@@ -754,8 +980,15 @@ function suppressPostalInLongerIdentifier(text, match) {
754
980
  if (match.patternDef.entityType !== EntityType.POSTAL_CODE)
755
981
  return false;
756
982
  const clean = match.text.trim();
757
- if (!/^\d+$/.test(clean))
983
+ if (!/^\d+$/.test(clean)) {
984
+ // A structured form glued on by a hyphen is the tail of a reference: "CPS-HR-2026-019" (#111).
985
+ const glued = text.slice(Math.max(0, match.start - 2), match.start);
986
+ if (glued.length === 2 && glued[1] === "-" && /[A-Za-z0-9]/.test(glued[0])) {
987
+ const m = COUNTRY_PREFIXED.exec(text.slice(0, match.start - 1));
988
+ return !(m && m[1] in POSTAL_PREFIX_DIGITS);
989
+ }
758
990
  return false;
991
+ }
759
992
  const beforeChar = match.start > 0 ? text[match.start - 1] : "";
760
993
  const afterChar = match.end < text.length ? text[match.end] : "";
761
994
  const prevPrev = match.start >= 2 ? text[match.start - 2] : "";
@@ -774,7 +1007,10 @@ function suppressPostalInLongerIdentifier(text, match) {
774
1007
  return true;
775
1008
  if (afterChar === "-" && /\d/.test(nextNext))
776
1009
  return true;
777
- if (beforeChar === "-" && !COUNTRY_PREFIXED.test(text.slice(0, match.start - 1)))
1010
+ // A hyphenated chain goes on after it: "réf. : L-2025-HD-0412" is a reference (#111).
1011
+ if (/^-[A-Za-z0-9]+-/.test(text.slice(match.end, match.end + 12)))
1012
+ return true;
1013
+ if (beforeChar === "-" && !countryPrefixed(text.slice(0, match.start - 1), clean))
778
1014
  return true;
779
1015
  // A further digit group on the same line: "1268 040390", "1234 5678 925",
780
1016
  // "+43 664 8213 907". Horizontal whitespace only — a digit on the *next*
@@ -782,7 +1018,16 @@ function suppressPostalInLongerIdentifier(text, match) {
782
1018
  if (/^[ \t]+\d/.test(text.slice(match.end, match.end + 4)))
783
1019
  return true;
784
1020
  // An identifier label introduces the digits: "DiNr. 4471", "Policen-Nr."
785
- if (ID_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 40), match.start)))
1021
+ // Unless the label is a *postal* one. ID_CUE_BEFORE matches any word ending
1022
+ // in "Nummer", "Nr", "Numero" or "Numéro" -- the wildcard is `[\w\-]*` -- so
1023
+ // it matches `postnummer`, `postnr` and `postinumero`, the canonical postal
1024
+ // labels of Norway, Denmark and Finland. Those countries write a bare
1025
+ // four- or five-digit code, so the isdigit() guard above lets this run and
1026
+ // the code was suppressed by its own label: `postnummer: 5020 Bergen`
1027
+ // produced nothing at all (rules-engine#41).
1028
+ const before = text.slice(Math.max(0, match.start - 40), match.start);
1029
+ const cue = ID_CUE_BEFORE.exec(before);
1030
+ if (cue !== null && !POSTAL_LABEL_CUE.test(cue[0].trimStart()))
786
1031
  return true;
787
1032
  // Digits after an international dialling prefix belong to PHONE
788
1033
  const [lineStart] = enclosingLine(text, match.start, match.end);
@@ -829,12 +1074,158 @@ function suppressRequiresContext(text, match) {
829
1074
  if (DELIMITED_FIELD_TYPES.has(match.patternDef.entityType) &&
830
1075
  fillsADelimitedField(text, match.start, match.end))
831
1076
  return false;
1077
+ if (DATE_LABEL_TYPES.has(match.patternDef.entityType)) {
1078
+ return !labelsThisDate(text, match.start, match.end, match.patternDef.contextKeywords, match.patternDef.entityType);
1079
+ }
832
1080
  const [before, after] = getContext(text, match.start, match.end);
833
1081
  const context = (before + " " + after).toLowerCase();
834
1082
  return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
835
1083
  }
1084
+ /** Date types decided by a label: DOB and DATE_OF_DEATH share one date shape. */
1085
+ const DATE_LABEL_TYPES = new Set([EntityType.DOB, EntityType.DATE_OF_DEATH]);
1086
+ /** Any common numeric date, to tell whether a label belongs to another date. */
1087
+ // A masked date counts as one: the gateway's rules pass reads SDK-masked text (rules-engine#111).
1088
+ // ... and a written-month date, now a DOB shape of its own (rules-engine#115).
1089
+ const ANY_DATE = new RegExp(String.raw `\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}|\[(?:dob|date_of_death)\]|(?<![A-Za-z0-9_])\d{1,2}\.?\s+(?:` + MONTH_NAMES + String.raw `)\.?\s+\d{4}(?![A-Za-z0-9_])`, "i");
1090
+ /**
1091
+ * True when one of `keywords` is this date's own label: before the date with no
1092
+ * other date between, or after it inside the date's own sentence. Anywhere in
1093
+ * the window typed every date from whichever date label the document carried
1094
+ * (rules-engine#52).
1095
+ */
1096
+ /** A date followed by a list separator: an item of the list a label introduces. */
1097
+ const LIST_ITEM = /\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}\s*(?:,|;|–|&|\band\b|\ben\b|\bet\b|\bund\b|\bor\b|\bof\b|\bou\b|\boder\b|\brespectievelijk\b|\bbzw\.?)\s*/g;
1098
+ /** Punctuation and at most two short words ("e.g.", "bv.", "op") between label and date. */
1099
+ const LABEL_TAIL = /^[\s:.,()\-]*(?:\p{L}{1,4}(?:\.\p{L}{1,2})*\.?(?:[\s:.,()\-]+|$)){0,2}$/u;
1100
+ /** The birth sign "°" directly before the date, but not the number sign "n°". */
1101
+ const BIRTH_SIGN = /(?<![a-z])°\s?$/;
1102
+ /** No other date between a label and the date, except items of its list. */
1103
+ function nothingButAListBetween(gap) {
1104
+ if (!ANY_DATE.test(gap))
1105
+ return true;
1106
+ const rest = gap.replace(LIST_ITEM, " ");
1107
+ return !ANY_DATE.test(rest) && LABEL_TAIL.test(rest);
1108
+ }
1109
+ /**
1110
+ * Where `keyword` occurs in the lower-cased `haystack`, or -1. An all-capitals
1111
+ * keyword is an abbreviation and counts only as a word of its own: "DOB" is a
1112
+ * label, the "dob" in Polish "dobrowolnej" is not (rules-engine#100); a plural
1113
+ * "s" may follow ("DOBs e.g. …"). Other
1114
+ * keywords still match inside a longer word, which inflected forms need.
1115
+ */
1116
+ function keywordAt(haystack, keyword, last) {
1117
+ const kw = keyword.toLowerCase();
1118
+ if (keyword !== keyword.toUpperCase() || keyword === kw) {
1119
+ return last ? haystack.lastIndexOf(kw) : haystack.indexOf(kw);
1120
+ }
1121
+ const isLetter = (c) => c !== "" && c.toLowerCase() !== c.toUpperCase();
1122
+ const hits = [];
1123
+ for (let i = haystack.indexOf(kw); i !== -1; i = haystack.indexOf(kw, i + 1)) {
1124
+ let next = i + kw.length;
1125
+ if (haystack[i - 1] === "[" && haystack[next] === "]")
1126
+ continue; // a placeholder ("[DOB]"), not a label (#111)
1127
+ if (haystack[next] === "s")
1128
+ next++;
1129
+ if (!isLetter(haystack.slice(i - 1, i)) && !isLetter(haystack.slice(next, next + 1))) {
1130
+ hits.push(i);
1131
+ }
1132
+ }
1133
+ if (hits.length === 0)
1134
+ return -1;
1135
+ return last ? hits[hits.length - 1] : hits[0];
1136
+ }
1137
+ function labelsThisDate(text, start, end, keywords, entityType) {
1138
+ const [before, after] = getContext(text, start, end);
1139
+ const lowerBefore = before.toLowerCase();
1140
+ const lowerAfter = after.toLowerCase();
1141
+ // A label on an earlier line counts only when the date opens its own line or
1142
+ // the label was asked as a question (a call transcript's answer turn);
1143
+ // a column header is a fallback and never overrules a label of the date's own.
1144
+ const lineStart = lowerBefore.lastIndexOf("\n") + 1;
1145
+ const opensLine = lowerBefore.slice(lineStart).replace(/[ \t:|;*\-•(\[]/g, "") === "";
1146
+ if (entityType === EntityType.DOB && BIRTH_SIGN.test(lowerBefore))
1147
+ return true;
1148
+ for (const keyword of keywords) {
1149
+ const kw = keyword.toLowerCase();
1150
+ const i = keywordAt(lowerBefore, keyword, true);
1151
+ if (i >= 0 && nothingButAListBetween(lowerBefore.slice(i + kw.length))
1152
+ && (i >= lineStart || opensLine
1153
+ || lowerBefore.slice(i + kw.length, lineStart).includes("?")))
1154
+ return true;
1155
+ const j = keywordAt(lowerAfter, keyword, false);
1156
+ if (j >= 0) {
1157
+ const gap = lowerAfter.slice(0, j);
1158
+ if (!ANY_DATE.test(gap) && !SENTENCE_BREAK.test(gap)
1159
+ && !LEADS_TO_A_DATE.test(lowerAfter.slice(j + kw.length)))
1160
+ return true;
1161
+ }
1162
+ }
1163
+ const header = columnHeader(text, start);
1164
+ return header !== null && keywords.some(kw => keywordAt(header, kw, false) >= 0);
1165
+ }
1166
+ /**
1167
+ * A label running straight into a date of its own introduces that date, not the
1168
+ * one before it ("Verstorben am 01.02.2020, geboren am 12.03.1940").
1169
+ */
1170
+ // ... or into a value already masked ("né le [DOB]"): the label is that value's (rules-engine#111).
1171
+ const LEADS_TO_A_DATE = new RegExp(String.raw `^[\s:.\-]*(?:[a-zà-ÿ]{1,3}[\s:.]+)?(?:\d{1,4}[/.\-]\d{1,2}[/.\-]\d{2,4}|\[[a-z_]+\]|\d{1,2}\.?\s+(?:` + MONTH_NAMES + String.raw `)\.?\s+\d{4})`, "i");
1172
+ /** Field separators a table row may use. */
1173
+ const COLUMN_SEPARATORS = [";", "|", "\t", ","];
1174
+ /**
1175
+ * The lower-cased header cell above the value, if it sits in a table: in a table
1176
+ * the column decides the label, not proximity. A header is one of the three
1177
+ * lines above, split by the row's separator into the same number of cells.
1178
+ */
1179
+ function columnHeader(text, start) {
1180
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
1181
+ const lineEndAt = text.indexOf("\n", start);
1182
+ const line = text.slice(lineStart, lineEndAt >= 0 ? lineEndAt : text.length);
1183
+ // The three lines above, by lastIndexOf: splitting the whole prefix was
1184
+ // quadratic in the document, once per date candidate.
1185
+ let aboveStart = lineStart - 1;
1186
+ for (let k = 0; k < 3 && aboveStart >= 0; k++) {
1187
+ aboveStart = aboveStart === 0 ? -1 : text.lastIndexOf("\n", aboveStart - 1);
1188
+ }
1189
+ const above = lineStart ? text.slice(aboveStart + 1, Math.max(0, lineStart - 1)).split("\n") : [];
1190
+ for (const sep of COLUMN_SEPARATORS) {
1191
+ if (!line.includes(sep))
1192
+ continue;
1193
+ const cells = line.split(sep);
1194
+ const column = text.slice(lineStart, start).split(sep).length - 1;
1195
+ for (const header of [...above].reverse()) {
1196
+ // A "label : value" field with a comma in its value is not a header (#111).
1197
+ if (header.includes(":"))
1198
+ continue;
1199
+ const heads = header.split(sep);
1200
+ if (heads.length === cells.length && !ANY_DATE.test(header))
1201
+ return heads[column].toLowerCase();
1202
+ }
1203
+ }
1204
+ return null;
1205
+ }
1206
+ /** A dotted MAC shape glued to more of a reference is part of it: the tail of
1207
+ * "WSO-II.6151.4471.2025" (rules-engine#105). Dotted form only. */
1208
+ function suppressMacInReference(text, match) {
1209
+ if (match.patternDef.entityType !== EntityType.MAC_ADDRESS || !match.text.includes("."))
1210
+ return false;
1211
+ return joinedToMore(text, match.start, match.end);
1212
+ }
1213
+ /** A card number is never carved out of a longer run of digit groups: a
1214
+ * four-digit group joined by the card's own separator on either side means the
1215
+ * run is longer, e.g. a Polish account number (rules-engine#106). */
1216
+ function suppressCardInsideDigitRun(text, match) {
1217
+ if (match.patternDef.entityType !== EntityType.CREDIT_CARD)
1218
+ return false;
1219
+ const sep = [...match.text].find(c => !/\d/.test(c)) ?? "";
1220
+ if (!sep)
1221
+ return false;
1222
+ const s = sep.replace(/[-\\^$.*+?()[\]{}|]/g, "\\$&");
1223
+ const before = text.slice(Math.max(0, match.start - 6), match.start);
1224
+ const after = text.slice(match.end, match.end + 6);
1225
+ return new RegExp(`(?<!\\d)\\d{4}${s}$`).test(before) || new RegExp(`^${s}\\d{4}(?!\\d)`).test(after);
1226
+ }
836
1227
  const TYPE_SUPPRESSORS = {
837
- [EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
1228
+ [EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneInsideAccountRun, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
838
1229
  [EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
839
1230
  [EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
840
1231
  [EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
@@ -844,10 +1235,14 @@ const TYPE_SUPPRESSORS = {
844
1235
  [EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict, suppressPlateAsCurrencyAmount],
845
1236
  [EntityType.SECRET]: [suppressSecretOverStructured, suppressSecretNotASecret],
846
1237
  [EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
1238
+ [EntityType.MAC_ADDRESS]: [suppressMacInReference],
1239
+ [EntityType.CREDIT_CARD]: [suppressCardInsideDigitRun],
847
1240
  };
848
1241
  export function shouldSuppress(text, match, scratch) {
849
1242
  if (suppressSequential(text, match))
850
1243
  return true;
1244
+ if (suppressRedactionPlaceholder(text, match))
1245
+ return true;
851
1246
  const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
852
1247
  if (typeSups) {
853
1248
  for (const s of typeSups) {