dasumae / src /app.js
NagaYu's picture
Upload 6 files
c4413a6 verified
Raw History Blame Contribute Delete
25.5 kB
// 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;