1use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Unread {
15 Missing(String),
17 Failed(String),
18}
19
20fn 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#[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#[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
67pub fn reads_here() -> bool {
71 runs("tesseract") && runs("pdftotext")
72}
73
74pub fn install_hint(wanted: &[String]) -> String {
79 #[cfg(all(unix, not(target_os = "macos")))]
81 let models = |prefix: &str| {
82 let names: Vec<String> = wanted.iter().map(|l| l.trim()).filter(|l| !l.is_empty() && *l != "eng").map(|l| match prefix {
83 "tesseract-ocr-traineddata-" => format!("{prefix}{}", match l { "fra" => "french", "deu" => "german", "spa" => "spanish", "ita" => "italian", other => other }),
84 _ => format!("{prefix}{l}"),
85 }).collect();
86 if names.is_empty() { String::new() } else { format!(" {}", names.join(" ")) }
87 };
88 #[cfg(not(all(unix, not(target_os = "macos"))))]
89 let _ = wanted;
90 #[cfg(windows)]
91 {
92 crate::translator().text("ocr-hint-windows", None)
93 }
94 #[cfg(target_os = "macos")]
95 {
96 "`brew install tesseract tesseract-lang poppler`".to_string()
97 }
98 #[cfg(all(unix, not(target_os = "macos")))]
99 {
100 let release = std::fs::read_to_string("/etc/os-release").unwrap_or_default().to_ascii_lowercase();
101 let like = |name: &str| release.lines().any(|l| (l.starts_with("id=") || l.starts_with("id_like=")) && l.contains(name));
102 if like("fedora") || like("rhel") {
103 format!("`sudo dnf install tesseract{} poppler-utils`", models("tesseract-langpack-"))
104 } else if like("debian") || like("ubuntu") {
105 format!("`sudo apt install tesseract-ocr{} poppler-utils`", models("tesseract-ocr-"))
106 } else if like("arch") {
107 format!("`sudo pacman -S tesseract{} poppler`", models("tesseract-data-"))
108 } else if like("suse") {
109 format!("`sudo zypper install tesseract-ocr{} poppler-tools`", models("tesseract-ocr-traineddata-"))
110 } else {
111 crate::translator().text("ocr-hint-packages", None)
112 }
113 }
114}
115
116fn languages(wanted: &[String]) -> String {
119 let out = crate::command(program("tesseract")).arg("--list-langs").output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default();
120 let have: Vec<&str> = out.lines().skip(1).map(str::trim).collect();
121 let wanted: Vec<&str> = wanted.iter().map(|l| l.trim()).filter(|l| have.contains(l)).collect();
122 if wanted.is_empty() { "eng".into() } else { wanted.join("+") }
123}
124
125fn tesseract(image: &Path, wanted: &[String]) -> Result<String, Unread> {
126 if !runs("tesseract") {
127 return Err(Unread::Missing(install_hint(wanted)));
128 }
129 let out = crate::command(program("tesseract")).arg(image).arg("stdout").args(["-l", &languages(wanted)]).output().map_err(|e| Unread::Failed(e.to_string()))?;
130 if !out.status.success() {
131 return Err(Unread::Failed(String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or("").to_string()));
132 }
133 Ok(String::from_utf8_lossy(&out.stdout).to_string())
134}
135
136pub fn text_of(file: &Path, work: &Path, wanted: &[String]) -> Result<String, Unread> {
140 let extension = file.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
141 if extension != "pdf" {
142 return tesseract(file, wanted);
143 }
144 if !runs("pdftotext") {
145 return Err(Unread::Missing(install_hint(wanted)));
146 }
147 let own = crate::command(program("pdftotext")).args(["-layout"]).arg(file).arg("-").output().map_err(|e| Unread::Failed(e.to_string()))?;
148 let text = String::from_utf8_lossy(&own.stdout).to_string();
149 if text.chars().filter(|c| c.is_alphanumeric()).count() > 80 {
150 return Ok(text);
151 }
152 let _ = std::fs::remove_dir_all(work);
155 std::fs::create_dir_all(work).map_err(|e| Unread::Failed(e.to_string()))?;
156 let read = pages_read(file, work, wanted);
157 let _ = std::fs::remove_dir_all(work);
158 read
159}
160
161fn pages_read(file: &Path, work: &Path, wanted: &[String]) -> Result<String, Unread> {
163 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()))?;
164 if !made.status.success() {
165 return Err(Unread::Failed(String::from_utf8_lossy(&made.stderr).lines().last().unwrap_or("").to_string()));
166 }
167 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();
168 pages.sort();
169 let mut out = String::new();
170 for page in pages {
171 out.push_str(&tesseract(&page, wanted)?);
172 out.push('\n');
173 }
174 Ok(out)
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
185 fn tesseract_is_found_where_windows_installers_put_it() {
186 let root = std::env::temp_dir().join(format!("sioul-ocr-places-{}", std::process::id()));
187 let _ = std::fs::remove_dir_all(&root);
188 let (programs, programs32, local, bin) = (root.join("Program Files"), root.join("Program Files (x86)"), root.join("Local"), root.join("bin"));
189 for dir in [&programs, &programs32, &local, &bin] {
190 std::fs::create_dir_all(dir).unwrap();
191 }
192 let env = |var: &str| match var {
193 "ProgramW6432" | "ProgramFiles" => Some(programs.display().to_string()),
194 "ProgramFiles(x86)" => Some(programs32.display().to_string()),
195 "LOCALAPPDATA" => Some(local.display().to_string()),
196 _ => None,
197 };
198 let places = tesseract_places(env);
199 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");
200 let path = [bin.clone()];
201 assert_eq!(find_in("tesseract.exe", &path, &places), None, "nowhere");
202 let put = |dir: PathBuf| {
203 std::fs::create_dir_all(&dir).unwrap();
204 std::fs::write(dir.join("tesseract.exe"), "").unwrap();
205 dir.join("tesseract.exe")
206 };
207 let yours = put(local.join("Programs").join("Tesseract-OCR"));
209 assert_eq!(find_in("tesseract.exe", &path, &places), Some(yours));
210 let everyone = put(programs32.join("Tesseract-OCR"));
211 assert_eq!(find_in("tesseract.exe", &path, &places), Some(everyone));
212 let on_path = put(bin.clone());
214 assert_eq!(find_in("tesseract.exe", &path, &places), Some(on_path));
215 assert_eq!(tesseract_places(|_| None), [PathBuf::from(r"C:\Program Files\Tesseract-OCR")]);
217 let _ = std::fs::remove_dir_all(&root);
218 }
219
220 #[test]
223 #[ignore]
224 fn a_letter_as_an_image() {
225 let dir = std::env::temp_dir().join(format!("sioul-ocr-{}", std::process::id()));
226 std::fs::create_dir_all(&dir).unwrap();
227 let image = dir.join("letter.png");
228 let script = format!(
229 "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",
230 image.display()
231 );
232 let made = std::process::Command::new("python3").arg("-c").arg(script).status().unwrap();
233 assert!(made.success());
234 let words = sioul_core::words::Words::builtin();
235 let text = text_of(&image, &dir.join("pages"), &words.ocr.tesseract).unwrap();
236 let reading = sioul_core::letters::read(&words, &text, "2026-10-02".parse().unwrap());
237 assert_eq!(reading.sender, "CAF", "{text}");
238 assert_eq!(reading.deadline, Some("2026-12-02".parse().unwrap()), "{text}");
239 let _ = std::fs::remove_dir_all(&dir);
240 }
241}