From b008c522914bd2ac476bb966b3acd29dc3c70a51 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:46:44 +0200 Subject: [PATCH] OCR: Downsample to shrinks a page box given in pixels (fixes #1848) (#2135) --- crates/engine/src/ocr.rs | 45 +++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 6bf6dc9c7..43a4d7a0a 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -30,6 +30,10 @@ pub struct OcrSettings { /// text read again, not a word in a picture. const ALREADY_ON_PAGE: f32 = 0.5; +/// The long side of an A4 sheet in points (a letter page's, 792, is shorter): "Downsample to" +/// is a resolution on paper this size or smaller. +const SHEET: f32 = 842.0; + /// How much of `word` the `text` boxes cover, from 0 to 1. Boxes are `[x0, y0, x1, y1]` in page /// view space. Overlaps between the text boxes are counted twice (glyphs rarely overlap), so the /// sum is capped at the whole word. @@ -121,11 +125,7 @@ impl OcrJob { out.push(skip("the page already contains text")); continue; } - // A scan is read at its own resolution (enlarging it only blurs the letters), at most - // the chosen one. - let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0; - let scale = self.native.get(page).copied().flatten().map_or(wanted, |n| n.clamp(1.0, wanted)); - let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + let shot = r.render(RenderRequest { page, scale: self.scale(page, info), ..Default::default() }); if let Some(e) = shot.error { out.push(skip(&e)); continue; @@ -151,6 +151,18 @@ impl OcrJob { progress(total, total); out } + + /// The render scale (pixels per point) `page` is read at. A scan is read at its own + /// resolution (enlarging it only blurs the letters), at most the chosen one. + fn scale(&self, page: usize, info: &PageInfo) -> f32 { + let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0; + let Some(native) = self.native.get(page).copied().flatten() else { return wanted }; + // A scan whose page is larger than a sheet (often a box given in pixels, one point per + // pixel, #1848) is measured as if shrunk to one: otherwise 1 px/pt is already its full + // image, and no "Downsample to" choice could ask for less. + let fit = (SHEET / info.width.max(info.height).max(1e-3)).min(1.0); + native.clamp(fit, wanted * fit) + } } impl Session { @@ -242,6 +254,7 @@ pub fn recognize_file( #[cfg(test)] mod tests { use super::*; + use crate::export; /// #1028: a recognised word over the page's own text is that text again; one in an empty /// part of the page (a picture) is new. @@ -260,6 +273,28 @@ mod tests { assert_eq!(covered([100.5, 52.0, 134.0, 61.0], &[]), 0.0); } + /// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel + /// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution + /// letter page is still never shrunk below one pixel per point. + #[test] + fn downsample_shrinks_a_page_box_given_in_pixels() { + let mut s = Session::new(); + let page = |w: u32, h: u32| export::encode_png(w, h, &vec![255; (w * h * 4) as usize]).unwrap(); + let bytes = s.create_from_images(&[("a4.png".into(), page(2480, 3508)), ("letter.png".into(), page(612, 792))]).unwrap(); + let id = s.open("scan.pdf", None, bytes, None).unwrap(); + let mut r = PageRenderer::new(s.get(id).unwrap().bytes.clone(), RenderConfig::default()); + for (dpi, a4) in [(72.0, [595, 842]), (150.0, [1240, 1754]), (300.0, [2480, 3508]), (600.0, [2480, 3508])] { + let job = s.ocr_job(id, &[], OcrSettings { dpi, ..Default::default() }).unwrap(); + for (page, want) in [(0, a4), (1, [612, 792])] { + let scale = job.scale(page, &job.infos[page]); + let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + assert!(shot.error.is_none(), "{:?}", shot.error); + let near = |got: u32, want: u32| got.abs_diff(want) <= 1; + assert!(near(shot.width, want[0]) && near(shot.height, want[1]), "{dpi} dpi, page {page}: {}x{}", shot.width, shot.height); + } + } + } + #[test] fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() { // A glyph drawn twice (fake bold) does not cover more than the whole word.