Compare commits

1 Commits

Author SHA1 Message Date
João Pedro Battistella Nadas
123ce59595 feat: OCR a camera shot / image into the input via Tesseract.js
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 <input type="file">.
- 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.
2026-06-09 18:42:19 +02:00
2 changed files with 133 additions and 1 deletions

View File

@@ -7,6 +7,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [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 ## [0.2.0] - 2026-06-09
### Added ### Added

View File

@@ -156,6 +156,40 @@
box-shadow: none; 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 */ /* CodeMirror editor host — matches .output styling so input & output align */
.cm-editor { .cm-editor {
min-height: 180px; min-height: 180px;
@@ -208,8 +242,13 @@
<div class="panes"> <div class="panes">
<div> <div>
<div class="input-header">
<label for="input">Input</label> <label for="input">Input</label>
<button type="button" id="scan-btn" class="scan-btn" title="Extract text from a camera shot or image file">Scan image</button>
</div>
<div id="input"></div> <div id="input"></div>
<input type="file" id="scan-input" accept="image/*" capture="environment" hidden>
<div id="scan-status" class="scan-status"></div>
</div> </div>
<div> <div>
<label for="output">Translation</label> <label for="output">Translation</label>
@@ -244,6 +283,96 @@
const meta = document.getElementById("meta"); const meta = document.getElementById("meta");
const hotStatus = document.getElementById("hot-status"); const hotStatus = document.getElementById("hot-status");
const hotStatusText = document.getElementById("hot-status-text"); 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) ---------------- // --- CodeMirror editor (vim mode on real keyboards only) ----------------
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches; const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;