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 it as a text/code file unless it is clearly
130        // binary.  There is deliberately no extension allow-list here: source
131        // files (`html`, `py`, `rs`, shell scripts, and files without an
132        // extension) are all valid searchable files.  UTF-16 is common for
133        // files saved by desktop editors, so decode it before the NUL-byte
134        // binary check.
135        _ => {
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
152/// Decode a BOM-marked UTF-16 text file.  A BOM is required so arbitrary
153/// binary files are not accidentally interpreted as source code.
154fn 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
175/// A searchable fallback for a file whose contents are empty, binary, or
176/// otherwise not extractable.  Keeping this in the FTS/embedding input means
177/// every imported file still has an index entry and can be found by name.
178pub 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/// Pull text out of an OOXML zip by scanning member XML for text tags.
194/// ponytail: tag scanning, not an XML parser — same approach as tools.rs's
195/// DDG HTML scraping; swap in a real parser only if a document breaks it.
196// ZIP entry names are case-sensitive by spec, so these prefix/extension
197// comparisons must stay exact (the lint targets display-extension matches).
198#[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    // Collect entry names first (borrow rules: by_index borrows the archive).
205    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                // A paragraph's runs join without separators; paragraphs get newlines.
219                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            // slide2 sorts before slide10 lexically; sort by the numeric part.
234            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            // Cell strings live in sharedStrings; numbers inline in each sheet.
256            // ponytail: dumps values without cell positions — searchable, not a
257            // faithful table; switch to the calamine crate if layout matters.
258            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
281/// Every text content of `<tag ...>text</tag>` occurrences in `xml`, entities
282/// unescaped. Skips self-closing `<tag/>`.
283fn 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        // Must be the exact tag: next char is '>', ' ' or '/'.
291        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; // a longer tag name that merely starts with `tag`
296        }
297        if head.ends_with('/') {
298            continue; // self-closing
299        }
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    // &amp; must be last: unescaping it earlier fabricates new entities out of
312    // compound escapes like &amp;lt; (the encoding of a literal "&lt;").
313    s.replace("&lt;", "<")
314        .replace("&gt;", ">")
315        .replace("&quot;", "\"")
316        .replace("&apos;", "'")
317        .replace("&amp;", "&")
318}
319
320/// Split extracted text into ~40-line chunks labeled with their line range.
321pub 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
337/// Whether a lowercased file extension is a supported image type.
338pub fn is_image_ext(ext: &str) -> bool {
339    matches!(ext, "jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp")
340}
341
342/// Why OCR failed: the tools aren't installed (user-fixable hint) vs a real
343/// failure (surfaced as an error status).
344#[derive(Debug)]
345pub enum OcrError {
346    MissingTools,
347    Failed(String),
348}
349
350/// OCR a (scanned) PDF with pdftoppm + tesseract. Pages join with `[page N]`
351/// marker lines — same inline-marker convention as pptx's `[slide N]`.
352/// `Ok("")` means the tools ran but found no text. `progress` is called after
353/// each finished page with (pages done, total pages).
354pub 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
361/// Binary names are parameters so tests can exercise the missing-tools path
362/// without mutating the process-global PATH.
363fn 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
376/// Run a command, mapping a missing binary to `OcrError::MissingTools` and a
377/// non-zero exit to `Failed` with its stderr.
378fn 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
398/// Render a PDF's pages to PNGs in `tmp` with pdftoppm, returned in document
399/// order (pdftoppm zero-pads page numbers, so a lexical sort is page order).
400pub 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
423/// Join per-page OCR results with `[page N]` markers: blank pages are
424/// dropped, failed pages leave a `[page N: ocr failed]` marker so the rest
425/// of the document still lands.
426pub 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    // 200 dpi is ~2x faster to render and OCR than 300 with negligible
452    // accuracy loss on normal print.
453    let pages = render_pdf_pages(pdftoppm, path, tmp, 200, true)?;
454
455    // Recognize with every installed language pack (eng+jpn+…): tesseract
456    // then picks the right script per line itself, so installing a tessdata
457    // pack is all it takes to support a language. None = listing failed;
458    // fall back to tesseract's default (eng).
459    let langs = installed_langs(tesseract);
460
461    // OCR pages in parallel — one tesseract process per core, pulling page
462    // indices off a shared counter; results re-ordered by index afterwards.
463    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
505/// All installed tesseract language packs joined as "eng+jpn+…" (minus the
506/// osd script-detection pack), or None if listing fails. Older tesseracts
507/// print the list to stderr, newer to stdout — scan both; language codes
508/// never contain spaces, which filters the header line.
509fn 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/// Build a minimal valid PDF: one page, optionally with `text` drawn in
528/// Helvetica. Offsets are computed at runtime so the xref is always correct.
529/// Test fixture shared with `app::files` tests.
530#[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/// A PDF with one page per entry in `texts`, each drawn in Helvetica.
545#[cfg(test)]
546pub(crate) fn pdf_with_pages(texts: &[&str]) -> Vec<u8> {
547    let font_obj = 2 + 2 * texts.len() + 1; // catalog, pages, (page, content)*, font
548    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    /// Build a minimal office zip in a temp dir: `entries` are (path, xml).
600    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 &amp; 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        // Verify numeric ordering (slide 2 before slide 10, not lexicographic)
701        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 &lt;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;amp;lt;"), "&amp;lt;");
739        assert_eq!(xml_unescape("a &amp; b &lt;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    /// True when tesseract + pdftoppm are runnable (real-OCR tests skip otherwise).
763    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        // Parallel OCR must still emit pages in document order.
855        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}