pdf_engine/ocr_need.rs
1//! Scan detection: how much OCR a page needs, before any recognizer runs.
2//!
3//! [`page_needs_ocr`] surveys one page through the interpreter and reports its
4//! shape as an [`OcrNeed`]: born-digital text ([`OcrNeed::None`]), an image of
5//! a page with no usable text layer ([`OcrNeed::Full`]), or text around image
6//! regions that carry no text of their own ([`OcrNeed::Partial`]).
7//!
8//! The survey counts what the page draws rather than what its resources name:
9//! a `/XObject` entry nothing ever draws is not a scan of anything. Glyphs are
10//! split three ways -- readable, invisible (render mode 3) and garbage (no
11//! usable Unicode mapping) -- and only the readable ones count as a text
12//! layer. Image coverage is the union of the drawn image rectangles over the
13//! page rectangle, so overlapping images are not counted twice.
14//!
15//! This is page SHAPE, not policy. Whether a [`OcrNeed::Full`] page is OCR'd is
16//! decided by the caller's mode (off / auto / force); in particular an already
17//! searchable scan -- image plus a full invisible text layer -- still reports
18//! [`OcrNeed::Full`], because an invisible layer is not trusted to be the
19//! page's text. Skipping pages whose invisible layer already explains them is a
20//! policy refinement for the OCR wiring, not a detection verdict.
21//!
22//! ## Thresholds
23//!
24//! | Constant | Value | Meaning |
25//! |---|---|---|
26//! | `MIN_READABLE_GLYPHS` | 40 | readable visible glyphs that make a text layer |
27//! | `FULL_PAGE_IMAGE_COVERAGE` | 0.50 | image share of the page that makes a scan Full |
28//! | `PARTIAL_MIN_IMAGE_FRACTION` | 0.15 | image share of the page that makes one region large |
29//! | `PARTIAL_MAX_TEXT_OVERLAP` | 0.05 | text share of an image below which it is uncovered |
30//!
31//! Each constant carries its own rationale below. The 40-glyph floor is shared
32//! with the vision routing's character floor: a scan regularly carries a
33//! stamped page number or a scanner header in real text while the body is
34//! pixels, and forty is above that and far below what any page of prose draws.
35
36// Copyright (c) 2026 Innovation Trigger B.V.
37//
38// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
39// the PDFluent Commercial Licence. See the LICENSE file in this repository --
40// that file travels with the copy you received, which a URL does not.
41
42use std::cmp::Ordering;
43
44use kurbo::{Affine, Point, Rect};
45use pdf_render::pdf_interpret::cmap::BfString;
46use pdf_render::pdf_interpret::font::Glyph;
47use pdf_render::pdf_interpret::{
48 BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
49};
50
51/// How much OCR a page needs.
52///
53/// `#[non_exhaustive]` because finer verdicts may land in a published
54/// crate: a caller that matches on this keeps a fallback arm, and adding
55/// one must not be a breaking change.
56#[non_exhaustive]
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum OcrNeed {
59 /// The page reads as it is: it has a text layer and no large image region
60 /// without text over it. This is also the answer for a blank page and for
61 /// a page that cannot be surveyed at all -- detection never invents work
62 /// it cannot see.
63 None,
64 /// The page is a scan: no text layer -- nothing drawn, or only invisible
65 /// or garbage glyphs -- while images cover at least half of it. The whole
66 /// page wants recognizing.
67 Full,
68 /// The page mixes text with at least one large image region (at least 15%
69 /// of the page) that has next to no text over it (under 5% of the region).
70 /// Only those regions want recognizing; the rest already reads.
71 Partial,
72}
73
74/// How much OCR page `page` (0-based) of `doc` needs.
75///
76/// A page index that does not exist, a page with no area, and a page the
77/// interpreter cannot walk all report [`OcrNeed::None`]: there is nothing here
78/// OCR could read, or nothing this survey could see.
79pub fn page_needs_ocr(doc: &crate::document::PdfDocument, page: usize) -> OcrNeed {
80 doc.page_needs_ocr(page)
81}
82
83/// Readable visible glyphs that make a text layer.
84///
85/// Forty, shared with the vision routing's character floor: a scan regularly
86/// carries a stamped page number or a scanner header in real text while the
87/// body is pixels, and forty is above that and far below what any page of
88/// prose draws. Glyphs, not characters: a ligature is one glyph whatever it
89/// spells.
90const MIN_READABLE_GLYPHS: usize = 40;
91
92/// Image share of the page that makes a scan [`OcrNeed::Full`].
93///
94/// Half. A scanner fills the frame, so a real scan lands near 1.0; a page of
95/// prose with figures lands far below. "Most of the page" is therefore
96/// uncontroversial anywhere near the middle, and 0.5 is where the two
97/// populations sit.
98const FULL_PAGE_IMAGE_COVERAGE: f64 = 0.5;
99
100/// Image share of the page that makes one region large enough to OCR on its own.
101///
102/// Fifteen per cent: a half-page figure is in, a logo or an icon is out. On a
103/// letter page that is about 270 by 270 points -- well above anything that is
104/// decoration, well below anything that is the page.
105const PARTIAL_MIN_IMAGE_FRACTION: f64 = 0.15;
106
107/// Text share of an image below which the image counts as uncovered.
108///
109/// Five per cent. A caption overlapping an image's edge, or a glyph box grazing
110/// it, must not mark the region as read; anything genuinely set over the image
111/// covers far more.
112const PARTIAL_MAX_TEXT_OVERLAP: f64 = 0.05;
113
114/// The survey behind [`page_needs_ocr`]: an interpreter device that counts
115/// readable glyphs and records where text and images sit.
116pub(crate) struct OcrSurveyDevice {
117 page: Rect,
118 /// Boxes of the readable visible glyphs, in device space.
119 text_boxes: Vec<Rect>,
120 readable: usize,
121 images: Vec<Rect>,
122 /// Fill glyphs are drawn again as strokes under render mode 2; the second
123 /// pass is the same character (see `TextExtractionDevice`) and must not be
124 /// counted twice.
125 last_fill: Option<([f64; 6], Option<BfString>)>,
126}
127
128impl OcrSurveyDevice {
129 /// A survey over a page `width` by `height` in device units.
130 pub(crate) fn new(width: f64, height: f64) -> Self {
131 Self {
132 page: Rect::new(0.0, 0.0, width.max(0.0), height.max(0.0)),
133 text_boxes: Vec::new(),
134 readable: 0,
135 images: Vec::new(),
136 last_fill: None,
137 }
138 }
139
140 /// The verdict over what the page drew.
141 pub(crate) fn decide(&self) -> OcrNeed {
142 let page_area = rect_area(self.page);
143 if page_area <= 0.0 {
144 return OcrNeed::None;
145 }
146 let clipped: Vec<Rect> = self
147 .images
148 .iter()
149 .filter_map(|r| intersect(*r, self.page))
150 .collect();
151 if self.readable < MIN_READABLE_GLYPHS
152 && union_area(&clipped) / page_area >= FULL_PAGE_IMAGE_COVERAGE
153 {
154 return OcrNeed::Full;
155 }
156 for image in &clipped {
157 let image_area = rect_area(*image);
158 if image_area / page_area < PARTIAL_MIN_IMAGE_FRACTION {
159 continue;
160 }
161 let covered: f64 = self
162 .text_boxes
163 .iter()
164 .filter_map(|t| intersect(*t, *image))
165 .map(rect_area)
166 .sum();
167 if covered / image_area < PARTIAL_MAX_TEXT_OVERLAP {
168 return OcrNeed::Partial;
169 }
170 }
171 OcrNeed::None
172 }
173}
174
175impl Device<'_> for OcrSurveyDevice {
176 fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
177 fn set_blend_mode(&mut self, _: BlendMode) {}
178 fn draw_path(&mut self, _: &kurbo::BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {}
179 fn push_clip_path(&mut self, _: &ClipPath) {}
180 fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
181 fn pop_clip_path(&mut self) {}
182 fn pop_transparency_group(&mut self) {}
183
184 fn draw_glyph(
185 &mut self,
186 glyph: &Glyph<'_>,
187 transform: Affine,
188 glyph_transform: Affine,
189 _: &Paint<'_>,
190 draw_mode: &GlyphDrawMode,
191 ) {
192 // Render mode 2 draws each glyph filled and then stroked; the stroke
193 // that repeats the fill just before it, at the same transform, is the
194 // same character and is counted once.
195 let placed = (transform * glyph_transform).as_coeffs();
196 let repeats_fill = matches!(draw_mode, GlyphDrawMode::Stroke(_))
197 && self
198 .last_fill
199 .as_ref()
200 .is_some_and(|(at, unicode)| *at == placed && *unicode == glyph.as_unicode());
201 self.last_fill = match draw_mode {
202 GlyphDrawMode::Fill => Some((placed, glyph.as_unicode())),
203 _ => None,
204 };
205 if repeats_fill {
206 return;
207 }
208 // Invisible text is not a text layer this survey trusts: it may be a
209 // stale OCR pass in the wrong language rather than the page's words.
210 if matches!(draw_mode, GlyphDrawMode::Invisible) {
211 return;
212 }
213 if !glyph_is_readable(glyph) {
214 return;
215 }
216 self.readable += 1;
217 self.text_boxes
218 .push(glyph_box(transform * glyph_transform, advance_width(glyph)));
219 }
220
221 fn draw_image(&mut self, image: Image<'_, '_>, transform: Affine) {
222 // The transform maps the image's pixel rectangle into device space.
223 let w = f64::from(image.width().max(1));
224 let h = f64::from(image.height().max(1));
225 let corners = [
226 transform * Point::new(0.0, 0.0),
227 transform * Point::new(w, 0.0),
228 transform * Point::new(w, h),
229 transform * Point::new(0.0, h),
230 ];
231 let (mut x0, mut y0, mut x1, mut y1) = (
232 f64::INFINITY,
233 f64::INFINITY,
234 f64::NEG_INFINITY,
235 f64::NEG_INFINITY,
236 );
237 for p in corners {
238 x0 = x0.min(p.x);
239 y0 = y0.min(p.y);
240 x1 = x1.max(p.x);
241 y1 = y1.max(p.y);
242 }
243 // A bounding box rather than the parallelogram: a rotated image counts
244 // a little more than it covers, which errs towards OCRing it.
245 self.images.push(Rect::new(x0, y0, x1, y1));
246 }
247
248 fn draw_placed_text(&mut self, text: &str, rect: Rect) {
249 // A form value with no appearance stream: real text at a known place,
250 // which the extractor reads and this survey counts with it.
251 let readable: usize = text.chars().filter(|c| char_is_readable(*c)).count();
252 if readable > 0 {
253 self.readable += readable;
254 self.text_boxes.push(rect);
255 }
256 }
257}
258
259/// Advance width in em of one glyph, for its box.
260fn advance_width(glyph: &Glyph<'_>) -> f64 {
261 match glyph {
262 Glyph::Outline(outline) => outline
263 .advance_width()
264 .map(|w| f64::from(w) / 1000.0)
265 .unwrap_or(0.5),
266 Glyph::Type3(_) => 0.5,
267 }
268}
269
270/// Device-space box of a glyph: origin plus advance by a full em high.
271///
272/// An approximation in the extractor's own style -- ascent to descent rather
273/// than ink -- which can only over-cover an image and therefore errs towards
274/// calling a region read.
275fn glyph_box(composed: Affine, advance_em: f64) -> Rect {
276 let coeffs = composed.as_coeffs();
277 let (x, y) = (coeffs[4], coeffs[5]);
278 let scale = (coeffs[0] * coeffs[0] + coeffs[1] * coeffs[1]).sqrt().abs();
279 let font_size = scale * 1000.0;
280 let width = (advance_em * font_size).max(font_size * 0.25);
281 Rect::new(
282 x.min(x + width),
283 y.min(y + font_size),
284 x.max(x + width),
285 y.max(y + font_size),
286 )
287}
288
289/// Whether a glyph carries text worth counting.
290///
291/// Readable is "mapped and not garbage": a glyph with no Unicode mapping
292/// (`.notdef`, a broken encoding, a picture font without `/ToUnicode`) is the
293/// common case, and an opaque mapping ([`is_opaque`]) or the replacement
294/// character completes it. Everything else -- letters, numbers, punctuation,
295/// symbols, marks, spaces -- reads.
296fn glyph_is_readable(glyph: &Glyph<'_>) -> bool {
297 match glyph.as_unicode() {
298 None => false,
299 Some(BfString::Char(c)) => char_is_readable(c),
300 Some(BfString::String(s)) => {
301 let mut any = false;
302 for c in s.chars() {
303 if !char_is_readable(c) {
304 return false;
305 }
306 any = true;
307 }
308 any
309 }
310 }
311}
312
313/// Whether one mapped character is text rather than garbage.
314///
315/// The mapping chain's own definition of opaque, plus the replacement
316/// character: tab, line feed, form feed and carriage return say where the text
317/// breaks and read; U+FFFD is what a decoder emits when it could not decode,
318/// so a page of it is a page the fonts could not say.
319fn char_is_readable(c: char) -> bool {
320 if c == '\u{FFFD}' {
321 return false;
322 }
323 !is_opaque(c)
324}
325
326/// Whether `c` carries no information: controls outside the four that break
327/// text, the private-use areas, FDD0-FDEF and the noncharacters.
328///
329/// Mirrors the `is_opaque` the Unicode fallback chain filters with, which is
330/// why this survey and the extractor agree on what is garbage. Most opaque
331/// mappings never reach this function -- the chain already refused them and
332/// the glyph arrives unmapped -- but the check stands on its own rather than
333/// trusting every present and future chain step to filter.
334fn is_opaque(c: char) -> bool {
335 let v = c as u32;
336 (v < 0x20 && !matches!(v, 0x09 | 0x0A | 0x0C | 0x0D))
337 || (0x7F..=0x9F).contains(&v)
338 || (0xE000..=0xF8FF).contains(&v)
339 || (0xFDD0..=0xFDEF).contains(&v)
340 || (v & 0xFFFE) == 0xFFFE
341 || (0xF0000..=0xFFFFD).contains(&v)
342 || (0x10_0000..=0x10FFFD).contains(&v)
343}
344
345/// Area of a rectangle, never negative.
346fn rect_area(r: Rect) -> f64 {
347 (r.width().max(0.0)) * (r.height().max(0.0))
348}
349
350/// Intersection of two rectangles, or `None` where they do not meet.
351fn intersect(a: Rect, b: Rect) -> Option<Rect> {
352 let r = a.intersect(b);
353 if r.width() > 0.0 && r.height() > 0.0 {
354 Some(r)
355 } else {
356 None
357 }
358}
359
360/// Area covered by at least one rectangle: a sweep over the distinct x edges,
361/// merging y intervals per slab. Quadratic in the edge count, which is twice
362/// the image count -- a handful on any page that is not a contact sheet.
363fn union_area(rects: &[Rect]) -> f64 {
364 let mut xs = Vec::with_capacity(rects.len() * 2);
365 for r in rects {
366 if r.width() > 0.0 && r.height() > 0.0 {
367 xs.push(r.x0);
368 xs.push(r.x1);
369 }
370 }
371 xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
372 xs.dedup();
373 let mut area = 0.0;
374 for slab in xs.windows(2) {
375 let (x0, x1) = (slab[0], slab[1]);
376 if x1 <= x0 {
377 continue;
378 }
379 let mut ys: Vec<(f64, f64)> = rects
380 .iter()
381 .filter(|r| r.x0 <= x0 && r.x1 >= x1 && r.height() > 0.0)
382 .map(|r| (r.y0, r.y1))
383 .collect();
384 if ys.is_empty() {
385 continue;
386 }
387 ys.sort_by(|a, b| {
388 a.0.partial_cmp(&b.0)
389 .unwrap_or(Ordering::Equal)
390 .then(a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal))
391 });
392 let mut running = ys[0];
393 let mut height = 0.0;
394 for &(lo, hi) in &ys[1..] {
395 if lo <= running.1 {
396 running.1 = running.1.max(hi);
397 } else {
398 height += running.1 - running.0;
399 running = (lo, hi);
400 }
401 }
402 area += (x1 - x0) * (height + running.1 - running.0);
403 }
404 area
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 /// A survey over a letter page is the test fixture's frame.
412 fn survey() -> OcrSurveyDevice {
413 OcrSurveyDevice::new(612.0, 792.0)
414 }
415
416 #[test]
417 fn an_empty_page_needs_nothing() {
418 assert_eq!(survey().decide(), OcrNeed::None);
419 }
420
421 #[test]
422 fn forty_glyphs_are_a_text_layer_and_thirty_nine_are_not() {
423 let mut yes = survey();
424 yes.readable = MIN_READABLE_GLYPHS;
425 assert_eq!(yes.decide(), OcrNeed::None);
426 let mut no = survey();
427 no.readable = MIN_READABLE_GLYPHS - 1;
428 no.images.push(Rect::new(0.0, 0.0, 612.0, 792.0));
429 assert_eq!(no.decide(), OcrNeed::Full);
430 }
431
432 #[test]
433 fn half_the_page_in_images_is_a_scan() {
434 let mut full = survey();
435 full.images.push(Rect::new(0.0, 0.0, 612.0, 396.0));
436 assert_eq!(full.decide(), OcrNeed::Full);
437 // Overlapping images count once: two copies of the same quarter page
438 // are a quarter page, not half.
439 let mut overlap = survey();
440 overlap.images.push(Rect::new(0.0, 0.0, 306.0, 396.0));
441 overlap.images.push(Rect::new(0.0, 0.0, 306.0, 396.0));
442 assert_eq!(overlap.decide(), OcrNeed::Partial);
443 }
444
445 #[test]
446 fn a_small_image_alone_is_not_worth_ocr() {
447 let mut small = survey();
448 small.images.push(Rect::new(0.0, 0.0, 100.0, 100.0));
449 assert_eq!(small.decide(), OcrNeed::None);
450 }
451
452 #[test]
453 fn a_large_uncovered_image_is_partial() {
454 let mut partial = survey();
455 partial.readable = MIN_READABLE_GLYPHS;
456 partial
457 .text_boxes
458 .push(Rect::new(72.0, 700.0, 540.0, 712.0));
459 partial.images.push(Rect::new(0.0, 0.0, 612.0, 396.0));
460 assert_eq!(partial.decide(), OcrNeed::Partial);
461 }
462
463 #[test]
464 fn text_over_the_image_marks_it_read() {
465 let mut read = survey();
466 read.readable = MIN_READABLE_GLYPHS;
467 read.images.push(Rect::new(0.0, 0.0, 612.0, 396.0));
468 // A fifth of the image under text is past the five-per-cent line.
469 read.text_boxes.push(Rect::new(0.0, 0.0, 612.0, 80.0));
470 assert_eq!(read.decide(), OcrNeed::None);
471 }
472
473 #[test]
474 fn union_area_counts_overlaps_once() {
475 let rects = vec![
476 Rect::new(0.0, 0.0, 10.0, 10.0),
477 Rect::new(5.0, 0.0, 15.0, 10.0),
478 ];
479 assert_eq!(union_area(&rects), 150.0);
480 assert_eq!(union_area(&[]), 0.0);
481 }
482
483 #[test]
484 fn readability_is_mapping_minus_garbage() {
485 assert!(char_is_readable('a'));
486 assert!(char_is_readable('7'));
487 assert!(char_is_readable(','));
488 assert!(char_is_readable(' '));
489 assert!(!char_is_readable('\u{1}'));
490 assert!(!char_is_readable('\u{FFFD}'));
491 assert!(!char_is_readable('\u{E000}'));
492 assert!(!char_is_readable('\u{10FFFD}'));
493 }
494}