v0.3.0: OCR via camera, FAB UX, side drawer #4
@@ -9,6 +9,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Added
|
### 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.
|
- "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
|
### 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.
|
- "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.
|
||||||
|
|||||||
@@ -329,15 +329,46 @@
|
|||||||
scanStatus.classList.toggle("error", error);
|
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) {
|
async function runOCR(file) {
|
||||||
const src = sourceSel.value;
|
const src = sourceSel.value;
|
||||||
const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO);
|
const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO);
|
||||||
|
|
||||||
scanBtn.disabled = true;
|
scanBtn.disabled = true;
|
||||||
setScanStatus("Loading OCR engine…");
|
setScanStatus("Preparing image…");
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
|
|
||||||
try {
|
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
|
// Lazy import — first scan triggers the ~5 MB JS + language pack
|
||||||
// download; cached by the browser after that.
|
// download; cached by the browser after that.
|
||||||
const { createWorker } = await import("https://esm.sh/tesseract.js@5");
|
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();
|
await worker.terminate();
|
||||||
|
|
||||||
editor.dispatch({
|
editor.dispatch({
|
||||||
|
|||||||
Reference in New Issue
Block a user