dasumae / src /detect.js
NagaYu's picture
Upload 6 files
c4413a6 verified
Raw History Blame Contribute Delete
21 kB
// 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-οΌ™ -> 0-9
if (code >= 0xff10 && code <= 0xff19) return String.fromCharCode(code - 0xff10 + 0x30);
// full-width Latin letters 1-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,
};