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;