From 123ce595957c66f586baff22ba275e75cd4be412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 18:42:19 +0200 Subject: [PATCH] feat: OCR a camera shot / image into the input via Tesseract.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Scan image" button in the input pane header: - Opens the phone's rear camera (capture=environment) or a desktop file picker via a hidden . - On image selected, lazy-imports tesseract.js from esm.sh — no bundling, no Docker image growth. - Language follows the "From" dropdown (ISO 639-2 mapping for the 30 currently-supported languages). "Auto-detect" loads the eng+por+deu+nld pack and lets tesseract pick. - Progress status renders below the input (engine load → OCR percentage → "done in Xs"). - Result replaces the editor contents; existing vim bindings, Shift-H/L nav, Ctrl-Enter translate all keep working. First scan triggers a ~5 MB Tesseract WASM download plus the chosen language pack (~3-10 MB). Cached by the browser after. --- CHANGELOG.md | 3 ++ static/index.html | 131 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b0314..a8c4819 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- "Scan image" button next to the input pane: opens the phone's rear camera (or a file picker on desktop), runs OCR locally in the browser via Tesseract.js (loaded from esm.sh on first use), and replaces the editor contents with the recognised text. Language follows the "From" dropdown via an ISO 639-2 mapping; "Auto-detect" loads a common European set (`eng+por+deu+nld`). Progress status shows beneath the input ("Loading OCR engine…" → "OCR 40%" → "OCR done in 3.2s"). Zero image-size cost — same CDN pattern as CodeMirror. + ## [0.2.0] - 2026-06-09 ### Added diff --git a/static/index.html b/static/index.html index 53bbfd1..7120f21 100644 --- a/static/index.html +++ b/static/index.html @@ -156,6 +156,40 @@ box-shadow: none; } + /* Input-pane header: label on the left, "Scan image" button on the right */ + .input-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 0.25rem; + } + .input-header label { margin-bottom: 0; } + .scan-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + font-size: 0.75rem; + padding: 0.2rem 0.6rem; + margin: 0; + border-radius: 4px; + cursor: pointer; + letter-spacing: 0.04em; + text-transform: uppercase; + width: auto; + } + .scan-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--fg); + } + .scan-btn:disabled { cursor: progress; opacity: 0.6; } + .scan-status { + font-size: 0.75rem; + color: var(--muted); + margin-top: 0.4rem; + min-height: 1em; + } + .scan-status.error { color: var(--error); } + /* CodeMirror editor host — matches .output styling so input & output align */ .cm-editor { min-height: 180px; @@ -208,8 +242,13 @@
- +
+ + +
+ +
@@ -244,6 +283,96 @@ const meta = document.getElementById("meta"); const hotStatus = document.getElementById("hot-status"); const hotStatusText = document.getElementById("hot-status-text"); + const scanBtn = document.getElementById("scan-btn"); + const scanInput = document.getElementById("scan-input"); + const scanStatus = document.getElementById("scan-status"); + + // --- OCR via Tesseract.js (lazy-loaded on first use from esm.sh) -------- + // Tesseract language codes are ISO 639-2 — map from our "From" dropdown. + const TESS_LANGS = { + "English": "eng", + "Portuguese (Brazil)": "por", + "Portuguese (Portugal)": "por", + "Spanish": "spa", + "French": "fra", + "German": "deu", + "Italian": "ita", + "Dutch": "nld", + "Polish": "pol", + "Russian": "rus", + "Japanese": "jpn", + "Korean": "kor", + "Chinese (Simplified)": "chi_sim", + "Chinese (Traditional)": "chi_tra", + "Arabic": "ara", + "Hindi": "hin", + "Turkish": "tur", + "Swedish": "swe", + "Norwegian": "nor", + "Danish": "dan", + "Finnish": "fin", + "Greek": "ell", + "Hebrew": "heb", + "Czech": "ces", + "Hungarian": "hun", + "Romanian": "ron", + "Ukrainian": "ukr", + "Vietnamese": "vie", + "Thai": "tha", + "Indonesian": "ind", + }; + // "Auto-detect" — load common European set together; tesseract picks best. + const TESS_AUTO = "eng+por+deu+nld"; + + function setScanStatus(text, { error = false } = {}) { + scanStatus.textContent = text; + scanStatus.classList.toggle("error", error); + } + + async function runOCR(file) { + const src = sourceSel.value; + const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO); + + scanBtn.disabled = true; + setScanStatus("Loading OCR engine…"); + const t0 = performance.now(); + + try { + // Lazy import — first scan triggers the ~5 MB JS + language pack + // download; cached by the browser after that. + const { createWorker } = await import("https://esm.sh/tesseract.js@5"); + const worker = await createWorker(lang, 1, { + logger: (m) => { + if (m.status === "recognizing text") { + setScanStatus(`OCR ${Math.round(m.progress * 100)}%`); + } else if (m.status) { + setScanStatus(`OCR: ${m.status}`); + } + }, + }); + const { data: { text } } = await worker.recognize(file); + await worker.terminate(); + + editor.dispatch({ + changes: { from: 0, to: editor.state.doc.length, insert: text.trim() }, + selection: { anchor: 0 }, + }); + const dt = ((performance.now() - t0) / 1000).toFixed(1); + setScanStatus(`OCR done in ${dt}s (${lang})`); + } catch (e) { + console.error(e); + setScanStatus(`OCR failed: ${e.message || e}`, { error: true }); + } finally { + scanBtn.disabled = false; + scanInput.value = ""; // allow re-selecting the same file + } + } + + scanBtn.addEventListener("click", () => scanInput.click()); + scanInput.addEventListener("change", (e) => { + const file = e.target.files?.[0]; + if (file) runOCR(file); + }); // --- CodeMirror editor (vim mode on real keyboards only) ---------------- const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;