// DasuMae — main controller. Everything runs in-page: OCR (Tesseract worker), // detect.js, face detection (ORT worker), editing, and PNG export. No network // request leaves this origin. import { detect } from './detect.js'; import { STRINGS, CLASS_INFO } from './i18n.js'; import { applyRedaction, STYLES } from './redact.js'; import { runOcr } from './ocr.js'; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Language selection (house rule, decided 2026-11-12, global-first) // 1. The saved choice first ('ja' | 'en'); anything else is ignored. // 2. Else Japanese only when the browser language starts with 'ja'. // 3. Else English — including when no locale is available. // Storage is wrapped in try/catch so a blocked/unavailable store never crashes. // --------------------------------------------------------------------------- const LANG_KEY = 'dasumae-lang'; function savedLang() { try { const v = localStorage.getItem(LANG_KEY); return v === 'ja' || v === 'en' ? v : null; } catch { return null; } } function persistLang(lang) { try { localStorage.setItem(LANG_KEY, lang); } catch { /* storage unavailable: ignore */ } } function initialLang() { const saved = savedLang(); if (saved) return saved; let nav = ''; try { nav = (navigator.language || '').toLowerCase(); } catch { nav = ''; } if (nav.startsWith('ja')) return 'ja'; return 'en'; } const state = { lang: initialLang(), style: 'solid', img: null, // ImageBitmap (natural size) natCanvas: null, // natural-size canvas: OCR input + redaction source natW: 0, natH: 0, viewScale: 1, // CSS px per original px boxes: [], // { id, class, text, bbox, active, manual, score? } selectedId: null, faceWorker: null, faceEnabled: true, faceStatus: 'idle', // idle | running | ok | unavailable seq: 0, // image generation, to ignore stale async results }; let nextId = 1; const dpr = Math.min(window.devicePixelRatio || 1, 2); // --------------------------------------------------------------------------- // DOM // --------------------------------------------------------------------------- const $ = (id) => document.getElementById(id); const dropzone = $('dropzone'); const fileInput = $('fileInput'); const editor = $('editor'); const canvas = $('canvas'); const ctx = canvas.getContext('2d'); const statusEl = $('status'); const detList = $('detList'); const noDet = $('noDet'); const styleSel = $('styleSel'); const faceToggle = $('faceToggle'); const faceToggleLabel = $('faceToggleLabel'); const downloadBtn = $('downloadBtn'); const resetBtn = $('resetBtn'); const langToggle = $('langToggle'); const t = () => STRINGS[state.lang]; // --------------------------------------------------------------------------- // i18n application // --------------------------------------------------------------------------- function applyI18n() { const s = t(); document.documentElement.lang = state.lang; if (s.title) document.title = s.title; const metaDesc = document.querySelector('meta[name="description"]'); if (metaDesc && s.metaDescription) metaDesc.setAttribute('content', s.metaDescription); for (const el of document.querySelectorAll('[data-i18n]')) { const key = el.getAttribute('data-i18n'); if (s[key] != null && typeof s[key] === 'string') el.textContent = s[key]; } // lists fillList('supportedList', s.supportedList); fillList('limitsList', s.limitsList); $('stressNote').textContent = s.stressNote || ''; faceToggleLabel.textContent = state.faceStatus === 'unavailable' ? s.faceToggleSoon : s.faceReady; langToggle.textContent = s.langToggle; renderList(); updateStyleButtons(); } function fillList(id, arr) { const ul = $(id); if (!ul) return; ul.innerHTML = ''; for (const item of arr || []) { const li = document.createElement('li'); li.textContent = item; ul.appendChild(li); } } // --------------------------------------------------------------------------- // Status // --------------------------------------------------------------------------- function setStatus(text) { if (!text) { statusEl.classList.remove('show'); statusEl.textContent = ''; return; } statusEl.textContent = text; statusEl.classList.add('show'); } // --------------------------------------------------------------------------- // Image loading + pipeline // --------------------------------------------------------------------------- async function loadFromBlob(blob) { let bitmap; try { // Decode with no colour-space conversion so the pixels Tesseract sees match // a plain leptonica decode as closely as the browser allows (improves OCR // parity with the Phase 0 node measurements). bitmap = await createImageBitmap(blob, { colorSpaceConversion: 'none' }); } catch { try { bitmap = await createImageBitmap(blob); } catch { setStatus(t().loadError); return; } } const seq = ++state.seq; state.img = bitmap; state.natW = bitmap.width; state.natH = bitmap.height; state.boxes = []; state.selectedId = null; // natural-size canvas used as OCR input and redaction source const nc = document.createElement('canvas'); nc.width = state.natW; nc.height = state.natH; nc.getContext('2d').drawImage(bitmap, 0, 0); state.natCanvas = nc; editor.hidden = false; dropzone.hidden = true; layout(); redraw(); renderList(); await runPipeline(seq); } // Browser Tesseract always re-rasterises its input through a canvas (passing raw // bytes / a blob / a 1x canvas all gave identical OCR). The browser decode is // noisier than node's leptonica path in SCALE-DEPENDENT ways: at 1x long API // keys read cleanly but some card digits confuse (0<->9, breaking Luhn); at 2x // the digits resolve but long keys fragment. So we OCR at BOTH scales and take // the union of detections — each scale covers the other's blind spot. Boxes are // scaled back to original-image pixels before merging. const OCR_SCALES = [1, 2]; function ocrCanvasAt(scale) { if (scale === 1) return state.natCanvas; const c = document.createElement('canvas'); c.width = Math.round(state.natW * scale); c.height = Math.round(state.natH * scale); const cx = c.getContext('2d'); cx.imageSmoothingEnabled = true; cx.imageSmoothingQuality = 'high'; cx.drawImage(state.natCanvas, 0, 0, c.width, c.height); return c; } function boxIou(a, b) { const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)); const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)); const inter = ix * iy, uni = a.w * a.h + b.w * b.h - inter; return uni <= 0 ? 0 : inter / uni; } // Union detections from the scale passes, dropping same-class overlaps (keep the // larger box, which redacts at least as much). function mergeDetections(dets) { const out = []; for (const d of dets.slice().sort((a, b) => (b.bbox.w * b.bbox.h) - (a.bbox.w * a.bbox.h))) { if (!out.some((o) => o.class === d.class && boxIou(o.bbox, d.bbox) > 0.4)) out.push(d); } return out; } async function runPipeline(seq) { if (window.__dasumae) { window.__dasumae.done = false; window.__dasumae.processing = true; } setStatus(t().statusOcr); const collected = []; let totalMs = 0, lastWords = null; try { for (const scale of OCR_SCALES) { const ocr = await runOcr(ocrCanvasAt(scale), (m) => { if (seq !== state.seq) return; if (m && m.status && /recogniz/i.test(m.status)) { setStatus(`${t().statusOcr} ${Math.round((m.progress || 0) * 100)}%`); } }); if (seq !== state.seq) return; if (scale === 1) lastWords = ocr.words; totalMs += ocr.ms; const inv = 1 / scale; for (const d of detect(ocr.words)) { const bbox = scale === 1 ? d.bbox : { x: d.bbox.x * inv, y: d.bbox.y * inv, w: d.bbox.w * inv, h: d.bbox.h * inv }; collected.push({ class: d.class, text: d.text, bbox, confidence: d.confidence }); } } } catch (err) { console.error('OCR failed', err); setStatus(t().loadError); if (window.__dasumae) { window.__dasumae.processing = false; window.__dasumae.done = true; window.__dasumae.error = String(err); } return; } if (seq !== state.seq) return; if (window.__dasumae) window.__dasumae.lastWords = lastWords; // debug/harness hook // Merge the per-scale detections, then add. setStatus(t().statusDetect); for (const d of mergeDetections(collected)) { addBox({ class: d.class, text: d.text, bbox: d.bbox, active: true, manual: false, score: d.confidence }); } renderList(); redraw(); // Face (worker) if (state.faceEnabled) { setStatus(t().statusFace); await runFace(seq); } if (seq !== state.seq) return; setStatus(t().statusDone); setTimeout(() => { if (seq === state.seq) setStatus(''); }, 1200); if (window.__dasumae) { window.__dasumae.processing = false; window.__dasumae.done = true; window.__dasumae.ocrMs = Math.round(totalMs); } } // --------------------------------------------------------------------------- // Face worker // --------------------------------------------------------------------------- function ensureFaceWorker() { if (state.faceWorker) return state.faceWorker; state.faceWorker = new Worker(new URL('./face-worker.js', import.meta.url), { type: 'module' }); return state.faceWorker; } function faceInputRGBA() { // Resize the natural image to the model's 320x240 input and read RGBA. const W = 320, H = 240; const c = document.createElement('canvas'); c.width = W; c.height = H; const cctx = c.getContext('2d'); cctx.drawImage(state.natCanvas, 0, 0, state.natW, state.natH, 0, 0, W, H); return cctx.getImageData(0, 0, W, H).data.buffer.slice(0); } function runFace(seq) { return new Promise((resolve) => { let worker; try { worker = ensureFaceWorker(); } catch (e) { markFaceUnavailable(String(e)); return resolve(); } const rgba = faceInputRGBA(); const timer = setTimeout(() => { markFaceUnavailable('timeout'); cleanup(); resolve(); }, 20000); const onMsg = (e) => { const msg = e.data || {}; if (msg.type === 'result') { state.faceStatus = 'ok'; if (window.__dasumae) window.__dasumae.faceOk = true; if (seq === state.seq) { for (const f of msg.faces) { addBox({ class: 'face', text: `face ${f.score}`, bbox: { x: f.x, y: f.y, w: f.w, h: f.h }, active: true, manual: false, score: f.score }); } renderList(); redraw(); } cleanup(); resolve(); } else if (msg.type === 'error') { markFaceUnavailable(msg.message); cleanup(); resolve(); } }; const onErr = (e) => { markFaceUnavailable(e.message || 'worker error'); cleanup(); resolve(); }; function cleanup() { clearTimeout(timer); worker.removeEventListener('message', onMsg); worker.removeEventListener('error', onErr); } worker.addEventListener('message', onMsg); worker.addEventListener('error', onErr); worker.postMessage({ type: 'detect', rgba, origW: state.natW, origH: state.natH, thr: 0.7 }, [rgba]); }); } function markFaceUnavailable(reason) { console.warn('Face detection unavailable:', reason); state.faceStatus = 'unavailable'; state.faceEnabled = false; faceToggle.checked = false; faceToggle.disabled = true; faceToggle.parentElement.classList.add('soon'); faceToggleLabel.textContent = t().faceToggleSoon; if (window.__dasumae) { window.__dasumae.faceOk = false; window.__dasumae.faceError = reason; } } // --------------------------------------------------------------------------- // Boxes // --------------------------------------------------------------------------- function addBox(b) { state.boxes.push({ id: nextId++, ...b }); } function maskText(cls, text) { const s = String(text); if (cls === 'credit_card' || cls === 'my_number') { const digits = s.replace(/\D/g, ''); if (digits.length >= 8) { const groups = []; for (let i = 0; i < digits.length; i += 4) groups.push(digits.slice(i, i + 4)); return groups.map((g, i) => (i === 0 || i === groups.length - 1) ? g : '•'.repeat(g.length)).join(' '); } } if (cls === 'email') { const at = s.indexOf('@'); if (at > 0) return s[0] + '•••' + s.slice(at); return s[0] + '•••'; } if (cls === 'api_key') { const m = s.match(/^([A-Za-z]+[-_]?(?:[a-z0-9]+-)?)/); const head = (m && m[1]) ? m[1] : s.slice(0, 4); return head + '•••' + s.slice(-4); } if (cls === 'ip_address') { const p = s.split('.'); return p.length === 4 ? `${p[0]}.${p[1]}.•••.•••` : s; } if (cls === 'phone_jp' || cls === 'phone_intl') { const keepHead = s.slice(0, Math.min(4, s.length)); return keepHead + '•••' + s.slice(-2); } if (cls === 'postal_jp') return s.replace(/\d(?=\d*\d$)/g, '•'); if (cls === 'face') return cls; // generic middle mask if (s.length <= 4) return '•'.repeat(s.length); return s.slice(0, 2) + '•'.repeat(Math.max(1, s.length - 4)) + s.slice(-2); } function classLabel(cls) { const info = CLASS_INFO[cls] || { ja: cls, en: cls }; return state.lang === 'ja' ? `${info.ja} / ${info.en}` : `${info.en} / ${info.ja}`; } function classColor(cls) { return (CLASS_INFO[cls] || { color: '#495057' }).color; } // --------------------------------------------------------------------------- // Detection list // --------------------------------------------------------------------------- function renderList() { if (!detList) return; detList.innerHTML = ''; const s = t(); if (state.boxes.length === 0) { noDet.hidden = false; noDet.textContent = s.noDetections; return; } noDet.hidden = true; for (const b of state.boxes) { const li = document.createElement('li'); li.className = 'det-item' + (b.active ? '' : ' off') + (b.id === state.selectedId ? ' selected' : ''); li.dataset.id = b.id; const sw = document.createElement('div'); sw.className = 'det-swatch'; sw.style.background = b.manual ? 'repeating-linear-gradient(45deg,#495057,#495057 3px,#fff 3px,#fff 6px)' : classColor(b.class); const main = document.createElement('div'); main.className = 'det-main'; const cls = document.createElement('div'); cls.className = 'det-class'; cls.textContent = b.manual ? classLabel('manual') : classLabel(b.class); const txt = document.createElement('div'); txt.className = 'det-text'; txt.textContent = b.manual ? '' : maskText(b.class, b.text); main.append(cls, txt); const ctrls = document.createElement('div'); ctrls.className = 'det-controls'; const toggle = document.createElement('button'); toggle.className = 'icon-btn'; toggle.textContent = b.active ? s.on : s.off; toggle.addEventListener('click', (ev) => { ev.stopPropagation(); b.active = !b.active; renderList(); redraw(); }); const del = document.createElement('button'); del.className = 'icon-btn'; del.textContent = s.delete; del.addEventListener('click', (ev) => { ev.stopPropagation(); state.boxes = state.boxes.filter((x) => x.id !== b.id); if (state.selectedId === b.id) state.selectedId = null; renderList(); redraw(); }); ctrls.append(toggle, del); li.append(sw, main, ctrls); li.addEventListener('click', () => { state.selectedId = b.id; renderList(); redraw(); }); detList.appendChild(li); } } // --------------------------------------------------------------------------- // Canvas layout + rendering // --------------------------------------------------------------------------- function layout() { if (!state.img) return; const wrap = canvas.parentElement; const availW = Math.max(240, wrap.clientWidth - 24); const availH = Math.max(240, Math.round(window.innerHeight * 0.72)); state.viewScale = Math.min(availW / state.natW, availH / state.natH, 1); const cssW = Math.round(state.natW * state.viewScale); const cssH = Math.round(state.natH * state.viewScale); canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); } function redraw() { if (!state.img) return; const s = state.viewScale * dpr; // original px -> canvas px ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(state.img, 0, 0, state.natW, state.natH, 0, 0, canvas.width, canvas.height); // active boxes: real redaction preview for (const b of state.boxes) { if (b.active) applyRedaction(ctx, state.natCanvas, b.bbox, s, state.style); } // outlines + handles for (const b of state.boxes) { const x = b.bbox.x * s, y = b.bbox.y * s, w = b.bbox.w * s, h = b.bbox.h * s; const col = b.manual ? '#495057' : classColor(b.class); ctx.save(); ctx.lineWidth = Math.max(1, 2 * dpr); ctx.strokeStyle = col; if (!b.active || b.manual) ctx.setLineDash([6 * dpr, 4 * dpr]); ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); if (b.id === state.selectedId) { // resize handle (bottom-right) const hs = 9 * dpr; ctx.fillStyle = '#fff'; ctx.strokeStyle = col; ctx.fillRect(x + w - hs, y + h - hs, hs, hs); ctx.strokeRect(x + w - hs, y + h - hs, hs, hs); } ctx.restore(); } } // --------------------------------------------------------------------------- // Pointer interaction: create / move / resize // --------------------------------------------------------------------------- let drag = null; // { mode:'create'|'move'|'resize', id, startX, startY, orig } function toOrig(ev) { const r = canvas.getBoundingClientRect(); return { x: (ev.clientX - r.left) / state.viewScale, y: (ev.clientY - r.top) / state.viewScale, }; } function hitHandle(b, p) { const hs = 12 / state.viewScale; // generous in original px return p.x >= b.bbox.x + b.bbox.w - hs && p.x <= b.bbox.x + b.bbox.w + hs * 0.4 && p.y >= b.bbox.y + b.bbox.h - hs && p.y <= b.bbox.y + b.bbox.h + hs * 0.4; } function hitBox(b, p) { return p.x >= b.bbox.x && p.x <= b.bbox.x + b.bbox.w && p.y >= b.bbox.y && p.y <= b.bbox.y + b.bbox.h; } canvas.addEventListener('pointerdown', (ev) => { if (!state.img) return; canvas.setPointerCapture(ev.pointerId); const p = toOrig(ev); // selected box handle first const sel = state.boxes.find((b) => b.id === state.selectedId); if (sel && hitHandle(sel, p)) { drag = { mode: 'resize', id: sel.id, startX: p.x, startY: p.y, orig: { ...sel.bbox } }; return; } // topmost box under cursor for (let i = state.boxes.length - 1; i >= 0; i--) { const b = state.boxes[i]; if (hitBox(b, p)) { state.selectedId = b.id; drag = { mode: 'move', id: b.id, startX: p.x, startY: p.y, orig: { ...b.bbox } }; renderList(); redraw(); return; } } // empty area -> create manual box state.selectedId = null; const nb = { id: nextId++, class: 'manual', text: '', active: true, manual: true, bbox: { x: p.x, y: p.y, w: 0, h: 0 } }; state.boxes.push(nb); drag = { mode: 'create', id: nb.id, startX: p.x, startY: p.y }; renderList(); redraw(); }); canvas.addEventListener('pointermove', (ev) => { if (!drag) return; const p = toOrig(ev); const b = state.boxes.find((x) => x.id === drag.id); if (!b) return; const clampX = (v) => Math.max(0, Math.min(state.natW, v)); const clampY = (v) => Math.max(0, Math.min(state.natH, v)); if (drag.mode === 'create') { const x1 = clampX(Math.min(drag.startX, p.x)), y1 = clampY(Math.min(drag.startY, p.y)); const x2 = clampX(Math.max(drag.startX, p.x)), y2 = clampY(Math.max(drag.startY, p.y)); b.bbox = { x: x1, y: y1, w: x2 - x1, h: y2 - y1 }; } else if (drag.mode === 'move') { const dx = p.x - drag.startX, dy = p.y - drag.startY; b.bbox.x = clampX(drag.orig.x + dx); b.bbox.y = clampY(drag.orig.y + dy); b.bbox.x = Math.min(b.bbox.x, state.natW - b.bbox.w); b.bbox.y = Math.min(b.bbox.y, state.natH - b.bbox.h); } else if (drag.mode === 'resize') { b.bbox.w = Math.max(4, clampX(p.x) - b.bbox.x); b.bbox.h = Math.max(4, clampY(p.y) - b.bbox.y); } redraw(); }); function endDrag(ev) { if (!drag) return; const b = state.boxes.find((x) => x.id === drag.id); if (drag.mode === 'create' && b && (b.bbox.w < 6 || b.bbox.h < 6)) { state.boxes = state.boxes.filter((x) => x.id !== b.id); // too small, discard state.selectedId = null; } else if (b) { state.selectedId = b.id; } drag = null; try { canvas.releasePointerCapture(ev.pointerId); } catch { /* noop */ } renderList(); redraw(); } canvas.addEventListener('pointerup', endDrag); canvas.addEventListener('pointercancel', endDrag); // --------------------------------------------------------------------------- // Download (full resolution) // --------------------------------------------------------------------------- function renderRedactedCanvas() { const out = document.createElement('canvas'); out.width = state.natW; out.height = state.natH; const octx = out.getContext('2d'); octx.drawImage(state.img, 0, 0); for (const b of state.boxes) { if (b.active) applyRedaction(octx, state.natCanvas, b.bbox, 1, state.style); } return out; } function download() { if (!state.img) return; const out = renderRedactedCanvas(); out.toBlob((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'dasumae-redacted.png'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); }, 'image/png'); } // --------------------------------------------------------------------------- // Style selector // --------------------------------------------------------------------------- function updateStyleButtons() { for (const btn of styleSel.querySelectorAll('.style-btn')) { btn.classList.toggle('active', btn.dataset.style === state.style); } } styleSel.addEventListener('click', (ev) => { const btn = ev.target.closest('.style-btn'); if (!btn) return; if (!STYLES.includes(btn.dataset.style)) return; state.style = btn.dataset.style; updateStyleButtons(); redraw(); }); // --------------------------------------------------------------------------- // Reset / inputs // --------------------------------------------------------------------------- function reset() { state.seq++; state.img = null; state.natCanvas = null; state.boxes = []; state.selectedId = null; editor.hidden = true; dropzone.hidden = false; fileInput.value = ''; setStatus(''); if (window.__dasumae) { window.__dasumae.done = false; window.__dasumae.processing = false; } } dropzone.addEventListener('click', () => fileInput.click()); dropzone.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } }); fileInput.addEventListener('change', () => { if (fileInput.files && fileInput.files[0]) loadFromBlob(fileInput.files[0]); }); ['dragenter', 'dragover'].forEach((t) => dropzone.addEventListener(t, (e) => { e.preventDefault(); dropzone.classList.add('drag'); })); ['dragleave', 'drop'].forEach((t) => dropzone.addEventListener(t, (e) => { e.preventDefault(); dropzone.classList.remove('drag'); })); dropzone.addEventListener('drop', (e) => { const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f && /^image\//.test(f.type)) loadFromBlob(f); }); window.addEventListener('paste', (e) => { const items = e.clipboardData && e.clipboardData.items; if (!items) return; for (const it of items) { if (it.type && it.type.startsWith('image/')) { const f = it.getAsFile(); if (f) { loadFromBlob(f); break; } } } }); downloadBtn.addEventListener('click', download); resetBtn.addEventListener('click', reset); faceToggle.addEventListener('change', () => { if (!faceToggle.disabled) state.faceEnabled = faceToggle.checked; }); langToggle.addEventListener('click', () => { state.lang = state.lang === 'ja' ? 'en' : 'ja'; persistLang(state.lang); applyI18n(); }); window.addEventListener('resize', () => { if (state.img) { layout(); redraw(); } }); // --------------------------------------------------------------------------- // Test hook for the Playwright harness (Task 3). No effect on normal use. // --------------------------------------------------------------------------- window.__dasumae = { ready: false, processing: false, done: false, faceOk: null, getBoxes: () => state.boxes.map((b) => ({ class: b.class, text: b.text, bbox: { ...b.bbox }, active: b.active, manual: b.manual, score: b.score })), setStyle: (st) => { if (STYLES.includes(st)) { state.style = st; updateStyleButtons(); redraw(); } }, setFaceEnabled: (v) => { if (!faceToggle.disabled) { state.faceEnabled = !!v; faceToggle.checked = !!v; } }, renderPNGDataURL: () => renderRedactedCanvas().toDataURL('image/png'), loadBlob: (blob) => loadFromBlob(blob), reset, }; // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- state.style = 'solid'; applyI18n(); updateStyleButtons(); window.__dasumae.ready = true;