// DasuMae detection module — dependency-free. // // Input: words = [{ text, bbox:{x,y,w,h}, confidence? }] (OCR output, pixels) // Output: detections = [{ class, text, bbox:{x,y,w,h}, confidence }] // // Approach: reconstruct line-level text from words, run freshly-written patterns // + validators (Luhn, My Number check digit, octet/range checks, length/charset // rules) against each line, then map the matched character span back to the // words it covers and merge their boxes to cover the whole planted item. // // Patterns are written from the public shape of each format (RFC email shape, // Luhn, the My Number check-digit algorithm, well-known API-key prefixes). No // rule file, pattern list, or code is copied from any other project. // // ESM module (package.json "type": "module"). // --------------------------------------------------------------------------- // Normalisation — only length-preserving 1:1 replacements, so character offsets // stay aligned with the word-owner map. // --------------------------------------------------------------------------- const DASHES = /[‐‑‒–—―−-]/g; // ‐‑‒–—―−- -> - function normalizeChar(ch) { const code = ch.codePointAt(0); // full-width digits 0-9 -> 0-9 if (code >= 0xff10 && code <= 0xff19) return String.fromCharCode(code - 0xff10 + 0x30); // full-width Latin letters A-Z a-z -> ASCII if (code >= 0xff21 && code <= 0xff3a) return String.fromCharCode(code - 0xff21 + 0x41); if (code >= 0xff41 && code <= 0xff5a) return String.fromCharCode(code - 0xff41 + 0x61); if (code === 0xff20) return '@'; // @ if (code === 0xff0e) return '.'; // . return ch; } function normalize(s) { let out = ''; for (const ch of String(s)) out += normalizeChar(ch); return out.replace(DASHES, '-'); } // --------------------------------------------------------------------------- // Validators (exported for unit tests) // --------------------------------------------------------------------------- function luhn(numStr) { const s = String(numStr).replace(/\D/g, ''); if (s.length === 0) return false; let sum = 0, alt = false; for (let i = s.length - 1; i >= 0; i--) { let d = s.charCodeAt(i) - 48; if (alt) { d *= 2; if (d > 9) d -= 9; } sum += d; alt = !alt; } return sum % 10 === 0; } function myNumberCheckDigit(payload11) { let sum = 0; for (let n = 1; n <= 11; n++) { const p = payload11.charCodeAt(11 - n) - 48; const q = n <= 6 ? n + 1 : n - 5; sum += p * q; } const r = sum % 11; return r <= 1 ? 0 : 11 - r; } function myNumberValid(twelveDigits) { const s = String(twelveDigits).replace(/\D/g, ''); if (!/^\d{12}$/.test(s)) return false; return myNumberCheckDigit(s.slice(0, 11)) === s.charCodeAt(11) - 48; } function octetsValid(ip) { const parts = ip.split('.'); if (parts.length !== 4) return false; return parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255 && (p.length === 1 || p[0] !== '0')); // reject 01, 00 style } // --------------------------------------------------------------------------- // Detectors. Each returns matches [{ class, text, start, end, confidence }] // against a normalised line string. start/end are character offsets. // Confidence heuristic (documented): // 0.95 exact validator passed (Luhn, My Number check digit, IBAN mod-97) or a // distinctive vendor key prefix / 〒 marker matched // 0.90 strong structural pattern with a range/shape check (email, IP) // 0.80 pattern-only match (phone) — shape matched, no external validator // 0.50 signal-only class (address_jp) — surfaced, never auto-gated // --------------------------------------------------------------------------- function pushMatch(out, cls, m, conf, textOverride) { out.push({ class: cls, text: textOverride ?? m[0], start: m.index, end: m.index + m[0].length, confidence: conf }); } // Each detector receives the line object { text, owners, compact } and pushes // raw matches with character offsets into line.text. // email — OCR often drops the tiny dot in ".jp", so a dot-less domain is still // accepted when it ends in a known TLD. (No '@' appears in the clean set, so FP // stays 0 despite the looser rule.) // ',' is in the domain charset because OCR frequently reads a domain '.' as ',' // (e.g. "example,jp"); it is normalised back to '.' below. The '@' is required // and never appears in the clean set, so FP stays 0 despite the looser rule. const RE_EMAIL = /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.,\-]{2,}/g; const EMAIL_TLD = /(?:com|jp|org|net|co|io|dev|info|edu|gov|ne)$/i; // Space-tolerant recovery pass: OCR often splits a domain with a stray space // ("example. com", "examp le. com"), which breaks a contiguous match. On the // space-stripped view we match up to a KNOWN TLD boundary (\b), so the match // can't run on into the next word. '@' is required (absent from the clean set). const RE_EMAIL_COMPACT = /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.,\-]+?[.,](?:com|jp|org|net|co|io|dev|info|edu|gov|ne)\b/gi; function detectEmail(line, out) { const s = line.text; for (const m of s.matchAll(RE_EMAIL)) { let tok = m[0].replace(/[.,\-]+$/, ''); // trim trailing punctuation tok = tok.replace(/,/g, '.'); // OCR comma -> dot (length-preserving) const at = tok.indexOf('@'); const local = tok.slice(0, at), domain = tok.slice(at + 1); if (!local || domain.length < 2 || /\.\./.test(tok)) continue; const hasDot = domain.includes('.'); if (hasDot ? !/\.[A-Za-z]{2,}$/.test(tok) : !EMAIL_TLD.test(domain)) continue; out.push({ class: 'email', text: tok, start: m.index, end: m.index + tok.length, confidence: hasDot ? 0.9 : 0.75 }); } // recovery pass on the compact view (offsets mapped back to the raw line) const { text: ctext, map } = line.compact; for (const m of ctext.matchAll(RE_EMAIL_COMPACT)) { const tok = m[0].replace(/,/g, '.'); if (tok.indexOf('@') < 1 || /\.\./.test(tok)) continue; const os = map[m.index], oe = map[m.index + m[0].length - 1] + 1; out.push({ class: 'email', text: tok, start: os, end: oe, confidence: 0.85 }); } } // credit card — digit runs (spaces/dashes allowed), 13–19 digits, Luhn. const RE_CARD = /\d(?:[ -]?\d){11,18}/g; function detectCard(line, out) { const s = line.text; for (const m of s.matchAll(RE_CARD)) { const digits = m[0].replace(/\D/g, ''); if (digits.length < 13 || digits.length > 19) continue; const before = s[m.index - 1], after = s[m.index + m[0].length]; if (before && /[A-Za-z]/.test(before)) continue; if (after && /[A-Za-z]/.test(after)) continue; if (!luhn(digits)) continue; pushMatch(out, 'credit_card', m, 0.95); } } // My Number — exactly 12 digits (grouped or contiguous), valid check digit. const RE_MYNUM = /\d(?:[ -]?\d){11}/g; function detectMyNumber(line, out) { const s = line.text; for (const m of s.matchAll(RE_MYNUM)) { const digits = m[0].replace(/\D/g, ''); if (digits.length !== 12) continue; const before = s[m.index - 1], after = s[m.index + m[0].length]; if (before && /[0-9A-Za-z]/.test(before)) continue; if (after && /[0-9A-Za-z]/.test(after)) continue; if (!myNumberValid(digits)) continue; pushMatch(out, 'my_number', m, 0.95); } } // IP address — 4 dotted octets, each 0–255. const RE_IP = /\d{1,3}(?:\.\d{1,3}){3}/g; function detectIp(line, out) { const s = line.text; for (const m of s.matchAll(RE_IP)) { if (!octetsValid(m[0])) continue; const before = s[m.index - 1], after = s[m.index + m[0].length]; if (before && /[0-9.]/.test(before)) continue; // part of a longer dotted string if (after && /[0-9.]/.test(after)) continue; pushMatch(out, 'ip_address', m, 0.9); } } // API keys — prefix-anchored, run on a SPACE-STRIPPED view of the line because // OCR scatters spaces inside a long key and doubles dashes ("sk-" -> "sk--"). // Once a vendor prefix is seen the whole token is secret, so the body is matched // loosely (length-tolerant, allows the stray @/./- that OCR substitutes in). The // prefixes don't occur in ordinary UI text, so clean-set FP stays 0. // Key body charset: the real key alphabet (base64/base62 + _ @ . - + / =) plus // the stray '[' ']' OCR sometimes injects mid-token (e.g. "sk-pro]j-..."). Once a // vendor prefix is seen the whole run is secret, so the body is matched loosely. const BODY = '[A-Za-z0-9_@.+/=\\-\\]\\[]'; const JWTBODY = '[A-Za-z0-9_@\\-]'; // JWT part body, tolerating OCR '0'->'@' const API_PATTERNS = [ /gh[opusr][_\-][A-Za-z0-9@\]\[]{18,}/g, /github[_\-]pat[_\-][A-Za-z0-9_@\]\[]{25,}/g, /AKIA[A-Z0-9]{12,}/g, new RegExp(`x[o0]x[bpars]${BODY}{16,}`, 'g'), // Slack xox*-; tolerate OCR o->0 new RegExp(`eyJ${JWTBODY}{6,}\\.${JWTBODY}{6,}\\.${JWTBODY}{4,}`, 'g'), // JWT new RegExp(`sk-+${BODY}{16,}`, 'g'), // sk-ant / sk-proj / any sk- key ]; function detectApiKey(line, out) { const { text: ctext, map } = line.compact; const taken = []; for (const re of API_PATTERNS) { for (const m of ctext.matchAll(re)) { const s = m.index, e = m.index + m[0].length; if (taken.some(([ts, te]) => s < te && e > ts)) continue; taken.push([s, e]); // map compact offsets back to original line.text offsets for box lookup const os = map[s], oe = map[e - 1] + 1; out.push({ class: 'api_key', text: m[0], start: os, end: oe, confidence: 0.95 }); } } } // phone (JP) — leading 0, dashed groups, <=11 digits total. const RE_PHONE_JP = /0\d{0,3}-\d{2,4}-\d{3,4}/g; function detectPhoneJp(line, out) { const s = line.text; for (const m of s.matchAll(RE_PHONE_JP)) { const digits = m[0].replace(/\D/g, ''); if (digits.length < 9 || digits.length > 11) continue; const before = s[m.index - 1], after = s[m.index + m[0].length]; if (before && /[0-9]/.test(before)) continue; if (after && /[0-9-]/.test(after)) continue; pushMatch(out, 'phone_jp', m, 0.8); } } // phone (international) — leading +, 8–15 digits. const RE_PHONE_INTL = /\+\d{1,3}[\s-]?\(?\d{1,4}\)?[\s-]?\d{2,4}[\s-]?\d{2,4}/g; function detectPhoneIntl(line, out) { const s = line.text; for (const m of s.matchAll(RE_PHONE_INTL)) { const digits = m[0].replace(/\D/g, ''); if (digits.length < 8 || digits.length > 15) continue; pushMatch(out, 'phone_intl', m, 0.8); } } // postal (JP) — the 〒 marker is required, but OCR reads 〒 as "T"/"TT" // (look-alike), so an isolated leading T before NNN-NNNN is accepted too. Bare // NNN-NNNN with no marker is left to manual to avoid flagging generic numbers. // The 〒 marker (or its "T"/"TT" OCR look-alike) is required. The first group is // \d{3,4}: a true code is 〒NNN-NNNN, but OCR often injects a phantom digit right // after 〒 (e.g. "〒7080-7118"), so a 4-digit first group is accepted too. const RE_POSTAL = /(?:〒|\bT{1,2})\s?\d{3,4}\s?-\s?\d{4}/g; function detectPostal(line, out) { const s = line.text; for (const m of s.matchAll(RE_POSTAL)) { const after = s[m.index + m[0].length]; if (after && /\d/.test(after)) continue; // part of a longer number pushMatch(out, 'postal_jp', m, 0.85); } } // address (JP) — signal only, never auto-gated. const RE_ADDRESS = /(?:東京都|北海道|(?:京都|大阪)府|[一-鿿]{2,3}県)[一-鿿0-9ー\-]{2,}[0-9]/g; function detectAddress(line, out) { for (const m of line.text.matchAll(RE_ADDRESS)) pushMatch(out, 'address_jp', m, 0.5); } // =========================================================================== // EU LOCALE PACK — IBAN (ISO 13616). Self-contained block: country length // table, ISO 7064 mod-97 checksum, candidate matching, and the detector. Kept // in one clearly-delimited section so a future locale pack can be added without // tangling with the universal core above. A candidate is accepted ONLY when its // length matches the country table AND mod-97 == 1. The checksum is the point — // like Luhn for cards, it is what keeps false positives at zero: an IBAN-shaped // string with a broken checksum, or a wrong length for its country code, is // never boxed. // =========================================================================== // ISO 13616 IBAN length by 2-letter country code (full published registry). const IBAN_LEN = { AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BR: 29, BY: 28, CH: 21, CR: 22, CY: 28, CZ: 24, DE: 22, DK: 18, DO: 28, EE: 20, EG: 29, ES: 24, FI: 18, FO: 18, FR: 27, GB: 22, GE: 22, GI: 23, GL: 18, GR: 27, GT: 28, HR: 21, HU: 28, IE: 22, IL: 23, IQ: 23, IS: 26, IT: 27, JO: 30, KW: 30, KZ: 20, LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21, LY: 25, MC: 27, MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30, NL: 18, NO: 15, PK: 24, PL: 28, PS: 29, PT: 25, QA: 29, RO: 24, RS: 22, SA: 24, SC: 31, SD: 18, SE: 24, SI: 19, SK: 24, SM: 27, ST: 25, SV: 28, TL: 23, TN: 24, TR: 26, UA: 29, VA: 22, VG: 24, XK: 20, }; // ISO 7064 mod-97-10: move the first 4 chars to the end, map A-Z -> 10..35, and // reduce the resulting big number mod 97 piecewise. A valid IBAN yields exactly 1. function ibanMod97(compact) { const s = compact.slice(4) + compact.slice(0, 4); let rem = 0; for (let i = 0; i < s.length; i++) { const code = s.charCodeAt(i); if (code >= 48 && code <= 57) { // 0-9 rem = (rem * 10 + (code - 48)) % 97; } else if (code >= 65 && code <= 90) { // A-Z -> 10..35 (two decimal digits) rem = (rem * 100 + (code - 55)) % 97; } else { return false; // non-alphanumeric: not an IBAN body } } return rem === 1; } // Normalise a candidate: drop spaces, uppercase. (Line text is already // character-normalised upstream, so letters are ASCII here.) function ibanCompact(tok) { return tok.replace(/\s+/g, '').toUpperCase(); } // Validate a compact token as a FULL IBAN: known country, exact country length, // well-formed body, mod-97 == 1. No prefix trimming — a run longer than the // country length is a wrong-length lookalike, not an IBAN. function ibanValid(tok) { const c = ibanCompact(tok); if (c.length < 15 || c.length > 34) return false; const L = IBAN_LEN[c.slice(0, 2)]; if (!L || c.length !== L) return false; if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(c)) return false; return ibanMod97(c); } // Candidate shapes on the reconstructed line text: // grouped — CC + 2 check digits, then space-separated groups (of 4, final // group 1-4). Each group is bounded by a non-alphanumeric via the // lookahead, so a following word is never glued onto the run. // contiguous — CC + 2 check digits + 11-30 alphanumerics, no spaces. // The whole matched run (including its internal spaces) becomes the box, so the // redaction covers every space-separated group, never a partial group. const RE_IBAN_GROUPED = /[A-Za-z]{2}[0-9]{2}(?:\s[A-Za-z0-9]{4}(?![A-Za-z0-9]))*(?:\s[A-Za-z0-9]{1,4}(?![A-Za-z0-9]))?/g; const RE_IBAN_CONTIG = /[A-Za-z]{2}[0-9]{2}[A-Za-z0-9]{11,30}/g; function detectIban(line, out) { const s = line.text; const claimed = []; const consider = (st, en, tok) => { if (!ibanValid(tok)) return; if (claimed.some(([a, b]) => st < b && en > a)) return; claimed.push([st, en]); out.push({ class: 'iban', text: ibanCompact(tok), start: st, end: en, confidence: 0.95 }); }; // grouped pass — must contain at least one space, and not start mid-token. for (const m of s.matchAll(RE_IBAN_GROUPED)) { if (!/\s/.test(m[0])) continue; const before = s[m.index - 1]; if (before && /[A-Za-z0-9]/.test(before)) continue; consider(m.index, m.index + m[0].length, m[0]); } // contiguous pass — reject if glued to adjacent alphanumerics (wrong-length run). for (const m of s.matchAll(RE_IBAN_CONTIG)) { const before = s[m.index - 1], after = s[m.index + m[0].length]; if (before && /[A-Za-z0-9]/.test(before)) continue; if (after && /[A-Za-z0-9]/.test(after)) continue; consider(m.index, m.index + m[0].length, m[0]); } } const DETECTORS = [detectEmail, detectCard, detectMyNumber, detectIp, detectApiKey, detectPhoneJp, detectPhoneIntl, detectPostal, detectAddress, detectIban]; // --------------------------------------------------------------------------- // Line reconstruction from words + char->word owner map. // --------------------------------------------------------------------------- function buildLines(words) { const ws = words .filter((w) => w && typeof w.text === 'string' && w.text.trim() !== '' && w.bbox) .map((w) => ({ ...w, _yc: w.bbox.y + w.bbox.h / 2 })) .sort((a, b) => a._yc - b._yc || a.bbox.x - b.bbox.x); const lines = []; for (const w of ws) { let line = lines.find((l) => Math.abs(l.yc - w._yc) <= Math.max(6, w.bbox.h * 0.6)); if (!line) { line = { yc: w._yc, words: [] }; lines.push(line); } line.words.push(w); // running mean of the line's vertical centre keeps drift small line.yc = line.words.reduce((s, x) => s + x._yc, 0) / line.words.length; } for (const line of lines) { line.words.sort((a, b) => a.bbox.x - b.bbox.x); let text = ''; const owners = []; // char index -> word index (or -1 for inserted spaces) line.words.forEach((w, wi) => { if (text.length) { text += ' '; owners.push(-1); } const t = normalize(w.text); for (let i = 0; i < t.length; i++) owners.push(wi); text += t; }); line.text = text; line.owners = owners; // space-stripped view with a map back to original offsets, for key scanning let ctext = ''; const map = []; for (let i = 0; i < text.length; i++) { if (/\s/.test(text[i])) continue; ctext += text[i]; map.push(i); } line.compact = { text: ctext, map }; } return lines; } function mergeBoxes(boxes) { const x1 = Math.min(...boxes.map((b) => b.x)); const y1 = Math.min(...boxes.map((b) => b.y)); const x2 = Math.max(...boxes.map((b) => b.x + b.w)); const y2 = Math.max(...boxes.map((b) => b.y + b.h)); return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; } // Map a [start,end) character span in a line to the merged bbox of the words // it overlaps. function spanBox(line, start, end) { const wi = new Set(); for (let i = start; i < end; i++) { const o = line.owners[i]; if (o >= 0) wi.add(o); } if (wi.size === 0) return null; const boxes = [...wi].map((i) => line.words[i].bbox); return mergeBoxes(boxes); } // Numeric classes whose value is written as several digit groups. OCR can mangle // the middle of a long number so that only part of it matches a validator (e.g. // a garbled card whose first 12 clean digits coincidentally pass the My Number // check digit), which would box only some of the groups and leave the rest of // the number readable. For these classes we expand the matched span outward // over the full CONTIGUOUS run of digit groups it belongs to — digits, ASCII // spaces, and hyphens — so a partially-read number still hides the whole thing. // Real text (letters, '@', '.', ':', '(', currency, etc.) breaks the run, so a // neighbouring unrelated number separated by text is never swallowed. const NUMERIC_EXPAND = new Set(['credit_card', 'my_number', 'phone_jp', 'phone_intl']); const isRunChar = (ch) => ch === ' ' || ch === '-' || (ch >= '0' && ch <= '9'); function expandNumericSpan(text, start, end) { let s = start, e = end; while (s > 0 && isRunChar(text[s - 1])) s--; while (e < text.length && isRunChar(text[e])) e++; // Trim pure separators at the edges so the box hugs the digit groups. while (s < e && (text[s] === ' ' || text[s] === '-')) s++; while (e > s && (text[e - 1] === ' ' || text[e - 1] === '-')) e--; return [s, e]; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- function detect(words) { const lines = buildLines(words); const detections = []; for (const line of lines) { const raw = []; for (const d of DETECTORS) d(line, raw); // resolve overlaps: a span already claimed by a higher-priority/validated // class wins (card/my_number/api_key/email before phone/address). raw.sort((a, b) => b.confidence - a.confidence || (b.end - b.start) - (a.end - a.start)); const claimed = []; for (const r of raw) { if (claimed.some(([s, e]) => r.start < e && r.end > s)) continue; claimed.push([r.start, r.end]); let bs = r.start, be = r.end; if (NUMERIC_EXPAND.has(r.class)) [bs, be] = expandNumericSpan(line.text, r.start, r.end); const bbox = spanBox(line, bs, be); if (!bbox) continue; detections.push({ class: r.class, text: r.text, bbox, confidence: r.confidence }); } } return detections; } export { detect, buildLines, normalize, luhn, myNumberCheckDigit, myNumberValid, octetsValid, ibanMod97, ibanValid, IBAN_LEN, };