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 install_hint(wanted: &[String]) -> String {
72 #[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
109fn 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
129pub 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 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
154fn 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 #[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 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 let on_path = put(bin.clone());
207 assert_eq!(find_in("tesseract.exe", &path, &places), Some(on_path));
208 assert_eq!(tesseract_places(|_| None), [PathBuf::from(r"C:\Program Files\Tesseract-OCR")]);
210 let _ = std::fs::remove_dir_all(&root);
211 }
212
213 #[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}