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 01/16] 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; -- 2.49.1 From 6d6b52e10e04242ce849ae886e62127594c734ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 18:39:30 +0200 Subject: [PATCH 02/16] fix: don't show "unloads in" when keep_alive=-1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the radagast server flipped to OLLAMA_KEEP_ALIVE=-1, /api/ps returns expires_at as year 4001 (effectively forever), which the old code rendered as "Hot: gemma2:9b — unloads in 17500000h". Add a 7-day threshold to formatRemaining(); anything beyond that just shows the model name with no expiry suffix. --- CHANGELOG.md | 3 +++ static/index.html | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8c4819..78f59b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. +### 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 ### Added diff --git a/static/index.html b/static/index.html index 7120f21..2d0dae5 100644 --- a/static/index.html +++ b/static/index.html @@ -547,6 +547,10 @@ if (!expiresAt) return null; const ms = new Date(expiresAt).getTime() - Date.now(); 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 mins = Math.floor(totalSec / 60); const secs = totalSec % 60; -- 2.49.1 From dcac01e51b66a197101e2f36483e32c6a50e584d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 18:48:40 +0200 Subject: [PATCH 03/16] perf(ocr): downscale photos to 1600px long edge before recognize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phone cameras emit 12+ MP images (~4000 px long edge). Tesseract on a full-resolution shot was painfully slow — minutes, not seconds. Downscaling to 1600 px on the long edge before worker.recognize() makes OCR 4-8× faster with no real accuracy loss for printed text. Implementation: createImageBitmap -> draw to a sized HTMLCanvas -> canvas.toBlob() at JPEG 0.85. Smaller-than-1600 px inputs are passed through unchanged. Status line briefly shows the downscale ratio so the timing is interpretable. --- CHANGELOG.md | 1 + static/index.html | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f59b6..5f5b13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. diff --git a/static/index.html b/static/index.html index 2d0dae5..0e6a59e 100644 --- a/static/index.html +++ b/static/index.html @@ -329,15 +329,46 @@ 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("Loading OCR engine…"); + 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"); @@ -350,7 +381,7 @@ } }, }); - const { data: { text } } = await worker.recognize(file); + const { data: { text } } = await worker.recognize(blob); await worker.terminate(); editor.dispatch({ -- 2.49.1 From 4d4e3518601dc4f1cda673bf5ad29822a23f3ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 18:54:27 +0200 Subject: [PATCH 04/16] feat(ocr): adaptive binarisation + per-word confidence filter Two layers to stop tesseract hallucinating text on phone photos: 1. Bradley adaptive threshold (O(N) via integral images) turns the downscaled grayscale image into a binary B/W mask. Cuts out gradients, shadows, and surface texture that tesseract misreads as ink. 2. After recognise(), filter data.words by confidence >= 60 and re-assemble lines from kept words. Status line surfaces how many were dropped so the cost is visible. Output of preprocess stage is PNG (not JPEG) so the sharp B/W edges don't get re-blurred. --- CHANGELOG.md | 2 + static/index.html | 108 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5b13f..edac168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. +- Adaptive thresholding (Bradley's method with integral images) binarises the downscaled image before OCR — drops hallucinated text from shadows, gradients, and surface texture on phone photos. Output is pure black-on-white, much closer to the scanned-page input Tesseract was trained on. +- Per-word confidence filter (≥ 60) drops the low-confidence noise Tesseract still emits. Re-assembles lines from kept words; status line reports how many words were dropped. ### 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. diff --git a/static/index.html b/static/index.html index 0e6a59e..7ddd98c 100644 --- a/static/index.html +++ b/static/index.html @@ -329,30 +329,97 @@ 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. + // Downscale to at most OCR_MAX_DIM on the long edge AND binarise with + // Bradley's adaptive threshold. Phone photos have gradients, shadows + // and texture that Tesseract trained on scanned pages doesn't expect, + // so it hallucinates text. Adaptive thresholding turns the input into + // a binary "ink vs paper" mask which is what the recogniser wants. const OCR_MAX_DIM = 1600; - async function downscaleForOCR(file) { + const OCR_CONFIDENCE_MIN = 60; // word-level confidence (0-100) + + async function preprocessForOCR(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 scale = Math.min(1, 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 }; + + binarizeAdaptive(ctx, w, h); + + // PNG, not JPEG — JPEG would re-blur the freshly-sharp B/W edges. + const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png")); + return { blob, w, h, scaled: scale < 1, origLong: long }; + } + + // Bradley/Roth adaptive threshold: compare each pixel against the + // mean of a local window. Computed in O(N) total via an integral + // image. Output is a 3-channel B/W image (R=G=B=0 or 255). + function binarizeAdaptive(ctx, w, h) { + const imgData = ctx.getImageData(0, 0, w, h); + const data = imgData.data; + + // 1. Grayscale via BT.601 luminance + const gray = new Uint8Array(w * h); + for (let i = 0, j = 0; i < data.length; i += 4, j++) { + gray[j] = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) | 0; + } + + // 2. Integral image (summed-area table) + const integral = new Float64Array(w * h); + for (let y = 0; y < h; y++) { + let rowSum = 0; + const yw = y * w; + for (let x = 0; x < w; x++) { + rowSum += gray[yw + x]; + integral[yw + x] = rowSum + (y > 0 ? integral[yw - w + x] : 0); + } + } + + // 3. Threshold: pixel becomes black if it's > T% below the local mean + const winSize = Math.max(15, Math.floor(w / 32)) | 1; // odd window + const halfWin = (winSize - 1) >> 1; + const T = 0.15; + + for (let y = 0; y < h; y++) { + const yw = y * w; + const y1 = Math.max(0, y - halfWin); + const y2 = Math.min(h - 1, y + halfWin); + for (let x = 0; x < w; x++) { + const x1 = Math.max(0, x - halfWin); + const x2 = Math.min(w - 1, x + halfWin); + const count = (x2 - x1 + 1) * (y2 - y1 + 1); + const A = (x1 > 0 && y1 > 0) ? integral[(y1 - 1) * w + (x1 - 1)] : 0; + const B = (y1 > 0) ? integral[(y1 - 1) * w + x2] : 0; + const C = (x1 > 0) ? integral[y2 * w + (x1 - 1)] : 0; + const D = integral[y2 * w + x2]; + const mean = (D - B - C + A) / count; + const black = gray[yw + x] < mean * (1 - T) ? 0 : 255; + const idx = (yw + x) * 4; + data[idx] = data[idx + 1] = data[idx + 2] = black; + } + } + ctx.putImageData(imgData, 0, 0); + } + + // Drop words below confidence threshold; re-assemble preserving line breaks. + function filterByConfidence(result, threshold = OCR_CONFIDENCE_MIN) { + const lines = result.lines || []; + if (!lines.length) return result.text || ""; + const keptLines = lines.map(line => { + const words = (line.words || []) + .filter(w => (w.confidence ?? 100) >= threshold) + .map(w => w.text); + return words.length ? words.join(" ") : ""; + }); + // Collapse consecutive blank lines so dropped runs don't leave gaps. + return keptLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); } async function runOCR(file) { @@ -364,9 +431,11 @@ const t0 = performance.now(); try { - const { blob, w, h, scaled, origLong } = await downscaleForOCR(file); + const { blob, w, h, scaled, origLong } = await preprocessForOCR(file); if (scaled) { - setScanStatus(`Downscaled ${origLong}px → ${Math.max(w, h)}px`); + setScanStatus(`Preprocessed ${origLong}px → ${Math.max(w, h)}px (binarised)`); + } else { + setScanStatus(`Preprocessed ${Math.max(w, h)}px (binarised)`); } // Lazy import — first scan triggers the ~5 MB JS + language pack @@ -381,15 +450,18 @@ } }, }); - const { data: { text } } = await worker.recognize(blob); + const { data } = await worker.recognize(blob); await worker.terminate(); + const text = filterByConfidence(data); + const droppedWords = (data.words || []).filter(w => (w.confidence ?? 100) < OCR_CONFIDENCE_MIN).length; editor.dispatch({ - changes: { from: 0, to: editor.state.doc.length, insert: text.trim() }, + changes: { from: 0, to: editor.state.doc.length, insert: text }, selection: { anchor: 0 }, }); const dt = ((performance.now() - t0) / 1000).toFixed(1); - setScanStatus(`OCR done in ${dt}s (${lang})`); + const dropped = droppedWords ? `, dropped ${droppedWords} low-confidence` : ""; + setScanStatus(`OCR done in ${dt}s (${lang}${dropped})`); } catch (e) { console.error(e); setScanStatus(`OCR failed: ${e.message || e}`, { error: true }); -- 2.49.1 From 74b88775508983fa12387fd7321b6cc01704d3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 19:02:51 +0200 Subject: [PATCH 05/16] feat(ocr): crop + rotate modal before recognition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After picking an image, a modal opens with cropperjs's drag-handle crop UI and Rotate left / Rotate right / Reset buttons. Cropping to just the text region is the single biggest quality win for OCR on phone photos — strips out logos, lighting transitions, and background junk that bleed past the confidence filter. - Lazy-loads cropperjs + its CSS from esm.sh on first use (no bundling; same pattern as Tesseract.js and CodeMirror). - Honours EXIF orientation from phone shots (checkOrientation). - Keyboard: Esc cancels, Enter accepts. Clicking the backdrop also cancels. - Output goes through the existing preprocessForOCR pipeline (downscale to 1600px + Bradley adaptive threshold) before Tesseract sees it. --- CHANGELOG.md | 1 + static/index.html | 179 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edac168..57c9eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. - Adaptive thresholding (Bradley's method with integral images) binarises the downscaled image before OCR — drops hallucinated text from shadows, gradients, and surface texture on phone photos. Output is pure black-on-white, much closer to the scanned-page input Tesseract was trained on. - Per-word confidence filter (≥ 60) drops the low-confidence noise Tesseract still emits. Re-assembles lines from kept words; status line reports how many words were dropped. +- Crop & rotate modal between image pick and OCR. After choosing an image, a modal pops up with cropperjs's drag-handle crop UI and Rotate left / Rotate right / Reset buttons. EXIF orientation is honoured automatically. Cancel / `Esc` / clicking the backdrop aborts; `Enter` accepts. Library lazy-loaded from esm.sh (no bundling). ### 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. diff --git a/static/index.html b/static/index.html index 7ddd98c..80a8aa2 100644 --- a/static/index.html +++ b/static/index.html @@ -190,6 +190,74 @@ } .scan-status.error { color: var(--error); } + /* Crop / rotate modal (cropperjs lives inside .modal-body) */ + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.78); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1000; + } + .modal { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + width: min(90vw, 900px); + max-height: 90vh; + display: flex; + flex-direction: column; + } + .modal-header { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + font-size: 0.85rem; + color: var(--muted); + letter-spacing: 0.04em; + } + .modal-body { + flex: 1; + min-height: 0; + max-height: 70vh; + background: #000; + } + .modal-body img { display: block; max-width: 100%; } + .modal-footer { + padding: 0.75rem 1rem; + border-top: 1px solid var(--border); + display: flex; + gap: 0.5rem; + justify-content: space-between; + flex-wrap: wrap; + } + .modal-footer .group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + .modal-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + padding: 0.4rem 0.9rem; + border-radius: 4px; + cursor: pointer; + margin: 0; + width: auto; + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + } + .modal-btn:hover { border-color: var(--accent); color: var(--fg); } + .modal-btn.primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); + } + .modal-btn.primary:hover { color: #fff; } + /* CodeMirror editor host — matches .output styling so input & output align */ .cm-editor { min-height: 180px; @@ -329,6 +397,95 @@ scanStatus.classList.toggle("error", error); } + // Crop + rotate modal. Lazy-loads cropperjs + its CSS from esm.sh on + // first use. Resolves with a Blob (cropped, rotation baked in) or null + // if the user cancelled. + let cropperCssLoaded = false; + async function openCropper(file) { + if (!cropperCssLoaded) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "https://esm.sh/cropperjs@1/dist/cropper.min.css"; + document.head.appendChild(link); + cropperCssLoaded = true; + } + const { default: Cropper } = await import("https://esm.sh/cropperjs@1"); + + return new Promise((resolve) => { + const backdrop = document.createElement("div"); + backdrop.className = "modal-backdrop"; + const modal = document.createElement("div"); + modal.className = "modal"; + const url = URL.createObjectURL(file); + modal.innerHTML = ` + + + + `; + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + const img = modal.querySelector("img"); + img.src = url; + const cropper = new Cropper(img, { + viewMode: 1, + autoCropArea: 1, + background: false, + // Honour EXIF orientation from the phone shot. + checkOrientation: true, + }); + + const cleanup = () => { + cropper.destroy(); + URL.revokeObjectURL(url); + backdrop.remove(); + document.removeEventListener("keydown", onKey); + }; + const finish = (blob) => { cleanup(); resolve(blob); }; + + const onKey = (e) => { + if (e.key === "Escape") finish(null); + else if (e.key === "Enter") emit("use"); + }; + document.addEventListener("keydown", onKey); + + function emit(act) { + if (act === "rotL") cropper.rotate(-90); + else if (act === "rotR") cropper.rotate(90); + else if (act === "reset") cropper.reset(); + else if (act === "cancel") finish(null); + else if (act === "use") { + const canvas = cropper.getCroppedCanvas({ + // Hand off something Tesseract can chew on without being huge. + // preprocessForOCR will downscale further if needed. + maxWidth: OCR_MAX_DIM * 2, + maxHeight: OCR_MAX_DIM * 2, + }); + canvas.toBlob(blob => finish(blob), "image/png"); + } + } + + modal.addEventListener("click", (e) => { + const act = e.target.closest("[data-act]")?.dataset.act; + if (act) emit(act); + }); + // Click on backdrop (outside the modal) = cancel + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) finish(null); + }); + }); + } + // Downscale to at most OCR_MAX_DIM on the long edge AND binarise with // Bradley's adaptive threshold. Phone photos have gradients, shadows // and texture that Tesseract trained on scanned pages doesn't expect, @@ -427,11 +584,31 @@ const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO); scanBtn.disabled = true; + setScanStatus("Loading crop editor…"); + + let cropped; + try { + cropped = await openCropper(file); + } catch (e) { + console.error(e); + setScanStatus(`Crop editor failed: ${e.message || e}`, { error: true }); + scanBtn.disabled = false; + scanInput.value = ""; + return; + } + if (!cropped) { + // User cancelled + setScanStatus(""); + scanBtn.disabled = false; + scanInput.value = ""; + return; + } + setScanStatus("Preparing image…"); const t0 = performance.now(); try { - const { blob, w, h, scaled, origLong } = await preprocessForOCR(file); + const { blob, w, h, scaled, origLong } = await preprocessForOCR(cropped); if (scaled) { setScanStatus(`Preprocessed ${origLong}px → ${Math.max(w, h)}px (binarised)`); } else { -- 2.49.1 From a4064d0d72b395ac93ac00780c62f260c6075322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 19:10:22 +0200 Subject: [PATCH 06/16] ux(ocr): camera FAB + modal padding for Android edge gestures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scan button is now a 📷 floating action button at bottom-right of the viewport (camera emoji per explicit request). Replaces the small "Scan image" button in the input header. - Crop modal: inner padding on .modal-body so cropperjs's bottom-right corner handle isn't on top of Android's edge-swipe back-gesture zone. Backdrop also respects iOS safe-area insets (env(safe-area-inset-{right,left,bottom})). --- CHANGELOG.md | 4 ++++ static/index.html | 58 ++++++++++++++++++++++++----------------------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c9eab..adc2023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Per-word confidence filter (≥ 60) drops the low-confidence noise Tesseract still emits. Re-assembles lines from kept words; status line reports how many words were dropped. - Crop & rotate modal between image pick and OCR. After choosing an image, a modal pops up with cropperjs's drag-handle crop UI and Rotate left / Rotate right / Reset buttons. EXIF orientation is honoured automatically. Cancel / `Esc` / clicking the backdrop aborts; `Enter` accepts. Library lazy-loaded from esm.sh (no bundling). +### Changed +- "Scan image" button replaced with a 📷 floating action button at the bottom-right of the viewport (was a small button in the input pane header). Mobile thumb-friendly, doesn't crowd the input label. +- Crop modal has inner padding so cropperjs's corner handles stay away from the screen's right/bottom edges — fixes the bottom-right resize handle triggering Android's edge-swipe back gesture. Also respects iOS safe-area insets. + ### 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. diff --git a/static/index.html b/static/index.html index 80a8aa2..5b29458 100644 --- a/static/index.html +++ b/static/index.html @@ -156,32 +156,31 @@ 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; + /* Floating action button for OCR scan — bottom-right, mobile-thumb friendly */ + .scan-fab { + position: fixed; + bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px)); + right: calc(1.5rem + env(safe-area-inset-right, 0px)); + width: 56px; + height: 56px; + padding: 0; margin: 0; - border-radius: 4px; + border-radius: 50%; + background: var(--accent); + color: #fff; + border: none; cursor: pointer; - letter-spacing: 0.04em; - text-transform: uppercase; - width: auto; + font-size: 1.75rem; + line-height: 1; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s; } - .scan-btn:hover:not(:disabled) { - border-color: var(--accent); - color: var(--fg); - } - .scan-btn:disabled { cursor: progress; opacity: 0.6; } + .scan-fab:hover:not(:disabled) { transform: scale(1.06); } + .scan-fab:disabled { opacity: 0.6; cursor: progress; } .scan-status { font-size: 0.75rem; color: var(--muted); @@ -198,7 +197,9 @@ display: flex; align-items: center; justify-content: center; - padding: 1rem; + /* Generous side padding pushes the modal in from Android's edge-swipe + gesture zone (~24dp from screen edges triggers back/home). */ + padding: 1rem max(1rem, env(safe-area-inset-right, 0px)) 1rem max(1rem, env(safe-area-inset-left, 0px)); z-index: 1000; } .modal { @@ -222,6 +223,8 @@ min-height: 0; max-height: 70vh; background: #000; + /* Inset so cropper's corner handles never sit on a screen-edge gesture zone */ + padding: 1.25rem; } .modal-body img { display: block; max-width: 100%; } .modal-footer { @@ -310,10 +313,7 @@
-
- - -
+
@@ -328,6 +328,8 @@
+ + -- 2.49.1 From bde042a459f12db3d079f7e743d2a936a616b97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 19:58:43 +0200 Subject: [PATCH 13/16] ux: 2-col controls, model in a kebab menu, Dutch default, drop iOS code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Controls go 3 cols → 2 cols (From / To). Model selector moves into a kebab (⋮ more_vert) menu in the top-left corner, using a native
element so toggle and a11y come for free. - Default source language: Dutch (was Auto-detect). Target stays English. Quickest path to a translation since the typical use case is reading Dutch text. - Drop the VisualViewport listener and --kb-inset CSS var — Android Chrome already handles keyboard with the interactive-widget=resizes-content meta directive; iOS Safari isn't in scope. FAB bottom calc reverts to just safe-area-inset. --- CHANGELOG.md | 5 ++- static/index.html | 92 ++++++++++++++++++++++++++++++----------------- 2 files changed, 63 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e5e4f7..574c5d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,10 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed - "Scan image" button replaced with a floating action button at the bottom-right of the viewport, using a Material Symbols Outlined camera glyph (`photo_camera`) for consistent rendering across platforms (was a small text button in the input pane header). - "Translate" inline button replaced with a primary FAB using the `translate` Material Symbols icon, stacked directly under the camera FAB at the bottom-right corner (closest to the thumb on phones). The camera FAB is now the *secondary* in the stack, just above. Both 56 × 56 px, same accent fill. The translate FAB gains a pulsing-ring `busy` animation during a running translation; `goBtn.textContent` swap is gone (was meaningless for an icon-only button). `body` got `~9 rem` of bottom padding so content scrolls clear of the stack. -- FAB stack rides above the on-screen keyboard on mobile. Added `interactive-widget=resizes-content` to the viewport meta (handles Chrome on Android) and a `VisualViewport` listener that exposes the keyboard's intrusion as a `--kb-inset` CSS custom property (handles iOS Safari). The FAB `bottom` uses `max(--kb-inset, env(safe-area-inset-bottom))` so the keyboard inset wins when it's open and the home-indicator inset wins when it isn't. +- FAB stack rides above the on-screen keyboard on mobile via `interactive-widget=resizes-content` in the viewport meta tag (handled by Chrome on Android). +- Layout: From / To dropdowns now occupy 2 columns at the top (was 3 columns including Model). +- Model selector moved into a kebab (⋮) menu in the top-left corner. Uses a native `
` element so toggle/keyboard-accessibility works without extra JS. +- Default source language is now Dutch (was Auto-detect). Target stays English. - Crop modal: - No default crop box. User drags on the image to draw the region; falls back to full image if they confirm without drawing. - Buttons are now Material Symbols icons: `rotate_left`, `rotate_right`, `invert_colors` (new — see below), `check`. Cancel kept as text. The `check` (Use this) button is larger and more prominent. The Reset button was removed (rarely useful with no default box). diff --git a/static/index.html b/static/index.html index b533b1c..f283e4b 100644 --- a/static/index.html +++ b/static/index.html @@ -46,12 +46,55 @@ .sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; } .controls { display: grid; - grid-template-columns: 1fr 1fr 1fr; + grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; } - @media (max-width: 640px) { - .controls { grid-template-columns: 1fr; } + + /* Kebab menu (top-left) housing the Model dropdown — keeps the main + controls focused on the From/To pair. */ + .kebab { + position: fixed; + top: calc(1rem + env(safe-area-inset-top, 0px)); + left: calc(1rem + env(safe-area-inset-left, 0px)); + z-index: 50; + } + .kebab > summary { + list-style: none; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); + } + .kebab > summary::-webkit-details-marker { display: none; } + .kebab > summary::marker { display: none; } + .kebab > summary .material-symbols-outlined { font-size: 22px; } + .kebab[open] > summary { border-color: var(--accent); color: var(--accent); } + .kebab-panel { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem; + min-width: 220px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3); + } + .kebab-panel label { + display: block; + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 0.25rem; } label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 0.25rem; text-transform: uppercase; letter-spacing: 0.08em; } select, textarea, button { @@ -190,15 +233,15 @@ .fab .material-symbols-outlined { font-size: 28px; } .fab:hover:not(:disabled) { transform: scale(1.06); } .fab:disabled { opacity: 0.55; cursor: progress; } - /* --kb-inset is set from JS based on VisualViewport so the FABs ride - above the on-screen keyboard. When there's no keyboard it's 0 and the - safe-area inset wins via max(). */ + /* Android Chrome shrinks the layout viewport when the soft keyboard + opens (via `interactive-widget=resizes-content` on the viewport meta), + so plain `bottom: 1.5rem` is already above the keyboard. */ .fab-primary { - bottom: calc(1.5rem + max(var(--kb-inset, 0px), env(safe-area-inset-bottom, 0px))); + bottom: calc(1.5rem + env(safe-area-inset-bottom, 0px)); } .fab-secondary { /* sits above the primary: 56 (primary height) + 0.75rem gap + base offset */ - bottom: calc(1.5rem + 56px + 0.75rem + max(var(--kb-inset, 0px), env(safe-area-inset-bottom, 0px))); + bottom: calc(1.5rem + 56px + 0.75rem + env(safe-area-inset-bottom, 0px)); } .fab.busy { animation: fab-pulse 1.4s ease-in-out infinite; @@ -389,6 +432,13 @@ +
+ more_vert +
+ + +
+

finrod

Translation via Ollama · loremaster of tongues
@@ -402,10 +452,6 @@
-
- - -
@@ -906,7 +952,7 @@ o2.value = lang; o2.textContent = lang; targetSel.appendChild(o2); } - sourceSel.value = "auto"; + sourceSel.value = "Dutch"; targetSel.value = "English"; } @@ -1143,26 +1189,6 @@ Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" }); } - // Keep the FABs floating above the on-screen keyboard when it's open. - // On Chrome+Android the `interactive-widget=resizes-content` meta directive - // already shrinks the layout viewport — kb-inset stays 0 there. - // On iOS Safari (which ignores the meta) the layout viewport stays full - // and we compute the hidden bottom strip from VisualViewport. - function updateKbInset() { - const vv = window.visualViewport; - if (!vv) { - document.documentElement.style.setProperty("--kb-inset", "0px"); - return; - } - const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); - document.documentElement.style.setProperty("--kb-inset", inset + "px"); - } - if (window.visualViewport) { - window.visualViewport.addEventListener("resize", updateKbInset); - window.visualViewport.addEventListener("scroll", updateKbInset); - } - updateKbInset(); - fillLangs(); loadModels().then(() => refreshLoaded(true)); -- 2.49.1 From 1fffe8b7e1d7cd785a45abf4c94bb55990afcca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 20:02:11 +0200 Subject: [PATCH 14/16] ux: hamburger + slide-out side drawer (not a kebab popover) Replace the
-based kebab popover with a proper Material- style navigation drawer: - Hamburger button (menu icon) at top-left. -

finrod

Translation via Ollama · loremaster of tongues
@@ -1189,6 +1248,28 @@ Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" }); } + // Side drawer (Settings) wiring — opens from the top-left hamburger. + const drawerEl = document.getElementById("drawer"); + const drawerBackdrop = document.getElementById("drawer-backdrop"); + const drawerToggle = document.getElementById("drawer-toggle"); + const drawerCloseBtn = document.getElementById("drawer-close"); + function openDrawer() { + drawerEl.classList.add("open"); + drawerBackdrop.classList.add("open"); + drawerEl.setAttribute("aria-hidden", "false"); + } + function closeDrawer() { + drawerEl.classList.remove("open"); + drawerBackdrop.classList.remove("open"); + drawerEl.setAttribute("aria-hidden", "true"); + } + drawerToggle.addEventListener("click", openDrawer); + drawerCloseBtn.addEventListener("click", closeDrawer); + drawerBackdrop.addEventListener("click", closeDrawer); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && drawerEl.classList.contains("open")) closeDrawer(); + }); + fillLangs(); loadModels().then(() => refreshLoaded(true)); -- 2.49.1 From b4990e17297bdc298f26648fa4885617801e0fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 20:09:36 +0200 Subject: [PATCH 15/16] ux: drop page header, move Finrod wordmark into the drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The h1 + tagline at the top of the main view were just taking vertical space. Strip them. The "Finrod" wordmark now lives only inside the side drawer, set in Cinzel (classical-inscription serif, loaded from Google Fonts) alongside the Star of Fëanor mark — matches the favicon and the loremaster vibe. Body padding-top adjusted to 3.5 rem so the controls clear the fixed hamburger button at the top-left. --- CHANGELOG.md | 1 + static/index.html | 46 +++++++++++++++++++++++++++------------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f46b15..5da36f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - FAB stack rides above the on-screen keyboard on mobile via `interactive-widget=resizes-content` in the viewport meta tag (handled by Chrome on Android). - Layout: From / To dropdowns now occupy 2 columns at the top (was 3 columns including Model). - Model selector moved into a slide-out side drawer (hamburger menu in the top-left). Drawer opens from the left with a backdrop, closes via the X button, backdrop click, or `Esc`. Honours Android safe-area insets. +- Page header (`h1 finrod` + tagline) removed from the main view to recover vertical space. The "Finrod" wordmark now lives only inside the side drawer, set in Cinzel (classical-inscription serif, loaded from Google Fonts) alongside the Star of Fëanor mark. - Default source language is now Dutch (was Auto-detect). Target stays English. - Crop modal: - No default crop box. User drags on the image to draw the region; falls back to full image if they confirm without drawing. diff --git a/static/index.html b/static/index.html index 38c20e6..b9e74e2 100644 --- a/static/index.html +++ b/static/index.html @@ -6,6 +6,7 @@ finrod — translator +