//! The tool as a caller meets it: a path in, a verdict and an exit code out. //! //! The PDFs are built here rather than committed, so the suite ships no //! book and no binary fixture. Every test is skipped when `pdftotext` is not //! installed, because without it there is nothing to judge and the tool says //! so with exit 69. use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; const PROSE: &str = "The second estate held its land in exchange for service, and the service was military. That much is agreed; what follows is not. The court rolls do not say how often the obligation was tested, because the clerk who recorded it had no vocabulary for the difference between a rebellion and a bad harvest, and so the record flattens the two into one offence with one penalty."; fn pdftotext_installed() -> bool { Command::new("pdftotext") .arg("-v") .output() .is_ok_and(|out| out.status.success() || !out.stderr.is_empty()) } fn run(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_pdftriage")) .args(args) .output() .expect("the binary should run") } /// Write a PDF with `pages` pages, each holding `text`. /// /// Hand-assembled rather than generated by a library: a PDF is an object /// table and a cross-reference index, and twenty lines of it here save a /// dependency and a committed fixture. fn write_pdf(path: &Path, text: &str, pages: usize) { let mut objects: Vec<(usize, Vec)> = Vec::new(); let page_ids: Vec = (0..pages).map(|at| 4 + 2 * at).collect(); let kids: Vec = page_ids.iter().map(|id| format!("{id} 0 R")).collect(); objects.push((1, b"<>".to_vec())); objects.push(( 2, format!("<>", kids.join(" ")).into_bytes(), )); objects.push(( 3, b"<>".to_vec(), )); for id in &page_ids { let contents_id = id + 1; objects.push(( *id, format!( "<>>>>>" ) .into_bytes(), )); let mut stream = String::new(); let mut y = 720; for line in text.lines() { let escaped = line .replace('\\', r"\\") .replace('(', r"\(") .replace(')', r"\)"); stream.push_str(&format!("BT /F1 11 Tf 72 {y} Td ({escaped}) Tj ET\n")); y -= 14; } let mut object = format!("<>\nstream\n", stream.len()).into_bytes(); object.extend_from_slice(stream.as_bytes()); object.extend_from_slice(b"\nendstream"); objects.push((contents_id, object)); } objects.sort_by_key(|(id, _)| *id); let mut out: Vec = b"%PDF-1.4\n".to_vec(); let mut offsets: Vec<(usize, usize)> = Vec::new(); for (id, body) in &objects { offsets.push((*id, out.len())); out.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); out.extend_from_slice(body); out.extend_from_slice(b"\nendobj\n"); } let xref_at = out.len(); let top = objects.last().expect("at least one object").0 + 1; out.extend_from_slice(format!("xref\n0 {top}\n0000000000 65535 f \n").as_bytes()); for id in 1..top { let at = offsets .iter() .find(|(object, _)| *object == id) .map_or(0, |(_, at)| *at); out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes()); } out.extend_from_slice( format!("trailer\n<>\nstartxref\n{xref_at}\n%%EOF\n").as_bytes(), ); let mut file = std::fs::File::create(path).expect("a writable path"); file.write_all(&out).expect("the PDF should write"); } fn temp(name: &str) -> PathBuf { let dir = std::env::temp_dir().join("pdftriage-tests"); std::fs::create_dir_all(&dir).expect("a writable temp directory"); dir.join(format!("{name}.pdf")) } #[test] fn a_book_that_reads_is_not_sent_for_ocr() { if !pdftotext_installed() { return; } let path = temp("healthy"); write_pdf(&path, PROSE, 20); let out = run(&[path.to_str().expect("utf-8 path")]); assert_eq!(out.status.code(), Some(1), "a healthy layer must exit 1"); let said = String::from_utf8(out.stdout).expect("utf-8"); assert!(said.starts_with("healthy, no OCR needed"), "{said}"); assert!(said.contains("characters a page"), "{said}"); } #[test] fn a_scan_with_no_layer_is_sent_for_ocr() { if !pdftotext_installed() { return; } let path = temp("absent"); write_pdf(&path, "", 20); let out = run(&[path.to_str().expect("utf-8 path")]); assert_eq!(out.status.code(), Some(0), "a dead layer must exit 0"); assert!(String::from_utf8_lossy(&out.stdout).starts_with("absent, needs OCR")); } #[test] fn the_json_form_is_a_doc_record() { if !pdftotext_installed() { return; } let path = temp("json"); write_pdf(&path, PROSE, 20); let out = run(&["--json", path.to_str().expect("utf-8 path")]); let doc: serde_json::Value = serde_json::from_slice(&out.stdout).expect("one JSON record"); assert_eq!(doc["v"], 1); assert_eq!(doc["kind"], "pdf"); assert_eq!(doc["declared_ext"], "pdf"); assert_eq!(doc["pages"], 20); assert_eq!(doc["text_layer"], "healthy"); assert_eq!(doc["needs_ocr"], false); assert!( doc["notes"] .as_array() .is_some_and(|notes| !notes.is_empty()) ); } #[test] fn quiet_says_nothing_at_all() { if !pdftotext_installed() { return; } let path = temp("quiet"); write_pdf(&path, PROSE, 20); let out = run(&["--quiet", path.to_str().expect("utf-8 path")]); assert!(out.stdout.is_empty(), "stdout was not empty"); assert!(out.stderr.is_empty(), "stderr was not empty"); assert_eq!(out.status.code(), Some(1)); } #[test] fn a_file_that_is_not_there_is_an_io_error() { let out = run(&["/definitely/not/here.pdf"]); assert_eq!(out.status.code(), Some(74)); assert!(out.stdout.is_empty(), "a diagnostic reached stdout"); assert!(!out.stderr.is_empty()); } #[test] fn a_file_that_is_not_a_pdf_cannot_be_judged() { if !pdftotext_installed() { return; } let path = temp("not-a-pdf"); std::fs::write(&path, b"this is not a PDF at all\n").expect("a writable path"); let out = run(&[path.to_str().expect("utf-8 path")]); assert_eq!(out.status.code(), Some(2), "could not tell must exit 2"); }