Files
pdfcraft/crates/ocr/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 d90b3f897f Refs #103: ship the OCR models in the desktop release packages (#630)
Release builds said "Text recognition isn't installed: its models are
missing" because no packaging script or release job fetched the OCR
models: only `cargo xtask models` does, into the source tree.

- packaging/env.sh gains stage_models DEST, which runs
  `cargo xtask models DEST` (every ATTRIBUTION.toml kind = "model" file,
  SHA-256 verified, with its licence text). No model file is named, so
  the models PRs #433/#572 add ship without packaging changes.
- `cargo xtask models` also writes ATTRIBUTION.txt crediting each model
  (CC-BY-SA-4.0 requires attribution when redistributed).
- macOS: PdfCraft.app/Contents/Resources/models, before signing.
- Windows: models\ beside pdfcraft.exe in the MSI (WiX v5 <Files>
  harvest) and the portable zip; test-msi.ps1 checks the folder.
- Linux deb/rpm/tar.gz/AppImage, Flatpak bundle and FreeBSD tarball:
  share/pdfcraft/models beside bin/. Models::search_dirs now also looks
  in <exe dir>/../share/pdfcraft/models (Models::dirs_beside_exe); a
  test builds every package layout on disk and finds the models in each.
  release.yml and freebsd.yml check the packages contain them.
- The missing-models messages no longer tell end users to run cargo,
  and the 11 catalogs that keyed an old PRINTCRAFT_MODELS wording (or
  the previous one) now key the current English text; ATTRIBUTION
  hashes updated. A test checks the translations match.

The web (wasm) build, which the issue reports, still has no models; this
is the desktop half only.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:42:23 -04:00

341 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! pdfcraft-ocr — Scan & OCR ▸ Recognize text (L4).
//!
//! Recognition runs the ocrs engine (MIT/Apache-2.0) with its pre-trained models (CC-BY-SA-4.0,
//! fetched by `cargo xtask models`; see ATTRIBUTION.toml). The caller renders a page to pixels;
//! [`Ocr::recognize`] finds the words in them, and [`text_layer`] turns words placed in user space
//! into page content: invisible text (rendering mode 3) over each word, so the page becomes a
//! searchable image (Acrobat's "Searchable Image (Exact)": the image is left untouched).
//!
//! The models read the Latin alphabet (English and other languages written without accents).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::path::{Path, PathBuf};
pub use pdfcraft_fonts::helvetica_width;
/// The model files, as named in ATTRIBUTION.toml.
pub const DETECTION_MODEL: &str = "text-detection.rten";
pub const RECOGNITION_MODEL: &str = "text-recognition.rten";
/// The languages the models read (ISO 639-1); all use the Latin alphabet without accents.
pub const LANGUAGES: &[(&str, &str)] = &[("en", "English")];
#[derive(Debug, thiserror::Error)]
pub enum OcrError {
/// Shown to end users (release packages ship the models; a source build fetches them with
/// `cargo xtask models`). The UI catalogs key this exact text.
#[error("the text recognition models are not installed (reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them)")]
NoModels,
#[error("loading {0}: {1}")]
Load(String, String),
#[error("text recognition failed: {0}")]
Recognize(String),
#[error("the image is empty")]
EmptyImage,
}
/// Where the two model files are.
#[derive(Clone, Debug, PartialEq)]
pub struct Models {
pub detection: PathBuf,
pub recognition: PathBuf,
}
impl Models {
/// The models in `dir`, if both files are there.
pub fn in_dir(dir: &Path) -> Option<Models> {
let m = Models { detection: dir.join(DETECTION_MODEL), recognition: dir.join(RECOGNITION_MODEL) };
(m.detection.is_file() && m.recognition.is_file()).then_some(m)
}
/// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them
/// beside the executable ([`Models::dirs_beside_exe`]), then the source tree's `assets/models/`.
pub fn find() -> Option<Models> {
Self::search_dirs().iter().find_map(|d| Self::in_dir(d))
}
/// The directories [`Models::find`] looks in, in order.
pub fn search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(d) = std::env::var_os("PDFCRAFT_MODELS") {
dirs.push(PathBuf::from(d));
}
if let Some(exe) = std::env::current_exe().ok().and_then(|e| e.parent().map(Path::to_path_buf)) {
dirs.extend(Self::dirs_beside_exe(&exe));
}
dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/models"));
dirs
}
/// Where the release packages put the models, relative to the directory holding the
/// executable (packaging/*):
/// - `models/` beside it: the Windows MSI and portable zip;
/// - `../Resources/models`: the macOS app bundle (`Contents/MacOS` → `Contents/Resources`);
/// - `../share/pdfcraft/models`: the FHS-style trees (`bin/` → `share/`) of the Linux
/// deb/rpm/tar.gz/AppImage/Flatpak and the FreeBSD tarball, wherever they are installed.
pub fn dirs_beside_exe(exe_dir: &Path) -> Vec<PathBuf> {
vec![exe_dir.join("models"), exe_dir.join("../Resources/models"), exe_dir.join("../share/pdfcraft/models")]
}
}
/// A recognised word: its text and its box in image pixels `[left, top, right, bottom]`.
#[derive(Clone, Debug, PartialEq)]
pub struct Word {
pub text: String,
pub rect: [f32; 4],
}
/// A recognised line of words, in reading order.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Line {
pub words: Vec<Word>,
}
impl Line {
pub fn text(&self) -> String {
self.words.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" ")
}
}
/// A loaded recogniser. Loading takes a moment; keep one and reuse it.
pub struct Ocr {
engine: ocrs::OcrEngine,
}
impl Ocr {
pub fn load(models: &Models) -> Result<Ocr, OcrError> {
let load = |p: &Path| rten::Model::load_file(p).map_err(|e| OcrError::Load(p.display().to_string(), e.to_string()));
let params = ocrs::OcrEngineParams {
detection_model: Some(load(&models.detection)?),
recognition_model: Some(load(&models.recognition)?),
..Default::default()
};
let engine = ocrs::OcrEngine::new(params).map_err(|e| OcrError::Load("ocr engine".into(), e.to_string()))?;
Ok(Ocr { engine })
}
/// Load the models found by [`Models::find`].
pub fn find() -> Result<Ocr, OcrError> {
Self::load(&Models::find().ok_or(OcrError::NoModels)?)
}
/// Recognise the text in an RGBA (or RGB, or grey) image, `width` × `height` pixels.
pub fn recognize(&self, pixels: &[u8], width: u32, height: u32) -> Result<Vec<Line>, OcrError> {
if width == 0 || height == 0 || pixels.is_empty() {
return Err(OcrError::EmptyImage);
}
// ocrs wants 1 or 3 channels.
let rgb: std::borrow::Cow<[u8]> = if pixels.len() == (width * height * 4) as usize {
pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::<Vec<u8>>().into()
} else {
pixels.into()
};
let err = |e: &dyn std::fmt::Display| OcrError::Recognize(e.to_string());
let source = ocrs::ImageSource::from_bytes(&rgb, (width, height)).map_err(|e| err(&e))?;
let input = self.engine.prepare_input(source).map_err(|e| err(&e))?;
let found = self.engine.detect_words(&input).map_err(|e| err(&e))?;
let lines = self.engine.find_text_lines(&input, &found);
let read = self.engine.recognize_text(&input, &lines).map_err(|e| err(&e))?;
use ocrs::TextItem;
Ok(read
.into_iter()
.flatten()
.map(|line| Line {
words: line
.words()
.map(|w| {
let r = w.bounding_rect();
Word { text: w.to_string(), rect: [r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32] }
})
.filter(|w| !w.text.trim().is_empty())
.collect(),
})
.filter(|l| !l.words.is_empty())
.collect())
}
}
/// A word placed on a page, in PDF user space: the bottom-left corner of its box, and the
/// vectors along its bottom edge (`across`, left → right as read) and its left edge (`up`).
#[derive(Clone, Debug, PartialEq)]
pub struct PlacedWord {
pub text: String,
pub origin: [f64; 2],
pub across: [f64; 2],
pub up: [f64; 2],
}
impl PlacedWord {
/// Place a pixel-space word with `to_user`, which maps an image pixel (x right, y down) to
/// user space.
pub fn place(word: &Word, to_user: impl Fn(f32, f32) -> [f64; 2]) -> PlacedWord {
let [l, t, r, b] = word.rect;
let o = to_user(l, b);
let br = to_user(r, b);
let tl = to_user(l, t);
PlacedWord { text: word.text.clone(), origin: o, across: [br[0] - o[0], br[1] - o[1]], up: [tl[0] - o[0], tl[1] - o[1]] }
}
}
/// Helvetica's ascender and descender (em fractions): a word box spans about this much.
const BOX_EM: f64 = 0.93;
const DESCENT: f64 = 0.21;
fn num(v: f64) -> String {
let s = format!("{:.4}", if v.abs() < 1e-9 { 0.0 } else { v });
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
/// Page content that writes `words` as invisible text in `/PCHelv` (standard Helvetica,
/// WinAnsiEncoding), each word stretched to its box so selection and search highlight the
/// right place. Marked content `/OCR` so it can be told apart from the page's own text.
pub fn text_layer(words: &[PlacedWord]) -> Vec<u8> {
let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
for w in words {
let width = helvetica_width(&w.text, 1.0);
let height = w.up[0].hypot(w.up[1]);
if width <= 0.0 || height <= 0.0 {
continue;
}
// The em square: its height spans the box; its width is stretched to fit the word.
let em = [w.up[0] / BOX_EM, w.up[1] / BOX_EM];
let ax = [w.across[0] / width, w.across[1] / width];
let o = [w.origin[0] + em[0] * DESCENT, w.origin[1] + em[1] * DESCENT];
let m = [ax[0], ax[1], em[0], em[1], o[0], o[1]];
let nums: Vec<String> = m.iter().map(|v| num(*v)).collect();
out.extend_from_slice(nums.join(" ").as_bytes());
out.extend_from_slice(b" Tm ");
out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text)));
out.extend_from_slice(b" Tj\n");
}
out.extend_from_slice(b"ET\nEMC\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn places_words_through_the_mapping() {
// 2 pixels per point, page 612 × 792, y flipped.
let to_user = |x: f32, y: f32| [x as f64 / 2.0, 792.0 - y as f64 / 2.0];
let w = PlacedWord::place(&Word { text: "Hello".into(), rect: [100.0, 200.0, 300.0, 240.0] }, to_user);
assert_eq!(w.origin, [50.0, 672.0]);
assert_eq!(w.across, [100.0, 0.0]);
assert_eq!(w.up, [0.0, 20.0]);
}
#[test]
fn text_layer_is_invisible_text_fitted_to_each_box() {
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] };
let s = String::from_utf8(text_layer(&[w])).unwrap();
assert!(s.contains("3 Tr") && s.contains("/PCHelv 1 Tf"), "{s}");
let width = helvetica_width("Hi", 1.0);
assert!(s.contains(&format!("{} 0 0 10 10 22.1 Tm (Hi) Tj", num(30.0 / width))), "{s}");
assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n"));
}
#[test]
fn rotated_pages_keep_the_reading_direction() {
// A page turned 90°: image x runs up the page.
let to_user = |x: f32, y: f32| [y as f64, x as f64];
let w = PlacedWord::place(&Word { text: "Up".into(), rect: [0.0, 0.0, 50.0, 10.0] }, to_user);
assert_eq!(w.across, [0.0, 50.0]);
assert_eq!(w.up, [-10.0, 0.0]);
}
/// #103: every release package's layout puts the models where the installed app looks.
/// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}.
#[test]
fn finds_the_models_in_every_release_package_layout() {
let base = std::env::temp_dir().join(format!("pdfcraft-ocr-layouts-{}", std::process::id()));
let layouts = [
("windows", "PdfCraft", "PdfCraft/models"),
("macos", "PdfCraft.app/Contents/MacOS", "PdfCraft.app/Contents/Resources/models"),
("linux-deb-rpm", "usr/bin", "usr/share/pdfcraft/models"),
("linux-tar", "pdfcraft-1.0.0-linux-x86_64/bin", "pdfcraft-1.0.0-linux-x86_64/share/pdfcraft/models"),
("appimage", "PdfCraft.AppDir/usr/bin", "PdfCraft.AppDir/usr/share/pdfcraft/models"),
("flatpak", "app/bin", "app/share/pdfcraft/models"),
("freebsd", "usr/local/bin", "usr/local/share/pdfcraft/models"),
];
for (name, exe_dir, models_dir) in layouts {
let root = base.join(name);
let (exe_dir, models_dir) = (root.join(exe_dir), root.join(models_dir));
std::fs::create_dir_all(&exe_dir).unwrap();
std::fs::create_dir_all(&models_dir).unwrap();
let found = || Models::dirs_beside_exe(&exe_dir).iter().find_map(|d| Models::in_dir(d));
assert_eq!(found(), None, "{name}: no models yet");
for file in [DETECTION_MODEL, RECOGNITION_MODEL] {
std::fs::write(models_dir.join(file), b"model").unwrap();
}
let models = found().unwrap_or_else(|| panic!("{name}: models in {} not found", models_dir.display()));
assert_eq!(models.detection.canonicalize().unwrap(), models_dir.join(DETECTION_MODEL).canonicalize().unwrap(), "{name}");
}
let _ = std::fs::remove_dir_all(&base);
}
/// The message tells end users what to do; release builds have no cargo.
#[test]
fn missing_models_message_is_for_end_users() {
let message = OcrError::NoModels.to_string();
assert!(!message.contains("cargo") && message.contains("PDFCRAFT_MODELS"), "{message}");
}
/// End to end on rendered text, when the models are installed (`cargo xtask models`).
#[test]
fn reads_rendered_text() {
let Some(models) = Models::find() else {
eprintln!("skipped: OCR models not installed");
return;
};
let ocr = Ocr::load(&models).unwrap();
let (w, h, px) = test_image::hello();
let lines = ocr.recognize(&px, w, h).unwrap();
let text: Vec<String> = lines.iter().map(Line::text).collect();
assert!(text.iter().any(|t| t.contains("HELL") && t.contains("WOR")), "{text:?}");
let word = lines.iter().flat_map(|l| &l.words).find(|w| w.text.contains("HELL")).unwrap();
assert!(word.rect[0] >= 10.0 && word.rect[0] < 60.0, "{word:?}");
}
mod test_image {
/// "HELLO WORLD" drawn with blocky 5×7 letters, 4 pixels per dot, black on white.
pub fn hello() -> (u32, u32, Vec<u8>) {
const GLYPHS: &[(char, [&str; 7])] = &[
('H', ["X...X", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"]),
('E', ["XXXXX", "X....", "X....", "XXXX.", "X....", "X....", "XXXXX"]),
('L', ["X....", "X....", "X....", "X....", "X....", "X....", "XXXXX"]),
('O', [".XXX.", "X...X", "X...X", "X...X", "X...X", "X...X", ".XXX."]),
('W', ["X...X", "X...X", "X...X", "X.X.X", "X.X.X", "XX.XX", "X...X"]),
('R', ["XXXX.", "X...X", "X...X", "XXXX.", "X.X..", "X..X.", "X...X"]),
('D', ["XXXX.", "X...X", "X...X", "X...X", "X...X", "X...X", "XXXX."]),
];
let text = "HELLO WORLD";
let (dot, x0, y0) = (4u32, 40u32, 40u32);
let (w, h) = (x0 * 2 + text.len() as u32 * 6 * dot, y0 * 2 + 7 * dot);
let mut px = vec![255u8; (w * h * 4) as usize];
for (i, c) in text.chars().enumerate() {
let Some((_, rows)) = GLYPHS.iter().find(|g| g.0 == c) else { continue };
for (ry, row) in rows.iter().enumerate() {
for (rx, b) in row.bytes().enumerate() {
if b != b'X' {
continue;
}
for dy in 0..dot {
for dx in 0..dot {
let x = x0 + (i as u32 * 6 + rx as u32) * dot + dx;
let y = y0 + ry as u32 * dot + dy;
let o = ((y * w + x) * 4) as usize;
px[o..o + 3].copy_from_slice(&[0, 0, 0]);
}
}
}
}
}
(w, h, px)
}
}
}