1use std::fmt::Write as _;
6use std::io::Read;
7use std::path::Path;
8
9use anyhow::{Context, Result};
10
11const CHUNK_LINES: usize = 40;
12
13fn pdf_toc(path: &Path) -> String {
16 use pdf_extract::{Document, Object};
17 let Ok(doc) = Document::load(path) else {
18 return String::new();
19 };
20 let Ok(catalog) = doc.catalog() else {
21 return String::new();
22 };
23 let Ok(outlines_obj) = catalog.get(b"Outlines") else {
24 return String::new();
25 };
26 let first_id = match outlines_obj {
27 Object::Reference(id) => *id,
28 _ => return String::new(),
29 };
30 let Ok(outlines) = doc.get_object(first_id) else {
31 return String::new();
32 };
33 let Ok(dict) = outlines.as_dict() else {
34 return String::new();
35 };
36 let first = match dict.get(b"First") {
37 Ok(Object::Reference(id)) => *id,
38 _ => return String::new(),
39 };
40 let mut out = String::from("=== Table of Contents ===\n");
41 walk_outline(&doc, first, 0, &mut out);
42 if out == "=== Table of Contents ===\n" {
43 return String::new();
44 }
45 out
46}
47
48fn decode_pdf_string(bytes: &[u8]) -> String {
49 if bytes.len() >= 2
51 && bytes[0] == 0xFE
52 && bytes[1] == 0xFF
53 && let Ok(s) = String::from_utf16(
54 &bytes[2..]
55 .chunks_exact(2)
56 .map(|c| u16::from_be_bytes([c[0], c[1]]))
57 .collect::<Vec<_>>(),
58 )
59 {
60 return s;
61 }
62 bytes.iter().map(|&b| b as char).collect()
64}
65
66fn walk_outline(
67 doc: &pdf_extract::Document,
68 id: pdf_extract::ObjectId,
69 depth: usize,
70 out: &mut String,
71) {
72 use pdf_extract::Object;
73 let Ok(item) = doc.get_object(id) else {
74 return;
75 };
76 let Ok(dict) = item.as_dict() else {
77 return;
78 };
79 if let Ok(Object::String(title_bytes, _)) = dict.get(b"Title") {
80 let title = decode_pdf_string(title_bytes);
81 if !title.is_empty() {
82 let indent = " ".repeat(depth);
83 let _ = writeln!(out, "{indent}{title}");
89 }
90 }
91 if let Ok(Object::Reference(child_id)) = dict.get(b"First") {
93 walk_outline(doc, *child_id, depth + 1, out);
94 }
95 if let Ok(Object::Reference(next_id)) = dict.get(b"Next") {
97 walk_outline(doc, *next_id, depth, out);
98 }
99}
100
101pub fn extract_text(path: &Path) -> Result<String> {
105 let ext = path
106 .extension()
107 .and_then(|e| e.to_str())
108 .map(str::to_lowercase)
109 .unwrap_or_default();
110 match ext.as_str() {
111 "pdf" => {
112 let toc = pdf_toc(path);
113 let text = pdf_extract::extract_text(path)
114 .map(|t| t.trim().to_string())
115 .with_context(|| format!("extracting pdf {}", path.display()))?;
116 if toc.is_empty() {
117 Ok(text)
118 } else if text.is_empty() {
119 Ok(toc)
120 } else {
121 Ok(format!("{toc}\n\n{text}"))
122 }
123 }
124 "docx" => office_text(path, &OfficeKind::Docx),
125 "pptx" => office_text(path, &OfficeKind::Pptx),
126 "xlsx" => office_text(path, &OfficeKind::Xlsx),
127 _ if is_image_ext(&ext) => Ok(String::new()),
129 _ => {
131 let bytes =
132 std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
133 let head = &bytes[..bytes.len().min(8192)];
134 if head.contains(&0) {
135 anyhow::bail!("unsupported binary file");
136 }
137 Ok(String::from_utf8_lossy(&bytes).trim().to_string())
138 }
139 }
140}
141
142enum OfficeKind {
143 Docx,
144 Pptx,
145 Xlsx,
146}
147
148#[allow(clippy::case_sensitive_file_extension_comparisons)]
154fn office_text(path: &Path, kind: &OfficeKind) -> Result<String> {
155 let file = std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
156 let mut zip = zip::ZipArchive::new(file).context("reading office zip")?;
157 let mut out = String::new();
158
159 let names: Vec<String> = (0..zip.len())
161 .filter_map(|i| zip.by_index(i).ok().map(|e| e.name().to_string()))
162 .collect();
163 let mut read_entry = |name: &str| -> Option<String> {
164 let mut e = zip.by_name(name).ok()?;
165 let mut s = String::new();
166 e.read_to_string(&mut s).ok()?;
167 Some(s)
168 };
169
170 match kind {
171 OfficeKind::Docx => {
172 if let Some(xml) = read_entry("word/document.xml") {
173 for para in xml.split("</w:p>") {
175 let line = xml_tag_texts(para, "w:t").join("");
176 if !line.trim().is_empty() {
177 out.push_str(line.trim());
178 out.push('\n');
179 }
180 }
181 }
182 }
183 OfficeKind::Pptx => {
184 let mut slides: Vec<&String> = names
185 .iter()
186 .filter(|n| n.starts_with("ppt/slides/slide") && n.ends_with(".xml"))
187 .collect();
188 slides.sort_by_key(|n| {
190 n.trim_start_matches("ppt/slides/slide")
191 .trim_end_matches(".xml")
192 .parse::<u32>()
193 .unwrap_or(0)
194 });
195 for name in slides {
196 let n = name
197 .trim_start_matches("ppt/slides/slide")
198 .trim_end_matches(".xml");
199 if let Some(xml) = read_entry(name) {
200 let texts = xml_tag_texts(&xml, "a:t");
201 if !texts.is_empty() {
202 let _ = writeln!(out, "[slide {n}]");
203 out.push_str(&texts.join("\n"));
204 out.push('\n');
205 }
206 }
207 }
208 }
209 OfficeKind::Xlsx => {
210 if let Some(xml) = read_entry("xl/sharedStrings.xml") {
214 out.push_str(&xml_tag_texts(&xml, "t").join("\n"));
215 out.push('\n');
216 }
217 let mut sheets: Vec<&String> = names
218 .iter()
219 .filter(|n| n.starts_with("xl/worksheets/") && n.ends_with(".xml"))
220 .collect();
221 sheets.sort();
222 for name in sheets {
223 if let Some(xml) = read_entry(name) {
224 let vals = xml_tag_texts(&xml, "v");
225 if !vals.is_empty() {
226 out.push_str(&vals.join(" "));
227 out.push('\n');
228 }
229 }
230 }
231 }
232 }
233 Ok(out.trim().to_string())
234}
235
236fn xml_tag_texts(xml: &str, tag: &str) -> Vec<String> {
239 let open = format!("<{tag}");
240 let close = format!("</{tag}>");
241 let mut out = Vec::new();
242 let mut rest = xml;
243 while let Some(start) = rest.find(&open) {
244 let after = &rest[start + open.len()..];
245 let Some(gt) = after.find('>') else { break };
247 let head = &after[..gt];
248 rest = &after[gt + 1..];
249 if !(head.is_empty() || head.starts_with(' ') || head.starts_with('/')) {
250 continue; }
252 if head.ends_with('/') {
253 continue; }
255 let Some(end) = rest.find(&close) else { break };
256 let text = xml_unescape(&rest[..end]);
257 if !text.is_empty() {
258 out.push(text);
259 }
260 rest = &rest[end + close.len()..];
261 }
262 out
263}
264
265fn xml_unescape(s: &str) -> String {
266 s.replace("<", "<")
269 .replace(">", ">")
270 .replace(""", "\"")
271 .replace("'", "'")
272 .replace("&", "&")
273}
274
275pub fn chunk_lines(text: &str) -> Vec<(String, String)> {
277 if text.trim().is_empty() {
278 return Vec::new();
279 }
280 let lines: Vec<&str> = text.lines().collect();
281 lines
282 .chunks(CHUNK_LINES)
283 .enumerate()
284 .map(|(i, chunk)| {
285 let first = i * CHUNK_LINES + 1;
286 let last = first + chunk.len() - 1;
287 (format!("lines {first}-{last}"), chunk.join("\n"))
288 })
289 .collect()
290}
291
292pub fn is_image_ext(ext: &str) -> bool {
294 matches!(ext, "jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp")
295}
296
297#[derive(Debug)]
300pub enum OcrError {
301 MissingTools,
302 Failed(String),
303}
304
305pub fn ocr_pdf(
310 path: &Path,
311 progress: &(dyn Fn(usize, usize) + Sync),
312) -> std::result::Result<String, OcrError> {
313 ocr_pdf_with("pdftoppm", "tesseract", path, progress)
314}
315
316fn ocr_pdf_with(
319 pdftoppm: &str,
320 tesseract: &str,
321 path: &Path,
322 progress: &(dyn Fn(usize, usize) + Sync),
323) -> std::result::Result<String, OcrError> {
324 let tmp = std::env::temp_dir().join(format!("nexus-ocr-{}", uuid::Uuid::new_v4()));
325 std::fs::create_dir_all(&tmp).map_err(|e| OcrError::Failed(e.to_string()))?;
326 let result = ocr_pdf_in(pdftoppm, tesseract, path, &tmp, progress);
327 let _ = std::fs::remove_dir_all(&tmp);
328 result
329}
330
331fn run_ocr_cmd(
334 cmd: &mut std::process::Command,
335 name: &str,
336) -> std::result::Result<Vec<u8>, OcrError> {
337 let out = cmd.output().map_err(|e| {
338 if e.kind() == std::io::ErrorKind::NotFound {
339 OcrError::MissingTools
340 } else {
341 OcrError::Failed(e.to_string())
342 }
343 })?;
344 if !out.status.success() {
345 return Err(OcrError::Failed(format!(
346 "{name}: {}",
347 String::from_utf8_lossy(&out.stderr).trim()
348 )));
349 }
350 Ok(out.stdout)
351}
352
353pub fn render_pdf_pages(
356 pdftoppm: &str,
357 path: &Path,
358 tmp: &Path,
359 dpi: u32,
360 gray: bool,
361) -> std::result::Result<Vec<std::path::PathBuf>, OcrError> {
362 let mut cmd = std::process::Command::new(pdftoppm);
363 cmd.args(["-r", &dpi.to_string()]);
364 if gray {
365 cmd.arg("-gray");
366 }
367 run_ocr_cmd(cmd.arg("-png").arg(path).arg(tmp.join("page")), "pdftoppm")?;
368 let mut pages: Vec<std::path::PathBuf> = std::fs::read_dir(tmp)
369 .map_err(|e| OcrError::Failed(e.to_string()))?
370 .flatten()
371 .map(|e| e.path())
372 .filter(|p| p.extension().is_some_and(|e| e == "png"))
373 .collect();
374 pages.sort();
375 Ok(pages)
376}
377
378pub fn join_pages(results: &[std::result::Result<String, String>]) -> String {
382 let mut text = String::new();
383 for (i, r) in results.iter().enumerate() {
384 match r {
385 Ok(p) if p.trim().is_empty() => {}
386 Ok(p) => {
387 let _ = write!(text, "[page {}]\n{}\n", i + 1, p.trim());
388 }
389 Err(_) => {
390 let _ = writeln!(text, "[page {}: ocr failed]", i + 1);
391 }
392 }
393 }
394 text.trim().to_string()
395}
396
397fn ocr_pdf_in(
398 pdftoppm: &str,
399 tesseract: &str,
400 path: &Path,
401 tmp: &Path,
402 progress: &(dyn Fn(usize, usize) + Sync),
403) -> std::result::Result<String, OcrError> {
404 let run = run_ocr_cmd;
405
406 let pages = render_pdf_pages(pdftoppm, path, tmp, 200, true)?;
409
410 let langs = installed_langs(tesseract);
415
416 let workers = std::thread::available_parallelism()
419 .map_or(4, std::num::NonZero::get)
420 .min(pages.len().max(1));
421 let next = std::sync::atomic::AtomicUsize::new(0);
422 let results = std::sync::Mutex::new(Vec::with_capacity(pages.len()));
423 std::thread::scope(|s| {
424 for _ in 0..workers {
425 s.spawn(|| {
426 loop {
427 let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
428 let Some(png) = pages.get(i) else { return };
429 let mut cmd = std::process::Command::new(tesseract);
430 cmd.arg(png).arg("stdout");
431 if let Some(l) = &langs {
432 cmd.args(["-l", l]);
433 }
434 let r = run(&mut cmd, "tesseract");
435 let done = {
436 let mut res = results.lock().unwrap();
437 res.push((i, r));
438 res.len()
439 };
440 progress(done, pages.len());
441 }
442 });
443 }
444 });
445 let mut results = results.into_inner().unwrap();
446 results.sort_by_key(|(i, _)| *i);
447
448 let mut text = String::new();
449 for (i, r) in results {
450 let stdout = r?;
451 let page = String::from_utf8_lossy(&stdout);
452 let page = page.trim();
453 if !page.is_empty() {
454 let _ = writeln!(text, "[page {}]\n{page}", i + 1);
455 }
456 }
457 Ok(text.trim().to_string())
458}
459
460fn installed_langs(tesseract: &str) -> Option<String> {
465 let out = std::process::Command::new(tesseract)
466 .arg("--list-langs")
467 .output()
468 .ok()?;
469 let text = format!(
470 "{}{}",
471 String::from_utf8_lossy(&out.stdout),
472 String::from_utf8_lossy(&out.stderr)
473 );
474 let langs: Vec<&str> = text
475 .lines()
476 .map(str::trim)
477 .filter(|l| !l.is_empty() && !l.contains(' ') && *l != "osd")
478 .collect();
479 (!langs.is_empty()).then(|| langs.join("+"))
480}
481
482#[cfg(test)]
486pub(crate) fn minimal_pdf(text: Option<&str>) -> Vec<u8> {
487 if let Some(t) = text {
488 pdf_with_pages(&[t])
489 } else {
490 let objs = vec![
491 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
492 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
493 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 150] >>".to_string(),
494 ];
495 serialize_pdf(&objs)
496 }
497}
498
499#[cfg(test)]
501pub(crate) fn pdf_with_pages(texts: &[&str]) -> Vec<u8> {
502 let font_obj = 2 + 2 * texts.len() + 1; let mut objs: Vec<String> = Vec::new();
504 objs.push("<< /Type /Catalog /Pages 2 0 R >>".into());
505 let kids: Vec<String> = (0..texts.len())
506 .map(|i| format!("{} 0 R", 3 + 2 * i))
507 .collect();
508 objs.push(format!(
509 "<< /Type /Pages /Kids [{}] /Count {} >>",
510 kids.join(" "),
511 texts.len()
512 ));
513 for (i, t) in texts.iter().enumerate() {
514 objs.push(format!(
515 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 150] /Contents {} 0 R /Resources << /Font << /F1 {font_obj} 0 R >> >> >>",
516 4 + 2 * i
517 ));
518 let stream = format!("BT /F1 32 Tf 20 60 Td ({t}) Tj ET");
519 objs.push(format!(
520 "<< /Length {} >>\nstream\n{stream}\nendstream",
521 stream.len()
522 ));
523 }
524 objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
525 serialize_pdf(&objs)
526}
527
528#[cfg(test)]
529fn serialize_pdf(objs: &[String]) -> Vec<u8> {
530 let mut out = b"%PDF-1.4\n".to_vec();
531 let mut offsets: Vec<usize> = Vec::new();
532 for (i, o) in objs.iter().enumerate() {
533 offsets.push(out.len());
534 out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes());
535 }
536 let xref_pos = out.len();
537 let n = objs.len() + 1;
538 out.extend_from_slice(format!("xref\n0 {n}\n").as_bytes());
539 out.extend_from_slice(b"0000000000 65535 f \n");
540 for off in &offsets {
541 out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
542 }
543 out.extend_from_slice(
544 format!("trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n").as_bytes(),
545 );
546 out
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use std::io::Write;
553
554 fn office_fixture(name: &str, entries: &[(&str, &str)]) -> std::path::PathBuf {
556 let dir = std::env::temp_dir().join(format!("nexus-extract-{}", uuid::Uuid::new_v4()));
557 std::fs::create_dir_all(&dir).unwrap();
558 let path = dir.join(name);
559 let file = std::fs::File::create(&path).unwrap();
560 let mut zip = zip::ZipWriter::new(file);
561 let opts = zip::write::SimpleFileOptions::default();
562 for (entry, xml) in entries {
563 zip.start_file(*entry, opts).unwrap();
564 zip.write_all(xml.as_bytes()).unwrap();
565 }
566 zip.finish().unwrap();
567 path
568 }
569
570 #[test]
571 fn plain_text_reads_as_is() {
572 let dir = std::env::temp_dir().join(format!("nexus-extract-{}", uuid::Uuid::new_v4()));
573 std::fs::create_dir_all(&dir).unwrap();
574 let p = dir.join("notes.md");
575 std::fs::write(&p, "# hi\nbody").unwrap();
576 assert_eq!(extract_text(&p).unwrap(), "# hi\nbody");
577 }
578
579 #[test]
580 fn unknown_binary_is_an_error() {
581 let dir = std::env::temp_dir().join(format!("nexus-extract-{}", uuid::Uuid::new_v4()));
582 std::fs::create_dir_all(&dir).unwrap();
583 let p = dir.join("blob.bin");
584 std::fs::write(&p, [0u8, 159, 146, 150]).unwrap();
585 assert!(extract_text(&p).is_err());
586 }
587
588 #[test]
589 fn docx_pulls_paragraph_text() {
590 let p = office_fixture(
591 "d.docx",
592 &[(
593 "word/document.xml",
594 r#"<w:document><w:p><w:r><w:t>Hello</w:t></w:r><w:r><w:t xml:space="preserve"> world</w:t></w:r></w:p><w:p><w:r><w:t>Second & last</w:t></w:r></w:p></w:document>"#,
595 )],
596 );
597 let text = extract_text(&p).unwrap();
598 assert_eq!(text, "Hello world\nSecond & last");
599 }
600
601 #[test]
602 fn pptx_pulls_slide_text_with_slide_markers() {
603 let p = office_fixture(
604 "s.pptx",
605 &[
606 (
607 "ppt/slides/slide1.xml",
608 r"<p:sld><a:t>Title one</a:t><a:t>Bullet</a:t></p:sld>",
609 ),
610 (
611 "ppt/slides/slide2.xml",
612 r"<p:sld><a:t>Second slide</a:t></p:sld>",
613 ),
614 (
615 "ppt/slides/slide10.xml",
616 r"<p:sld><a:t>Tenth slide</a:t></p:sld>",
617 ),
618 ],
619 );
620 let text = extract_text(&p).unwrap();
621 assert!(text.contains("[slide 1]"));
622 assert!(text.contains("Title one"));
623 assert!(text.contains("[slide 2]"));
624 assert!(text.contains("Second slide"));
625 let i2 = text.find("[slide 2]").unwrap();
627 let i10 = text.find("[slide 10]").unwrap();
628 assert!(i2 < i10);
629 }
630
631 #[test]
632 fn xlsx_pulls_shared_strings_and_cell_values() {
633 let p = office_fixture(
634 "x.xlsx",
635 &[
636 (
637 "xl/sharedStrings.xml",
638 r"<sst><si><t>revenue</t></si><si><t>cost</t></si></sst>",
639 ),
640 (
641 "xl/worksheets/sheet1.xml",
642 r"<worksheet><c><v>42</v></c><c><v>7</v></c></worksheet>",
643 ),
644 ],
645 );
646 let text = extract_text(&p).unwrap();
647 assert!(text.contains("revenue"));
648 assert!(text.contains("cost"));
649 assert!(text.contains("42"));
650 }
651
652 #[test]
653 fn xml_tag_texts_handles_attrs_self_closing_and_entities() {
654 let xml = r#"<w:t a="b">one</w:t><w:t/><w:t>two <3</w:t>"#;
655 assert_eq!(
656 xml_tag_texts(xml, "w:t"),
657 vec!["one".to_string(), "two <3".to_string()]
658 );
659 }
660
661 #[test]
662 fn xml_unescape_does_not_double_unescape_compound_entities() {
663 assert_eq!(xml_unescape("&amp;lt;"), "&lt;");
664 assert_eq!(xml_unescape("a & b <3"), "a & b <3");
665 }
666
667 #[test]
668 fn chunks_are_40_lines_with_locations() {
669 let text = (1..=90)
670 .map(|i| i.to_string())
671 .collect::<Vec<_>>()
672 .join("\n");
673 let chunks = chunk_lines(&text);
674 assert_eq!(chunks.len(), 3);
675 assert_eq!(chunks[0].0, "lines 1-40");
676 assert!(chunks[0].1.starts_with("1\n"));
677 assert_eq!(chunks[1].0, "lines 41-80");
678 assert_eq!(chunks[2].0, "lines 81-90");
679 assert!(
680 chunks[2].1.ends_with("\n90")
681 || chunks[2].1 == "81\n82\n83\n84\n85\n86\n87\n88\n89\n90"
682 );
683 assert!(chunk_lines("").is_empty());
684 assert!(chunk_lines(" \n ").is_empty());
685 }
686
687 fn ocr_tools_present() -> bool {
689 std::process::Command::new("tesseract")
690 .arg("--version")
691 .output()
692 .is_ok()
693 && std::process::Command::new("pdftoppm")
694 .arg("-v")
695 .output()
696 .is_ok()
697 }
698
699 #[test]
700 fn ocr_pdf_reads_rendered_text() {
701 if !ocr_tools_present() {
702 eprintln!("skipping ocr_pdf_reads_rendered_text: tesseract/pdftoppm not installed");
703 return;
704 }
705 let dir = std::env::temp_dir().join(format!("nexus-ocr-test-{}", uuid::Uuid::new_v4()));
706 std::fs::create_dir_all(&dir).unwrap();
707 let pdf = dir.join("scan.pdf");
708 std::fs::write(&pdf, minimal_pdf(Some("HELLO NEXUS OCR"))).unwrap();
709
710 let text = ocr_pdf(&pdf, &|_, _| {}).unwrap();
711 assert!(text.contains("HELLO"), "ocr text was: {text:?}");
712 assert!(text.contains("[page 1]"), "ocr text was: {text:?}");
713 }
714
715 #[test]
716 fn ocr_pdf_blank_page_yields_empty_text() {
717 if !ocr_tools_present() {
718 eprintln!("skipping ocr_pdf_blank_page_yields_empty_text: tools not installed");
719 return;
720 }
721 let dir = std::env::temp_dir().join(format!("nexus-ocr-test-{}", uuid::Uuid::new_v4()));
722 std::fs::create_dir_all(&dir).unwrap();
723 let pdf = dir.join("blank.pdf");
724 std::fs::write(&pdf, minimal_pdf(None)).unwrap();
725 assert_eq!(ocr_pdf(&pdf, &|_, _| {}).unwrap(), "");
726 }
727
728 #[test]
729 fn render_pdf_pages_renders_sorted_pages_at_dpi() {
730 if !ocr_tools_present() {
731 eprintln!("skipping render_pdf_pages_renders_sorted_pages_at_dpi: tools not installed");
732 return;
733 }
734 let dir = std::env::temp_dir().join(format!("nexus-render-test-{}", uuid::Uuid::new_v4()));
735 std::fs::create_dir_all(&dir).unwrap();
736 let pdf = dir.join("multi.pdf");
737 std::fs::write(&pdf, pdf_with_pages(&["ONE", "TWO", "THREE"])).unwrap();
738 let tmp = dir.join("pages");
739 std::fs::create_dir_all(&tmp).unwrap();
740
741 let pages = render_pdf_pages("pdftoppm", &pdf, &tmp, 120, false).unwrap();
742 assert_eq!(pages.len(), 3);
743 let mut sorted = pages.clone();
744 sorted.sort();
745 assert_eq!(pages, sorted, "pages must come back in document order");
746 let _ = std::fs::remove_dir_all(&dir);
747 }
748
749 #[test]
750 fn join_pages_marks_failures_and_skips_blank_pages() {
751 let joined = join_pages(&[
752 Ok("first".to_string()),
753 Err("boom".to_string()),
754 Ok(" ".to_string()),
755 Ok("fourth".to_string()),
756 ]);
757 assert!(joined.contains("[page 1]\nfirst"), "{joined:?}");
758 assert!(joined.contains("[page 2: ocr failed]"), "{joined:?}");
759 assert!(!joined.contains("[page 3]"), "{joined:?}");
760 assert!(joined.contains("[page 4]\nfourth"), "{joined:?}");
761 }
762
763 #[test]
764 fn ocr_pdf_multipage_keeps_page_order() {
765 if !ocr_tools_present() {
766 eprintln!("skipping ocr_pdf_multipage_keeps_page_order: tools not installed");
767 return;
768 }
769 let dir = std::env::temp_dir().join(format!("nexus-ocr-test-{}", uuid::Uuid::new_v4()));
770 std::fs::create_dir_all(&dir).unwrap();
771 let pdf = dir.join("multi.pdf");
772 std::fs::write(
773 &pdf,
774 pdf_with_pages(&["ALPHA BRAVO", "CHARLIE DELTA", "ECHO FOXTROT"]),
775 )
776 .unwrap();
777
778 let text = ocr_pdf(&pdf, &|_, _| {}).unwrap();
779 let a = text.find("ALPHA").expect(&text);
781 let c = text.find("CHARLIE").expect(&text);
782 let e = text.find("ECHO").expect(&text);
783 assert!(a < c && c < e, "pages out of order: {text:?}");
784 assert!(text.contains("[page 3]"), "{text:?}");
785 }
786
787 #[test]
788 fn installed_langs_lists_packs_without_osd() {
789 if !ocr_tools_present() {
790 eprintln!("skipping installed_langs_lists_packs_without_osd: tools not installed");
791 return;
792 }
793 let langs = installed_langs("tesseract").expect("langs should list");
794 assert!(langs.split('+').any(|l| l == "eng"), "{langs}");
795 assert!(!langs.split('+').any(|l| l == "osd"), "{langs}");
796 }
797
798 #[test]
799 fn installed_langs_missing_binary_is_none() {
800 assert!(installed_langs("nexus-definitely-not-a-binary").is_none());
801 }
802
803 #[test]
804 fn ocr_pdf_missing_tools_is_distinguishable() {
805 let dir = std::env::temp_dir().join(format!("nexus-ocr-test-{}", uuid::Uuid::new_v4()));
806 std::fs::create_dir_all(&dir).unwrap();
807 let pdf = dir.join("x.pdf");
808 std::fs::write(&pdf, minimal_pdf(None)).unwrap();
809 let err = ocr_pdf_with(
810 "nexus-definitely-not-a-binary",
811 "tesseract",
812 &pdf,
813 &|_, _| {},
814 )
815 .unwrap_err();
816 assert!(matches!(err, OcrError::MissingTools));
817 }
818}