feat(ocr): crop + rotate modal before recognition

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.
This commit is contained in:
João Pedro Battistella Nadas
2026-06-09 19:02:51 +02:00
parent 4d4e351860
commit 74b8877550
2 changed files with 179 additions and 1 deletions

View File

@@ -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 = `
<div class="modal-header">Crop &amp; rotate before OCR</div>
<div class="modal-body"><img alt="" /></div>
<div class="modal-footer">
<div class="group">
<button class="modal-btn" data-act="rotL">Rotate left</button>
<button class="modal-btn" data-act="rotR">Rotate right</button>
<button class="modal-btn" data-act="reset">Reset</button>
</div>
<div class="group">
<button class="modal-btn" data-act="cancel">Cancel</button>
<button class="modal-btn primary" data-act="use">Use this</button>
</div>
</div>
`;
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 {