Download src/detect.js from NagaYu/dasumae: direct link, hf CLI and curl.
- Browser
- Download file 21 kB
-
https://huggingface.co/spaces/NagaYu/dasumae/resolve/main/src/detect.js
- Command line
-
hf download hf://spaces/NagaYu/dasumae/src/detect.js
-
curl -L -o detect.js https://huggingface.co/spaces/NagaYu/dasumae/resolve/main/src/detect.js
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-9 | |
| if (code >= 0xff10 && code <= 0xff19) return String.fromCharCode(code - 0xff10 + 0x30); | |
| // full-width Latin letters οΌ‘-οΌΊ ο½-ο½ -> 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, | |
| }; | |