Skip to main content

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}