Compare commits
3 Commits
v0.2.0
...
dcac01e51b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcac01e51b | ||
|
|
6d6b52e10e | ||
|
|
123ce59595 |
@@ -7,6 +7,13 @@ 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.
|
||||||
|
- Images are downscaled to 1600 px on the long edge before OCR. A 12 MP phone photo (~4000 px) drops to ~1.3 MP, making Tesseract 4-8× faster with no real accuracy loss for printed text. Smaller-than-1600 px inputs are passed through unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- "Hot: …" status no longer claims "unloads in X" when the server's `keep_alive` is `-1` (Ollama returns an absurd year-4001 `expires_at`). Threshold: any expiry >7 days drops the suffix and just shows the model name.
|
||||||
|
|
||||||
## [0.2.0] - 2026-06-09
|
## [0.2.0] - 2026-06-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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,127 @@
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downscale to at most OCR_MAX_DIM on the long edge. A typical phone
|
||||||
|
// photo is 4000+ px on the long edge — Tesseract is 4-8× faster at
|
||||||
|
// 1600 px with no real accuracy loss for printed text.
|
||||||
|
const OCR_MAX_DIM = 1600;
|
||||||
|
async function downscaleForOCR(file) {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const long = Math.max(bitmap.width, bitmap.height);
|
||||||
|
if (long <= OCR_MAX_DIM) {
|
||||||
|
bitmap.close?.();
|
||||||
|
return { blob: file, w: bitmap.width, h: bitmap.height, scaled: false };
|
||||||
|
}
|
||||||
|
const scale = OCR_MAX_DIM / long;
|
||||||
|
const w = Math.round(bitmap.width * scale);
|
||||||
|
const h = Math.round(bitmap.height * scale);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||||
|
bitmap.close?.();
|
||||||
|
const blob = await new Promise(resolve =>
|
||||||
|
canvas.toBlob(resolve, "image/jpeg", 0.85)
|
||||||
|
);
|
||||||
|
return { blob, w, h, scaled: true, origLong: long };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOCR(file) {
|
||||||
|
const src = sourceSel.value;
|
||||||
|
const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO);
|
||||||
|
|
||||||
|
scanBtn.disabled = true;
|
||||||
|
setScanStatus("Preparing image…");
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { blob, w, h, scaled, origLong } = await downscaleForOCR(file);
|
||||||
|
if (scaled) {
|
||||||
|
setScanStatus(`Downscaled ${origLong}px → ${Math.max(w, h)}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(blob);
|
||||||
|
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;
|
||||||
@@ -418,6 +578,10 @@
|
|||||||
if (!expiresAt) return null;
|
if (!expiresAt) return null;
|
||||||
const ms = new Date(expiresAt).getTime() - Date.now();
|
const ms = new Date(expiresAt).getTime() - Date.now();
|
||||||
if (!isFinite(ms) || ms <= 0) return null;
|
if (!isFinite(ms) || ms <= 0) return null;
|
||||||
|
// Ollama's keep_alive=-1 returns expires_at far in the future (year 4001).
|
||||||
|
// Any realistic finite keep_alive is hours at most, so treat >7 days
|
||||||
|
// as "never unloads" and drop the misleading suffix.
|
||||||
|
if (ms > 7 * 24 * 60 * 60 * 1000) return null;
|
||||||
const totalSec = Math.floor(ms / 1000);
|
const totalSec = Math.floor(ms / 1000);
|
||||||
const mins = Math.floor(totalSec / 60);
|
const mins = Math.floor(totalSec / 60);
|
||||||
const secs = totalSec % 60;
|
const secs = totalSec % 60;
|
||||||
|
|||||||
Reference in New Issue
Block a user