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 _ => {
136 let bytes =
137 std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
138 if let Some(text) = decode_utf16(&bytes) {
139 return Ok(text.trim().to_string());
140 }
141 if bytes.contains(&0) {
142 anyhow::bail!("unsupported binary file");
143 }
144 Ok(String::from_utf8_lossy(&bytes)
145 .trim_start_matches('\u{feff}')
146 .trim()
147 .to_string())
148 }
149 }
150}
151
152fn decode_utf16(bytes: &[u8]) -> Option<String> {
155 let (little_endian, payload) = if bytes.starts_with(&[0xFF, 0xFE]) {
156 (true, &bytes[2..])
157 } else if bytes.starts_with(&[0xFE, 0xFF]) {
158 (false, &bytes[2..])
159 } else {
160 return None;
161 };
162 let units: Vec<u16> = payload
163 .chunks_exact(2)
164 .map(|pair| {
165 if little_endian {
166 u16::from_le_bytes([pair[0], pair[1]])
167 } else {
168 u16::from_be_bytes([pair[0], pair[1]])
169 }
170 })
171 .collect();
172 String::from_utf16(&units).ok()
173}
174
175pub fn metadata_chunks(name: &str) -> Vec<(String, String)> {
179 vec![(
180 "file metadata".to_string(),
181 format!(
182 "uploaded file: {name}\nNo text could be extracted from this file; search by its file name."
183 ),
184 )]
185}
186
187enum OfficeKind {
188 Docx,
189 Pptx,
190 Xlsx,
191}
192
193#[allow(clippy::case_sensitive_file_extension_comparisons)]
199fn office_text(path: &Path, kind: &OfficeKind) -> Result<String> {
200 let file = std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
201 let mut zip = zip::ZipArchive::new(file).context("reading office zip")?;
202 let mut out = String::new();
203
204 let names: Vec<String> = (0..zip.len())
206 .filter_map(|i| zip.by_index(i).ok().map(|e| e.name().to_string()))
207 .collect();
208 let mut read_entry = |name: &str| -> Option<String> {
209 let mut e = zip.by_name(name).ok()?;
210 let mut s = String::new();
211 e.read_to_string(&mut s).ok()?;
212 Some(s)
213 };
214
215 match kind {
216 OfficeKind::Docx => {
217 if let Some(xml) = read_entry("word/document.xml") {
218 for para in xml.split("</w:p>") {
220 let line = xml_tag_texts(para, "w:t").join("");
221 if !line.trim().is_empty() {
222 out.push_str(line.trim());
223 out.push('\n');
224 }
225 }
226 }
227 }
228 OfficeKind::Pptx => {
229 let mut slides: Vec<&String> = names
230 .iter()
231 .filter(|n| n.starts_with("ppt/slides/slide") && n.ends_with(".xml"))
232 .collect();
233 slides.sort_by_key(|n| {
235 n.trim_start_matches("ppt/slides/slide")
236 .trim_end_matches(".xml")
237 .parse::<u32>()
238 .unwrap_or(0)
239 });
240 for name in slides {
241 let n = name
242 .trim_start_matches("ppt/slides/slide")
243 .trim_end_matches(".xml");
244 if let Some(xml) = read_entry(name) {
245 let texts = xml_tag_texts(&xml, "a:t");
246 if !texts.is_empty() {
247 let _ = writeln!(out, "[slide {n}]");
248 out.push_str(&texts.join("\n"));
249 out.push('\n');
250 }
251 }
252 }
253 }
254 OfficeKind::Xlsx => {
255 if let Some(xml) = read_entry("xl/sharedStrings.xml") {
259 out.push_str(&xml_tag_texts(&xml, "t").join("\n"));
260 out.push('\n');
261 }
262 let mut sheets: Vec<&String> = names
263 .iter()
264 .filter(|n| n.starts_with("xl/worksheets/") && n.ends_with(".xml"))
265 .collect();
266 sheets.sort();
267 for name in sheets {
268 if let Some(xml) = read_entry(name) {
269 let vals = xml_tag_texts(&xml, "v");
270 if !vals.is_empty() {
271 out.push_str(&vals.join(" "));
272 out.push('\n');
273 }
274 }
275 }
276 }
277 }
278 Ok(out.trim().to_string())
279}
280
281fn xml_tag_texts(xml: &str, tag: &str) -> Vec<String> {
284 let open = format!("<{tag}");
285 let close = format!("</{tag}>");
286 let mut out = Vec::new();
287 let mut rest = xml;
288 while let Some(start) = rest.find(&open) {
289 let after = &rest[start + open.len()..];
290 let Some(gt) = after.find('>') else { break };
292 let head = &after[..gt];
293 rest = &after[gt + 1..];
294 if !(head.is_empty() || head.starts_with(' ') || head.starts_with('/')) {
295 continue; }
297 if head.ends_with('/') {
298 continue; }
300 let Some(end) = rest.find(&close) else { break };
301 let text = xml_unescape(&rest[..end]);
302 if !text.is_empty() {
303 out.push(text);
304 }
305 rest = &rest[end + close.len()..];
306 }
307 out
308}
309
310fn xml_unescape(s: &str) -> String {
311 s.replace("<", "<")
314 .replace(">", ">")
315 .replace(""", "\"")
316 .replace("'", "'")
317 .replace("&", "&")
318}
319
320pub fn chunk_lines(text: &str) -> Vec<(String, String)> {
322 if text.trim().is_empty() {
323 return Vec::new();
324 }
325 let lines: Vec<&str> = text.lines().collect();
326 lines
327 .chunks(CHUNK_LINES)
328 .enumerate()
329 .map(|(i, chunk)| {
330 let first = i * CHUNK_LINES + 1;
331 let last = first + chunk.len() - 1;
332 (format!("lines {first}-{last}"), chunk.join("\n"))
333 })
334 .collect()
335}
336
337pub fn is_image_ext(ext: &str) -> bool {
339 matches!(ext, "jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp")
340}
341
342#[derive(Debug)]
345pub enum OcrError {
346 MissingTools,
347 Failed(String),
348}
349
350pub fn ocr_pdf(
355 path: &Path,
356 progress: &(dyn Fn(usize, usize) + Sync),
357) -> std::result::Result<String, OcrError> {
358 ocr_pdf_with("pdftoppm", "tesseract", path, progress)
359}
360
361fn ocr_pdf_with(
364 pdftoppm: &str,
365 tesseract: &str,
366 path: &Path,
367 progress: &(dyn Fn(usize, usize) + Sync),
368) -> std::result::Result<String, OcrError> {
369 let tmp = std::env::temp_dir().join(format!("nexus-ocr-{}", uuid::Uuid::new_v4()));
370 std::fs::create_dir_all(&tmp).map_err(|e| OcrError::Failed(e.to_string()))?;
371 let result = ocr_pdf_in(pdftoppm, tesseract, path, &tmp, progress);
372 let _ = std::fs::remove_dir_all(&tmp);
373 result
374}
375
376fn run_ocr_cmd(
379 cmd: &mut std::process::Command,
380 name: &str,
381) -> std::result::Result<Vec<u8>, OcrError> {
382 let out = cmd.output().map_err(|e| {
383 if e.kind() == std::io::ErrorKind::NotFound {
384 OcrError::MissingTools
385 } else {
386 OcrError::Failed(e.to_string())
387 }
388 })?;
389 if !out.status.success() {
390 return Err(OcrError::Failed(format!(
391 "{name}: {}",
392 String::from_utf8_lossy(&out.stderr).trim()
393 )));
394 }
395 Ok(out.stdout)
396}
397
398pub fn render_pdf_pages(
401 pdftoppm: &str,
402 path: &Path,
403 tmp: &Path,
404 dpi: u32,
405 gray: bool,
406) -> std::result::Result<Vec<std::path::PathBuf>, OcrError> {
407 let mut cmd = std::process::Command::new(pdftoppm);
408 cmd.args(["-r", &dpi.to_string()]);
409 if gray {
410 cmd.arg("-gray");
411 }
412 run_ocr_cmd(cmd.arg("-png").arg(path).arg(tmp.join("page")), "pdftoppm")?;
413 let mut pages: Vec<std::path::PathBuf> = std::fs::read_dir(tmp)
414 .map_err(|e| OcrError::Failed(e.to_string()))?
415 .flatten()
416 .map(|e| e.path())
417 .filter(|p| p.extension().is_some_and(|e| e == "png"))
418 .collect();
419 pages.sort();
420 Ok(pages)
421}
422
423pub fn join_pages(results: &[std::result::Result<String, String>]) -> String {
427 let mut text = String::new();
428 for (i, r) in results.iter().enumerate() {
429 match r {
430 Ok(p) if p.trim().is_empty() => {}
431 Ok(p) => {
432 let _ = write!(text, "[page {}]\n{}\n", i + 1, p.trim());
433 }
434 Err(_) => {
435 let _ = writeln!(text, "[page {}: ocr failed]", i + 1);
436 }
437 }
438 }
439 text.trim().to_string()
440}
441
442fn ocr_pdf_in(
443 pdftoppm: &str,
444 tesseract: &str,
445 path: &Path,
446 tmp: &Path,
447 progress: &(dyn Fn(usize, usize) + Sync),
448) -> std::result::Result<String, OcrError> {
449 let run = run_ocr_cmd;
450
451 let pages = render_pdf_pages(pdftoppm, path, tmp, 200, true)?;
454
455 let langs = installed_langs(tesseract);
460
461 let workers = std::thread::available_parallelism()
464 .map_or(4, std::num::NonZero::get)
465 .min(pages.len().max(1));
466 let next = std::sync::atomic::AtomicUsize::new(0);
467 let results = std::sync::Mutex::new(Vec::with_capacity(pages.len()));
468 std::thread::scope(|s| {
469 for _ in 0..workers {
470 s.spawn(|| {
471 loop {
472 let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
473 let Some(png) = pages.get(i) else { return };
474 let mut cmd = std::process::Command::new(tesseract);
475 cmd.arg(png).arg("stdout");
476 if let Some(l) = &langs {
477 cmd.args(["-l", l]);
478 }
479 let r = run(&mut cmd, "tesseract");
480 let done = {
481 let mut res = results.lock().unwrap();
482 res.push((i, r));
483 res.len()
484 };
485 progress(done, pages.len());
486 }
487 });
488 }
489 });
490 let mut results = results.into_inner().unwrap();
491 results.sort_by_key(|(i, _)| *i);
492
493 let mut text = String::new();
494 for (i, r) in results {
495 let stdout = r?;
496 let page = String::from_utf8_lossy(&stdout);
497 let page = page.trim();
498 if !page.is_empty() {
499 let _ = writeln!(text, "[page {}]\n{page}", i + 1);
500 }
501 }
502 Ok(text.trim().to_string())
503}
504
505fn installed_langs(tesseract: &str) -> Option<String> {
510 let out = std::process::Command::new(tesseract)
511 .arg("--list-langs")
512 .output()
513 .ok()?;
514 let text = format!(
515 "{}{}",
516 String::from_utf8_lossy(&out.stdout),
517 String::from_utf8_lossy(&out.stderr)
518 );
519 let langs: Vec<&str> = text
520 .lines()
521 .map(str::trim)
522 .filter(|l| !l.is_empty() && !l.contains(' ') && *l != "osd")
523 .collect();
524 (!langs.is_empty()).then(|| langs.join("+"))
525}
526
527#[cfg(test)]
531pub(crate) fn minimal_pdf(text: Option<&str>) -> Vec<u8> {
532 if let Some(t) = text {
533 pdf_with_pages(&[t])
534 } else {
535 let objs = vec![
536 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
537 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
538 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 150] >>".to_string(),
539 ];
540 serialize_pdf(&objs)
541 }
542}
543
544#[cfg(test)]
546pub(crate) fn pdf_with_pages(texts: &[&str]) -> Vec<u8> {
547 let font_obj = 2 + 2 * texts.len() + 1; let mut objs: Vec<String> = Vec::new();
549 objs.push("<< /Type /Catalog /Pages 2 0 R >>".into());
550 let kids: Vec<String> = (0..texts.len())
551 .map(|i| format!("{} 0 R", 3 + 2 * i))
552 .collect();
553 objs.push(format!(
554 "<< /Type /Pages /Kids [{}] /Count {} >>",
555 kids.join(" "),
556 texts.len()
557 ));
558 for (i, t) in texts.iter().enumerate() {
559 objs.push(format!(
560 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 150] /Contents {} 0 R /Resources << /Font << /F1 {font_obj} 0 R >> >> >>",
561 4 + 2 * i
562 ));
563 let stream = format!("BT /F1 32 Tf 20 60 Td ({t}) Tj ET");
564 objs.push(format!(
565 "<< /Length {} >>\nstream\n{stream}\nendstream",
566 stream.len()
567 ));
568 }
569 objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
570 serialize_pdf(&objs)
571}
572
573#[cfg(test)]
574fn serialize_pdf(objs: &[String]) -> Vec<u8> {
575 let mut out = b"%PDF-1.4\n".to_vec();
576 let mut offsets: Vec<usize> = Vec::new();
577 for (i, o) in objs.iter().enumerate() {
578 offsets.push(out.len());
579 out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes());
580 }
581 let xref_pos = out.len();
582 let n = objs.len() + 1;
583 out.extend_from_slice(format!("xref\n0 {n}\n").as_bytes());
584 out.extend_from_slice(b"0000000000 65535 f \n");
585 for off in &offsets {
586 out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
587 }
588 out.extend_from_slice(
589 format!("trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n").as_bytes(),
590 );
591 out
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use std::io::Write;
598
599 fn office_fixture(name: &str, entries: &[(&str, &str)]) -> std::path::PathBuf {
601 let dir = std::env::temp_dir().join(format!("nexus-extract-{}", uuid::Uuid::new_v4()));
602 std::fs::create_dir_all(&dir).unwrap();
603 let path = dir.join(name);
604 let file = std::fs::File::create(&path).unwrap();
605 let mut zip = zip::ZipWriter::new(file);
606 let opts = zip::write::SimpleFileOptions::default();
607 for (entry, xml) in entries {
608 zip.start_file(*entry, opts).unwrap();
609 zip.write_all(xml.as_bytes()).unwrap();
610 }
611 zip.finish().unwrap();
612 path
613 }
614
615 #[test]
616 fn plain_text_reads_as_is() {
617 let dir = std::env::temp_dir().join(format!("nexus-extract-{}", uuid::Uuid::new_v4()));
618 std::fs::create_dir_all(&dir).unwrap();
619 let p = dir.join("notes.md");
620 std::fs::write(&p, "# hi\nbody").unwrap();
621 assert_eq!(extract_text(&p).unwrap(), "# hi\nbody");
622 }
623
624 #[test]
625 fn unknown_binary_is_an_error() {
626 let dir = std::env::temp_dir().join(format!("nexus-extract-{}", uuid::Uuid::new_v4()));
627 std::fs::create_dir_all(&dir).unwrap();
628 let p = dir.join("blob.bin");
629 std::fs::write(&p, [0u8, 159, 146, 150]).unwrap();
630 assert!(extract_text(&p).is_err());
631 }
632
633 #[test]
634 fn generic_code_files_are_plain_text() {
635 let dir = std::env::temp_dir().join(format!("nexus-extract-{}", uuid::Uuid::new_v4()));
636 std::fs::create_dir_all(&dir).unwrap();
637 for (name, expected) in [("page.html", "<main>hello</main>"), ("job.py", "print(42)")] {
638 let path = dir.join(name);
639 std::fs::write(&path, expected).unwrap();
640 assert_eq!(extract_text(&path).unwrap(), expected, "{name}");
641 }
642 }
643
644 #[test]
645 fn utf16_code_files_are_decoded_before_binary_detection() {
646 let dir = std::env::temp_dir().join(format!("nexus-extract-{}", uuid::Uuid::new_v4()));
647 std::fs::create_dir_all(&dir).unwrap();
648 let path = dir.join("script.py");
649 let mut bytes = vec![0xFF, 0xFE];
650 bytes.extend("print(42)".encode_utf16().flat_map(u16::to_le_bytes));
651 std::fs::write(&path, bytes).unwrap();
652 assert_eq!(extract_text(&path).unwrap(), "print(42)");
653 }
654
655 #[test]
656 fn metadata_chunk_makes_empty_files_searchable_by_name() {
657 let chunks = metadata_chunks("archive.zip");
658 assert_eq!(chunks.len(), 1);
659 assert_eq!(chunks[0].0, "file metadata");
660 assert!(chunks[0].1.contains("archive.zip"));
661 }
662
663 #[test]
664 fn docx_pulls_paragraph_text() {
665 let p = office_fixture(
666 "d.docx",
667 &[(
668 "word/document.xml",
669 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>"#,
670 )],
671 );
672 let text = extract_text(&p).unwrap();
673 assert_eq!(text, "Hello world\nSecond & last");
674 }
675
676 #[test]
677 fn pptx_pulls_slide_text_with_slide_markers() {
678 let p = office_fixture(
679 "s.pptx",
680 &[
681 (
682 "ppt/slides/slide1.xml",
683 r"<p:sld><a:t>Title one</a:t><a:t>Bullet</a:t></p:sld>",
684 ),
685 (
686 "ppt/slides/slide2.xml",
687 r"<p:sld><a:t>Second slide</a:t></p:sld>",
688 ),
689 (
690 "ppt/slides/slide10.xml",
691 r"<p:sld><a:t>Tenth slide</a:t></p:sld>",
692 ),
693 ],
694 );
695 let text = extract_text(&p).unwrap();
696 assert!(text.contains("[slide 1]"));
697 assert!(text.contains("Title one"));
698 assert!(text.contains("[slide 2]"));
699 assert!(text.contains("Second slide"));
700 let i2 = text.find("[slide 2]").unwrap();
702 let i10 = text.find("[slide 10]").unwrap();
703 assert!(i2 < i10);
704 }
705
706 #[test]
707 fn xlsx_pulls_shared_strings_and_cell_values() {
708 let p = office_fixture(
709 "x.xlsx",
710 &[
711 (
712 "xl/sharedStrings.xml",
713 r"<sst><si><t>revenue</t></si><si><t>cost</t></si></sst>",
714 ),
715 (
716 "xl/worksheets/sheet1.xml",
717 r"<worksheet><c><v>42</v></c><c><v>7</v></c></worksheet>",
718 ),
719 ],
720 );
721 let text = extract_text(&p).unwrap();
722 assert!(text.contains("revenue"));
723 assert!(text.contains("cost"));
724 assert!(text.contains("42"));
725 }
726
727 #[test]
728 fn xml_tag_texts_handles_attrs_self_closing_and_entities() {
729 let xml = r#"<w:t a="b">one</w:t><w:t/><w:t>two <3</w:t>"#;
730 assert_eq!(
731 xml_tag_texts(xml, "w:t"),
732 vec!["one".to_string(), "two <3".to_string()]
733 );
734 }
735
736 #[test]
737 fn xml_unescape_does_not_double_unescape_compound_entities() {
738 assert_eq!(xml_unescape("&amp;lt;"), "&lt;");
739 assert_eq!(xml_unescape("a & b <3"), "a & b <3");
740 }
741
742 #[test]
743 fn chunks_are_40_lines_with_locations() {
744 let text = (1..=90)
745 .map(|i| i.to_string())
746 .collect::<Vec<_>>()
747 .join("\n");
748 let chunks = chunk_lines(&text);
749 assert_eq!(chunks.len(), 3);
750 assert_eq!(chunks[0].0, "lines 1-40");
751 assert!(chunks[0].1.starts_with("1\n"));
752 assert_eq!(chunks[1].0, "lines 41-80");
753 assert_eq!(chunks[2].0, "lines 81-90");
754 assert!(
755 chunks[2].1.ends_with("\n90")
756 || chunks[2].1 == "81\n82\n83\n84\n85\n86\n87\n88\n89\n90"
757 );
758 assert!(chunk_lines("").is_empty());
759 assert!(chunk_lines(" \n ").is_empty());
760 }
761
762 fn ocr_tools_present() -> bool {
764 std::process::Command::new("tesseract")
765 .arg("--version")
766 .output()
767 .is_ok()
768 && std::process::Command::new("pdftoppm")
769 .arg("-v")
770 .output()
771 .is_ok()
772 }
773
774 #[test]
775 fn ocr_pdf_reads_rendered_text() {
776 if !ocr_tools_present() {
777 eprintln!("skipping ocr_pdf_reads_rendered_text: tesseract/pdftoppm not installed");
778 return;
779 }
780 let dir = std::env::temp_dir().join(format!("nexus-ocr-test-{}", uuid::Uuid::new_v4()));
781 std::fs::create_dir_all(&dir).unwrap();
782 let pdf = dir.join("scan.pdf");
783 std::fs::write(&pdf, minimal_pdf(Some("HELLO NEXUS OCR"))).unwrap();
784
785 let text = ocr_pdf(&pdf, &|_, _| {}).unwrap();
786 assert!(text.contains("HELLO"), "ocr text was: {text:?}");
787 assert!(text.contains("[page 1]"), "ocr text was: {text:?}");
788 }
789
790 #[test]
791 fn ocr_pdf_blank_page_yields_empty_text() {
792 if !ocr_tools_present() {
793 eprintln!("skipping ocr_pdf_blank_page_yields_empty_text: tools not installed");
794 return;
795 }
796 let dir = std::env::temp_dir().join(format!("nexus-ocr-test-{}", uuid::Uuid::new_v4()));
797 std::fs::create_dir_all(&dir).unwrap();
798 let pdf = dir.join("blank.pdf");
799 std::fs::write(&pdf, minimal_pdf(None)).unwrap();
800 assert_eq!(ocr_pdf(&pdf, &|_, _| {}).unwrap(), "");
801 }
802
803 #[test]
804 fn render_pdf_pages_renders_sorted_pages_at_dpi() {
805 if !ocr_tools_present() {
806 eprintln!("skipping render_pdf_pages_renders_sorted_pages_at_dpi: tools not installed");
807 return;
808 }
809 let dir = std::env::temp_dir().join(format!("nexus-render-test-{}", uuid::Uuid::new_v4()));
810 std::fs::create_dir_all(&dir).unwrap();
811 let pdf = dir.join("multi.pdf");
812 std::fs::write(&pdf, pdf_with_pages(&["ONE", "TWO", "THREE"])).unwrap();
813 let tmp = dir.join("pages");
814 std::fs::create_dir_all(&tmp).unwrap();
815
816 let pages = render_pdf_pages("pdftoppm", &pdf, &tmp, 120, false).unwrap();
817 assert_eq!(pages.len(), 3);
818 let mut sorted = pages.clone();
819 sorted.sort();
820 assert_eq!(pages, sorted, "pages must come back in document order");
821 let _ = std::fs::remove_dir_all(&dir);
822 }
823
824 #[test]
825 fn join_pages_marks_failures_and_skips_blank_pages() {
826 let joined = join_pages(&[
827 Ok("first".to_string()),
828 Err("boom".to_string()),
829 Ok(" ".to_string()),
830 Ok("fourth".to_string()),
831 ]);
832 assert!(joined.contains("[page 1]\nfirst"), "{joined:?}");
833 assert!(joined.contains("[page 2: ocr failed]"), "{joined:?}");
834 assert!(!joined.contains("[page 3]"), "{joined:?}");
835 assert!(joined.contains("[page 4]\nfourth"), "{joined:?}");
836 }
837
838 #[test]
839 fn ocr_pdf_multipage_keeps_page_order() {
840 if !ocr_tools_present() {
841 eprintln!("skipping ocr_pdf_multipage_keeps_page_order: tools not installed");
842 return;
843 }
844 let dir = std::env::temp_dir().join(format!("nexus-ocr-test-{}", uuid::Uuid::new_v4()));
845 std::fs::create_dir_all(&dir).unwrap();
846 let pdf = dir.join("multi.pdf");
847 std::fs::write(
848 &pdf,
849 pdf_with_pages(&["ALPHA BRAVO", "CHARLIE DELTA", "ECHO FOXTROT"]),
850 )
851 .unwrap();
852
853 let text = ocr_pdf(&pdf, &|_, _| {}).unwrap();
854 let a = text.find("ALPHA").expect(&text);
856 let c = text.find("CHARLIE").expect(&text);
857 let e = text.find("ECHO").expect(&text);
858 assert!(a < c && c < e, "pages out of order: {text:?}");
859 assert!(text.contains("[page 3]"), "{text:?}");
860 }
861
862 #[test]
863 fn installed_langs_lists_packs_without_osd() {
864 if !ocr_tools_present() {
865 eprintln!("skipping installed_langs_lists_packs_without_osd: tools not installed");
866 return;
867 }
868 let langs = installed_langs("tesseract").expect("langs should list");
869 assert!(langs.split('+').any(|l| l == "eng"), "{langs}");
870 assert!(!langs.split('+').any(|l| l == "osd"), "{langs}");
871 }
872
873 #[test]
874 fn installed_langs_missing_binary_is_none() {
875 assert!(installed_langs("nexus-definitely-not-a-binary").is_none());
876 }
877
878 #[test]
879 fn ocr_pdf_missing_tools_is_distinguishable() {
880 let dir = std::env::temp_dir().join(format!("nexus-ocr-test-{}", uuid::Uuid::new_v4()));
881 std::fs::create_dir_all(&dir).unwrap();
882 let pdf = dir.join("x.pdf");
883 std::fs::write(&pdf, minimal_pdf(None)).unwrap();
884 let err = ocr_pdf_with(
885 "nexus-definitely-not-a-binary",
886 "tesseract",
887 &pdf,
888 &|_, _| {},
889 )
890 .unwrap_err();
891 assert!(matches!(err, OcrError::MissingTools));
892 }
893}