Skip to main content

nexus_core/
extract.rs

1//! Import-time text extraction for space filesets: plain text as-is, PDF via
2//! pdf-extract, office formats (pptx/docx/xlsx) by scanning their zipped XML
3//! for text tags — same string-scanning style as tools.rs's DDG HTML parser.
4
5use std::fmt::Write as _;
6use std::io::Read;
7use std::path::Path;
8
9use anyhow::{Context, Result};
10
11const CHUNK_LINES: usize = 40;
12
13/// Extract the PDF outline/bookmarks tree as indented text, or empty string
14/// if the PDF has no outline (most scanned PDFs won't).
15fn 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    // UTF-16BE with BOM
50    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    // Fallback: PDFDocEncoding / Latin-1
63    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            // ponytail: no page number extraction from Dest — the PDF
84            // outline's Dest can be a named destination, an explicit
85            // page reference, or absent. Page numbers from the outline
86            // would require resolving the Dest against the page tree,
87            // which is complex and rarely critical.
88            let _ = writeln!(out, "{indent}{title}");
89        }
90    }
91    // Children
92    if let Ok(Object::Reference(child_id)) = dict.get(b"First") {
93        walk_outline(doc, *child_id, depth + 1, out);
94    }
95    // Siblings
96    if let Ok(Object::Reference(next_id)) = dict.get(b"Next") {
97        walk_outline(doc, *next_id, depth, out);
98    }
99}
100
101/// Extract searchable text from `path`, dispatching on the (lowercased)
102/// extension. `Ok("")` means the file parsed but had no text (e.g. a scanned
103/// PDF); `Err` means it couldn't be read/parsed at all.
104pub 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        // Images: return empty text so the OCR pipeline picks them up.
128        _ if is_image_ext(&ext) => Ok(String::new()),
129        // Everything else: treat as text if it looks like text.
130        _ => {
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/// Pull text out of an OOXML zip by scanning member XML for text tags.
149/// ponytail: tag scanning, not an XML parser — same approach as tools.rs's
150/// DDG HTML scraping; swap in a real parser only if a document breaks it.
151// ZIP entry names are case-sensitive by spec, so these prefix/extension
152// comparisons must stay exact (the lint targets display-extension matches).
153#[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    // Collect entry names first (borrow rules: by_index borrows the archive).
160    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                // A paragraph's runs join without separators; paragraphs get newlines.
174                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            // slide2 sorts before slide10 lexically; sort by the numeric part.
189            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            // Cell strings live in sharedStrings; numbers inline in each sheet.
211            // ponytail: dumps values without cell positions — searchable, not a
212            // faithful table; switch to the calamine crate if layout matters.
213            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
236/// Every text content of `<tag ...>text</tag>` occurrences in `xml`, entities
237/// unescaped. Skips self-closing `<tag/>`.
238fn 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        // Must be the exact tag: next char is '>', ' ' or '/'.
246        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; // a longer tag name that merely starts with `tag`
251        }
252        if head.ends_with('/') {
253            continue; // self-closing
254        }
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    // &amp; must be last: unescaping it earlier fabricates new entities out of
267    // compound escapes like &amp;lt; (the encoding of a literal "&lt;").
268    s.replace("&lt;", "<")
269        .replace("&gt;", ">")
270        .replace("&quot;", "\"")
271        .replace("&apos;", "'")
272        .replace("&amp;", "&")
273}
274
275/// Split extracted text into ~40-line chunks labeled with their line range.
276pub 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
292/// Whether a lowercased file extension is a supported image type.
293pub fn is_image_ext(ext: &str) -> bool {
294    matches!(ext, "jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp")
295}
296
297/// Why OCR failed: the tools aren't installed (user-fixable hint) vs a real
298/// failure (surfaced as an error status).
299#[derive(Debug)]
300pub enum OcrError {
301    MissingTools,
302    Failed(String),
303}
304
305/// OCR a (scanned) PDF with pdftoppm + tesseract. Pages join with `[page N]`
306/// marker lines — same inline-marker convention as pptx's `[slide N]`.
307/// `Ok("")` means the tools ran but found no text. `progress` is called after
308/// each finished page with (pages done, total pages).
309pub 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
316/// Binary names are parameters so tests can exercise the missing-tools path
317/// without mutating the process-global PATH.
318fn 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
331/// Run a command, mapping a missing binary to `OcrError::MissingTools` and a
332/// non-zero exit to `Failed` with its stderr.
333fn 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
353/// Render a PDF's pages to PNGs in `tmp` with pdftoppm, returned in document
354/// order (pdftoppm zero-pads page numbers, so a lexical sort is page order).
355pub 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
378/// Join per-page OCR results with `[page N]` markers: blank pages are
379/// dropped, failed pages leave a `[page N: ocr failed]` marker so the rest
380/// of the document still lands.
381pub 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    // 200 dpi is ~2x faster to render and OCR than 300 with negligible
407    // accuracy loss on normal print.
408    let pages = render_pdf_pages(pdftoppm, path, tmp, 200, true)?;
409
410    // Recognize with every installed language pack (eng+jpn+…): tesseract
411    // then picks the right script per line itself, so installing a tessdata
412    // pack is all it takes to support a language. None = listing failed;
413    // fall back to tesseract's default (eng).
414    let langs = installed_langs(tesseract);
415
416    // OCR pages in parallel — one tesseract process per core, pulling page
417    // indices off a shared counter; results re-ordered by index afterwards.
418    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
460/// All installed tesseract language packs joined as "eng+jpn+…" (minus the
461/// osd script-detection pack), or None if listing fails. Older tesseracts
462/// print the list to stderr, newer to stdout — scan both; language codes
463/// never contain spaces, which filters the header line.
464fn 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/// Build a minimal valid PDF: one page, optionally with `text` drawn in
483/// Helvetica. Offsets are computed at runtime so the xref is always correct.
484/// Test fixture shared with `app::files` tests.
485#[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/// A PDF with one page per entry in `texts`, each drawn in Helvetica.
500#[cfg(test)]
501pub(crate) fn pdf_with_pages(texts: &[&str]) -> Vec<u8> {
502    let font_obj = 2 + 2 * texts.len() + 1; // catalog, pages, (page, content)*, font
503    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    /// Build a minimal office zip in a temp dir: `entries` are (path, xml).
555    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 &amp; 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        // Verify numeric ordering (slide 2 before slide 10, not lexicographic)
626        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 &lt;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;amp;lt;"), "&amp;lt;");
664        assert_eq!(xml_unescape("a &amp; b &lt;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    /// True when tesseract + pdftoppm are runnable (real-OCR tests skip otherwise).
688    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        // Parallel OCR must still emit pages in document order.
780        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}