mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 11:17:13 +00:00
197 lines
6.6 KiB
Rust
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");
|
||
|
|
}
|