use std::cmp::Ordering;
use kurbo::{Affine, Point, Rect};
use pdf_render::pdf_interpret::cmap::BfString;
use pdf_render::pdf_interpret::font::Glyph;
use pdf_render::pdf_interpret::{
BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OcrNeed {
None,
Full,
Partial,
}
pub fn page_needs_ocr(doc: &crate::document::PdfDocument, page: usize) -> OcrNeed {
doc.page_needs_ocr(page)
}
const MIN_READABLE_GLYPHS: usize = 40;
const FULL_PAGE_IMAGE_COVERAGE: f64 = 0.5;
const PARTIAL_MIN_IMAGE_FRACTION: f64 = 0.15;
const PARTIAL_MAX_TEXT_OVERLAP: f64 = 0.05;
pub(crate) struct OcrSurveyDevice {
page: Rect,
text_boxes: Vec<Rect>,
readable: usize,
images: Vec<Rect>,
last_fill: Option<([f64; 6], Option<BfString>)>,
}
impl OcrSurveyDevice {
pub(crate) fn new(width: f64, height: f64) -> Self {
Self {
page: Rect::new(0.0, 0.0, width.max(0.0), height.max(0.0)),
text_boxes: Vec::new(),
readable: 0,
images: Vec::new(),
last_fill: None,
}
}
pub(crate) fn decide(&self) -> OcrNeed {
let page_area = rect_area(self.page);
if page_area <= 0.0 {
return OcrNeed::None;
}
let clipped: Vec<Rect> = self
.images
.iter()
.filter_map(|r| intersect(*r, self.page))
.collect();
if self.readable < MIN_READABLE_GLYPHS
&& union_area(&clipped) / page_area >= FULL_PAGE_IMAGE_COVERAGE
{
return OcrNeed::Full;
}
for image in &clipped {
let image_area = rect_area(*image);
if image_area / page_area < PARTIAL_MIN_IMAGE_FRACTION {
continue;
}
let covered: f64 = self
.text_boxes
.iter()
.filter_map(|t| intersect(*t, *image))
.map(rect_area)
.sum();
if covered / image_area < PARTIAL_MAX_TEXT_OVERLAP {
return OcrNeed::Partial;
}
}
OcrNeed::None
}
}
impl Device<'_> for OcrSurveyDevice {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &kurbo::BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
fn draw_glyph(
&mut self,
glyph: &Glyph<'_>,
transform: Affine,
glyph_transform: Affine,
_: &Paint<'_>,
draw_mode: &GlyphDrawMode,
) {
let placed = (transform * glyph_transform).as_coeffs();
let repeats_fill = matches!(draw_mode, GlyphDrawMode::Stroke(_))
&& self
.last_fill
.as_ref()
.is_some_and(|(at, unicode)| *at == placed && *unicode == glyph.as_unicode());
self.last_fill = match draw_mode {
GlyphDrawMode::Fill => Some((placed, glyph.as_unicode())),
_ => None,
};
if repeats_fill {
return;
}
if matches!(draw_mode, GlyphDrawMode::Invisible) {
return;
}
if !glyph_is_readable(glyph) {
return;
}
self.readable += 1;
self.text_boxes
.push(glyph_box(transform * glyph_transform, advance_width(glyph)));
}
fn draw_image(&mut self, image: Image<'_, '_>, transform: Affine) {
let w = f64::from(image.width().max(1));
let h = f64::from(image.height().max(1));
let corners = [
transform * Point::new(0.0, 0.0),
transform * Point::new(w, 0.0),
transform * Point::new(w, h),
transform * Point::new(0.0, h),
];
let (mut x0, mut y0, mut x1, mut y1) = (
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
);
for p in corners {
x0 = x0.min(p.x);
y0 = y0.min(p.y);
x1 = x1.max(p.x);
y1 = y1.max(p.y);
}
self.images.push(Rect::new(x0, y0, x1, y1));
}
fn draw_placed_text(&mut self, text: &str, rect: Rect) {
let readable: usize = text.chars().filter(|c| char_is_readable(*c)).count();
if readable > 0 {
self.readable += readable;
self.text_boxes.push(rect);
}
}
}
fn advance_width(glyph: &Glyph<'_>) -> f64 {
match glyph {
Glyph::Outline(outline) => outline
.advance_width()
.map(|w| f64::from(w) / 1000.0)
.unwrap_or(0.5),
Glyph::Type3(_) => 0.5,
}
}
fn glyph_box(composed: Affine, advance_em: f64) -> Rect {
let coeffs = composed.as_coeffs();
let (x, y) = (coeffs[4], coeffs[5]);
let scale = (coeffs[0] * coeffs[0] + coeffs[1] * coeffs[1]).sqrt().abs();
let font_size = scale * 1000.0;
let width = (advance_em * font_size).max(font_size * 0.25);
Rect::new(
x.min(x + width),
y.min(y + font_size),
x.max(x + width),
y.max(y + font_size),
)
}
fn glyph_is_readable(glyph: &Glyph<'_>) -> bool {
match glyph.as_unicode() {
None => false,
Some(BfString::Char(c)) => char_is_readable(c),
Some(BfString::String(s)) => {
let mut any = false;
for c in s.chars() {
if !char_is_readable(c) {
return false;
}
any = true;
}
any
}
}
}
fn char_is_readable(c: char) -> bool {
if c == '\u{FFFD}' {
return false;
}
!is_opaque(c)
}
fn is_opaque(c: char) -> bool {
let v = c as u32;
(v < 0x20 && !matches!(v, 0x09 | 0x0A | 0x0C | 0x0D))
|| (0x7F..=0x9F).contains(&v)
|| (0xE000..=0xF8FF).contains(&v)
|| (0xFDD0..=0xFDEF).contains(&v)
|| (v & 0xFFFE) == 0xFFFE
|| (0xF0000..=0xFFFFD).contains(&v)
|| (0x10_0000..=0x10FFFD).contains(&v)
}
fn rect_area(r: Rect) -> f64 {
(r.width().max(0.0)) * (r.height().max(0.0))
}
fn intersect(a: Rect, b: Rect) -> Option<Rect> {
let r = a.intersect(b);
if r.width() > 0.0 && r.height() > 0.0 {
Some(r)
} else {
None
}
}
fn union_area(rects: &[Rect]) -> f64 {
let mut xs = Vec::with_capacity(rects.len() * 2);
for r in rects {
if r.width() > 0.0 && r.height() > 0.0 {
xs.push(r.x0);
xs.push(r.x1);
}
}
xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
xs.dedup();
let mut area = 0.0;
for slab in xs.windows(2) {
let (x0, x1) = (slab[0], slab[1]);
if x1 <= x0 {
continue;
}
let mut ys: Vec<(f64, f64)> = rects
.iter()
.filter(|r| r.x0 <= x0 && r.x1 >= x1 && r.height() > 0.0)
.map(|r| (r.y0, r.y1))
.collect();
if ys.is_empty() {
continue;
}
ys.sort_by(|a, b| {
a.0.partial_cmp(&b.0)
.unwrap_or(Ordering::Equal)
.then(a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal))
});
let mut running = ys[0];
let mut height = 0.0;
for &(lo, hi) in &ys[1..] {
if lo <= running.1 {
running.1 = running.1.max(hi);
} else {
height += running.1 - running.0;
running = (lo, hi);
}
}
area += (x1 - x0) * (height + running.1 - running.0);
}
area
}
#[cfg(test)]
mod tests {
use super::*;
fn survey() -> OcrSurveyDevice {
OcrSurveyDevice::new(612.0, 792.0)
}
#[test]
fn an_empty_page_needs_nothing() {
assert_eq!(survey().decide(), OcrNeed::None);
}
#[test]
fn forty_glyphs_are_a_text_layer_and_thirty_nine_are_not() {
let mut yes = survey();
yes.readable = MIN_READABLE_GLYPHS;
assert_eq!(yes.decide(), OcrNeed::None);
let mut no = survey();
no.readable = MIN_READABLE_GLYPHS - 1;
no.images.push(Rect::new(0.0, 0.0, 612.0, 792.0));
assert_eq!(no.decide(), OcrNeed::Full);
}
#[test]
fn half_the_page_in_images_is_a_scan() {
let mut full = survey();
full.images.push(Rect::new(0.0, 0.0, 612.0, 396.0));
assert_eq!(full.decide(), OcrNeed::Full);
let mut overlap = survey();
overlap.images.push(Rect::new(0.0, 0.0, 306.0, 396.0));
overlap.images.push(Rect::new(0.0, 0.0, 306.0, 396.0));
assert_eq!(overlap.decide(), OcrNeed::Partial);
}
#[test]
fn a_small_image_alone_is_not_worth_ocr() {
let mut small = survey();
small.images.push(Rect::new(0.0, 0.0, 100.0, 100.0));
assert_eq!(small.decide(), OcrNeed::None);
}
#[test]
fn a_large_uncovered_image_is_partial() {
let mut partial = survey();
partial.readable = MIN_READABLE_GLYPHS;
partial
.text_boxes
.push(Rect::new(72.0, 700.0, 540.0, 712.0));
partial.images.push(Rect::new(0.0, 0.0, 612.0, 396.0));
assert_eq!(partial.decide(), OcrNeed::Partial);
}
#[test]
fn text_over_the_image_marks_it_read() {
let mut read = survey();
read.readable = MIN_READABLE_GLYPHS;
read.images.push(Rect::new(0.0, 0.0, 612.0, 396.0));
read.text_boxes.push(Rect::new(0.0, 0.0, 612.0, 80.0));
assert_eq!(read.decide(), OcrNeed::None);
}
#[test]
fn union_area_counts_overlaps_once() {
let rects = vec![
Rect::new(0.0, 0.0, 10.0, 10.0),
Rect::new(5.0, 0.0, 15.0, 10.0),
];
assert_eq!(union_area(&rects), 150.0);
assert_eq!(union_area(&[]), 0.0);
}
#[test]
fn readability_is_mapping_minus_garbage() {
assert!(char_is_readable('a'));
assert!(char_is_readable('7'));
assert!(char_is_readable(','));
assert!(char_is_readable(' '));
assert!(!char_is_readable('\u{1}'));
assert!(!char_is_readable('\u{FFFD}'));
assert!(!char_is_readable('\u{E000}'));
assert!(!char_is_readable('\u{10FFFD}'));
}
}