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.
This commit is contained in:
@@ -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 @@
|
||||
|
||||
<div class="panes">
|
||||
<div>
|
||||
<label for="input">Input</label>
|
||||
<div class="input-header">
|
||||
<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>
|
||||
<input type="file" id="scan-input" accept="image/*" capture="environment" hidden>
|
||||
<div id="scan-status" class="scan-status"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="output">Translation</label>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user