Files
catena/crates/pdftriage/tests/cli.rs
T

197 lines
6.6 KiB
Rust

//! 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<u8>)> = Vec::new();
let page_ids: Vec<usize> = (0..pages).map(|at| 4 + 2 * at).collect();
let kids: Vec<String> = page_ids.iter().map(|id| format!("{id} 0 R")).collect();
objects.push((1, b"<</Type/Catalog/Pages 2 0 R>>".to_vec()));
objects.push((
2,
format!("<</Type/Pages/Kids[{}]/Count {pages}>>", kids.join(" ")).into_bytes(),
));
objects.push((
3,
b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>".to_vec(),
));
for id in &page_ids {
let contents_id = id + 1;
objects.push((
*id,
format!(
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]\
/Contents {contents_id} 0 R/Resources<</Font<</F1 3 0 R>>>>>>"
)
.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!("<</Length {}>>\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<u8> = 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<</Size {top}/Root 1 0 R>>\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");
}