Skip to main content

sioul_sync/
ocr.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Reading a scan's text (docs/porch.md, "Paper letters"): a PDF's own text
5//! when it has one (Poppler's `pdftotext`), else its pages as images
6//! (`pdftoppm`) read by Tesseract, as photos and scanned images are. Both are
7//! the system's own programs, as the antivirus is: optional, said when missing,
8//! with the command that installs them. Nothing leaves this computer.
9
10use std::path::{Path, PathBuf};
11
12/// Why a scan could not be read.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Unread {
15    /// A program is missing; how to install it.
16    Missing(String),
17    Failed(String),
18}
19
20/// A program, as the system has it: on its path, else where Homebrew puts it
21/// (a macOS application does not get the shell's path); on Windows,
22/// Tesseract also where its installers put it, since they leave it off the
23/// PATH unless asked (`tesseract_places`).
24fn program(name: &str) -> PathBuf {
25    #[cfg(windows)]
26    if name == "tesseract" {
27        let path: Vec<PathBuf> = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default();
28        if let Some(found) = find_in("tesseract.exe", &path, &tesseract_places(|var| std::env::var(var).ok())) {
29            return found;
30        }
31    }
32    ["/opt/homebrew/bin", "/usr/local/bin"].iter().map(|dir| Path::new(dir).join(name)).find(|p| p.is_file()).filter(|_| cfg!(target_os = "macos")).unwrap_or_else(|| PathBuf::from(name))
33}
34
35/// The folders Windows installers put Tesseract in, from the environment
36/// (`env`): UB Mannheim's installer (which winget and Chocolatey use too) in
37/// Program Files, 64-bit or 32-bit, or for you alone in
38/// `AppData\Local\Programs`; each in `Tesseract-OCR`. Without the
39/// environment, the usual one. The registry is not read.
40#[cfg(any(windows, test))]
41fn tesseract_places(env: impl Fn(&str) -> Option<String>) -> Vec<PathBuf> {
42    let mut out: Vec<PathBuf> = ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"].iter().filter_map(|var| env(var)).filter(|dir| !dir.trim().is_empty()).map(|dir| PathBuf::from(dir).join("Tesseract-OCR")).collect();
43    if let Some(local) = env("LOCALAPPDATA").filter(|dir| !dir.trim().is_empty()) {
44        out.push(PathBuf::from(local).join("Programs").join("Tesseract-OCR"));
45    }
46    out.push(PathBuf::from(r"C:\Program Files\Tesseract-OCR"));
47    let mut seen = Vec::new();
48    out.retain(|dir| {
49        let new = !seen.contains(dir);
50        seen.push(dir.clone());
51        new
52    });
53    out
54}
55
56/// `file` ("tesseract.exe") in the first folder of `path` that holds it, else
57/// in the first of `places` that does.
58#[cfg(any(windows, test))]
59fn find_in(file: &str, path: &[PathBuf], places: &[PathBuf]) -> Option<PathBuf> {
60    path.iter().chain(places).map(|dir| dir.join(file)).find(|candidate| candidate.is_file())
61}
62
63fn runs(name: &str) -> bool {
64    crate::command(program(name)).arg("--version").output().is_ok()
65}
66
67/// How to install what reads scans here, in one line, in your language:
68/// Tesseract, its models of the languages `wanted` ("fra", English being
69/// in every Tesseract), and Poppler; a command in backticks (the window
70/// offers it to copy).
71pub fn install_hint(wanted: &[String]) -> String {
72    // SUSE names its models by the language's English name.
73    #[cfg(all(unix, not(target_os = "macos")))]
74    let models = |prefix: &str| {
75        let names: Vec<String> = wanted.iter().map(|l| l.trim()).filter(|l| !l.is_empty() && *l != "eng").map(|l| match prefix {
76            "tesseract-ocr-traineddata-" => format!("{prefix}{}", match l { "fra" => "french", "deu" => "german", "spa" => "spanish", "ita" => "italian", other => other }),
77            _ => format!("{prefix}{l}"),
78        }).collect();
79        if names.is_empty() { String::new() } else { format!(" {}", names.join(" ")) }
80    };
81    #[cfg(not(all(unix, not(target_os = "macos"))))]
82    let _ = wanted;
83    #[cfg(windows)]
84    {
85        crate::translator().text("ocr-hint-windows", None)
86    }
87    #[cfg(target_os = "macos")]
88    {
89        "`brew install tesseract tesseract-lang poppler`".to_string()
90    }
91    #[cfg(all(unix, not(target_os = "macos")))]
92    {
93        let release = std::fs::read_to_string("/etc/os-release").unwrap_or_default().to_ascii_lowercase();
94        let like = |name: &str| release.lines().any(|l| (l.starts_with("id=") || l.starts_with("id_like=")) && l.contains(name));
95        if like("fedora") || like("rhel") {
96            format!("`sudo dnf install tesseract{} poppler-utils`", models("tesseract-langpack-"))
97        } else if like("debian") || like("ubuntu") {
98            format!("`sudo apt install tesseract-ocr{} poppler-utils`", models("tesseract-ocr-"))
99        } else if like("arch") {
100            format!("`sudo pacman -S tesseract{} poppler`", models("tesseract-data-"))
101        } else if like("suse") {
102            format!("`sudo zypper install tesseract-ocr{} poppler-tools`", models("tesseract-ocr-traineddata-"))
103        } else {
104            crate::translator().text("ocr-hint-packages", None)
105        }
106    }
107}
108
109/// The languages Tesseract has among those wanted ("fra+eng": the models of
110/// the languages you read, `words::OcrWords::tesseract`), else English.
111fn languages(wanted: &[String]) -> String {
112    let out = crate::command(program("tesseract")).arg("--list-langs").output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default();
113    let have: Vec<&str> = out.lines().skip(1).map(str::trim).collect();
114    let wanted: Vec<&str> = wanted.iter().map(|l| l.trim()).filter(|l| have.contains(l)).collect();
115    if wanted.is_empty() { "eng".into() } else { wanted.join("+") }
116}
117
118fn tesseract(image: &Path, wanted: &[String]) -> Result<String, Unread> {
119    if !runs("tesseract") {
120        return Err(Unread::Missing(install_hint(wanted)));
121    }
122    let out = crate::command(program("tesseract")).arg(image).arg("stdout").args(["-l", &languages(wanted)]).output().map_err(|e| Unread::Failed(e.to_string()))?;
123    if !out.status.success() {
124        return Err(Unread::Failed(String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("").to_string()));
125    }
126    Ok(String::from_utf8_lossy(&out.stdout).to_string())
127}
128
129/// A scan's text: a PDF's own, else read from its pages; an image's, read.
130/// `work` is a folder for the pages while they are read (emptied after);
131/// `wanted`, Tesseract's models to read them with ("fra", "eng").
132pub fn text_of(file: &Path, work: &Path, wanted: &[String]) -> Result<String, Unread> {
133    let extension = file.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
134    if extension != "pdf" {
135        return tesseract(file, wanted);
136    }
137    if !runs("pdftotext") {
138        return Err(Unread::Missing(install_hint(wanted)));
139    }
140    let own = crate::command(program("pdftotext")).args(["-layout"]).arg(file).arg("-").output().map_err(|e| Unread::Failed(e.to_string()))?;
141    let text = String::from_utf8_lossy(&own.stdout).to_string();
142    if text.chars().filter(|c| c.is_alphanumeric()).count() > 80 {
143        return Ok(text);
144    }
145    // A scanned PDF: its pages as images, at 300 dpi, read one by one. The
146    // pictures of your letter go afterwards, whether they could be read or not.
147    let _ = std::fs::remove_dir_all(work);
148    std::fs::create_dir_all(work).map_err(|e| Unread::Failed(e.to_string()))?;
149    let read = pages_read(file, work, wanted);
150    let _ = std::fs::remove_dir_all(work);
151    read
152}
153
154/// A PDF's pages made images in `work`, then read in their order.
155fn pages_read(file: &Path, work: &Path, wanted: &[String]) -> Result<String, Unread> {
156    let made = crate::command(program("pdftoppm")).args(["-r", "300", "-png"]).arg(file).arg(work.join("page")).output().map_err(|e| Unread::Failed(e.to_string()))?;
157    if !made.status.success() {
158        return Err(Unread::Failed(String::from_utf8_lossy(&made.stderr).lines().last().unwrap_or("").to_string()));
159    }
160    let mut pages: Vec<PathBuf> = std::fs::read_dir(work).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "png")).collect();
161    pages.sort();
162    let mut out = String::new();
163    for page in pages {
164        out.push_str(&tesseract(&page, wanted)?);
165        out.push('\n');
166    }
167    Ok(out)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    /// Windows: Tesseract found where its installers put it, off the PATH,
175    /// in folders invented for the test standing for Program Files and
176    /// AppData; the PATH first when it has it; none when nowhere.
177    #[test]
178    fn tesseract_is_found_where_windows_installers_put_it() {
179        let root = std::env::temp_dir().join(format!("sioul-ocr-places-{}", std::process::id()));
180        let _ = std::fs::remove_dir_all(&root);
181        let (programs, programs32, local, bin) = (root.join("Program Files"), root.join("Program Files (x86)"), root.join("Local"), root.join("bin"));
182        for dir in [&programs, &programs32, &local, &bin] {
183            std::fs::create_dir_all(dir).unwrap();
184        }
185        let env = |var: &str| match var {
186            "ProgramW6432" | "ProgramFiles" => Some(programs.display().to_string()),
187            "ProgramFiles(x86)" => Some(programs32.display().to_string()),
188            "LOCALAPPDATA" => Some(local.display().to_string()),
189            _ => None,
190        };
191        let places = tesseract_places(env);
192        assert_eq!(places, [programs.join("Tesseract-OCR"), programs32.join("Tesseract-OCR"), local.join("Programs").join("Tesseract-OCR"), PathBuf::from(r"C:\Program Files\Tesseract-OCR")], "each once");
193        let path = [bin.clone()];
194        assert_eq!(find_in("tesseract.exe", &path, &places), None, "nowhere");
195        let put = |dir: PathBuf| {
196            std::fs::create_dir_all(&dir).unwrap();
197            std::fs::write(dir.join("tesseract.exe"), "").unwrap();
198            dir.join("tesseract.exe")
199        };
200        // Installed for you alone, then for everyone: the computer's first.
201        let yours = put(local.join("Programs").join("Tesseract-OCR"));
202        assert_eq!(find_in("tesseract.exe", &path, &places), Some(yours));
203        let everyone = put(programs32.join("Tesseract-OCR"));
204        assert_eq!(find_in("tesseract.exe", &path, &places), Some(everyone));
205        // On the PATH: that one.
206        let on_path = put(bin.clone());
207        assert_eq!(find_in("tesseract.exe", &path, &places), Some(on_path));
208        // No environment at all: the usual folder alone.
209        assert_eq!(tesseract_places(|_| None), [PathBuf::from(r"C:\Program Files\Tesseract-OCR")]);
210        let _ = std::fs::remove_dir_all(&root);
211    }
212
213    /// A French letter written as an image, read back. Needs Tesseract with
214    /// French and Python's Pillow, which make no part of Sioul: run by hand.
215    #[test]
216    #[ignore]
217    fn a_letter_as_an_image() {
218        let dir = std::env::temp_dir().join(format!("sioul-ocr-{}", std::process::id()));
219        std::fs::create_dir_all(&dir).unwrap();
220        let image = dir.join("letter.png");
221        let script = format!(
222            "from PIL import Image, ImageDraw, ImageFont\nimport glob\nfont = ImageFont.truetype(sorted(glob.glob('/usr/share/fonts/**/DejaVuSans.ttf', recursive=True))[0], 34)\nim = Image.new('L', (1700, 900), 255)\nd = ImageDraw.Draw(im)\nlines = ['CAISSE D ALLOCATIONS FAMILIALES', 'Exempleville, le 28 septembre 2026', 'Objet : notification de décision', 'Voies et délais de recours : vous pouvez contester', 'cette décision dans un délai de deux mois.']\nfor i, l in enumerate(lines):\n    d.text((80, 80 + i * 70), l, fill=0, font=font)\nim.save('{}')\n",
223            image.display()
224        );
225        let made = std::process::Command::new("python3").arg("-c").arg(script).status().unwrap();
226        assert!(made.success());
227        let words = sioul_core::words::Words::builtin();
228        let text = text_of(&image, &dir.join("pages"), &words.ocr.tesseract).unwrap();
229        let reading = sioul_core::letters::read(&words, &text, "2026-10-02".parse().unwrap());
230        assert_eq!(reading.sender, "CAF", "{text}");
231        assert_eq!(reading.deadline, Some("2026-12-02".parse().unwrap()), "{text}");
232        let _ = std::fs::remove_dir_all(&dir);
233    }
234}