use std::collections::BTreeSet;
use std::path::PathBuf;
use std::process::Command;
use pdfrum::{Document, PdfaClause, PdfaLevel};
use pdfrum_corpus::CORPUS;
fn verapdf() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("PDFRUM_VERAPDF")?);
path.is_file().then_some(path)
}
enum Verdict {
Clauses(BTreeSet<String>),
Unparsable,
}
fn oracle_clauses(tool: &PathBuf, file: &PathBuf, flavour: &str) -> Option<Verdict> {
let output = Command::new(tool)
.arg("-f")
.arg(flavour)
.arg("--format")
.arg("json")
.arg(file)
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let declined = |s: &str| s.contains("doesn't appear to be a valid PDF");
if declined(&text) || declined(&stderr) {
return Some(Verdict::Unparsable);
}
if !text.contains("\"validationResult\"") {
return None;
}
let mut clauses = BTreeSet::new();
for (index, _) in text.match_indices("\"clause\"") {
let Some(rest) = text.get(index..) else {
continue;
};
let Some(value) = between(rest, "\"clause\" : \"", "\"") else {
continue;
};
let next_clause = rest
.get("\"clause\"".len()..)
.and_then(|r| r.find("\"clause\""));
let status = rest.find("\"status\" : \"failed\"");
let counts = match (status, next_clause) {
(Some(s), Some(n)) => s < n + "\"clause\"".len(),
(Some(_), None) => true,
(None, _) => false,
};
if counts {
clauses.insert(value.to_owned());
}
}
Some(Verdict::Clauses(clauses))
}
fn between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
let start = text.find(open)? + open.len();
let rest = text.get(start..)?;
rest.get(..rest.find(close)?)
}
fn our_clauses(report: &pdfrum::PdfaReport, level: PdfaLevel) -> BTreeSet<String> {
report
.clauses()
.into_iter()
.filter_map(|clause| {
clause.iso(level).split(", ").nth(1).map(str::to_owned)
})
.collect()
}
fn aliases(clause: &str) -> &'static [&'static str] {
match clause {
"6.5.1" => &["6.4.1"],
_ => &[],
}
}
#[test]
fn every_corpus_file_is_checked_at_both_levels() {
let mut checked = 0;
for doc_entry in CORPUS {
let stem = doc_entry.stem;
let Ok(doc) = Document::open(pdfrum_corpus::path(stem)) else {
continue;
};
for level in [PdfaLevel::A1b, PdfaLevel::A2b] {
let report = doc.check_pdfa(level);
assert_eq!(report.level, level, "{stem}: the report names its level");
assert!(
!report.conforms(),
"{stem} at {level}: no corpus file is PDF/A, so none may pass"
);
assert!(
report
.clauses()
.contains(&PdfaClause::XmpIdentificationMissing)
|| report.clauses().contains(&PdfaClause::XmpMissing),
"{stem} at {level}: no corpus file identifies itself as PDF/A"
);
}
checked += 1;
}
assert_eq!(checked, CORPUS.len(), "every corpus file was checked");
}
#[test]
fn a1_is_stricter_than_a2_where_they_differ() {
let stricter = [
PdfaClause::Transparency,
PdfaClause::OptionalContent,
PdfaClause::EmbeddedFile,
];
for doc_entry in CORPUS {
let stem = doc_entry.stem;
let Ok(doc) = Document::open(pdfrum_corpus::path(stem)) else {
continue;
};
let a1 = doc.check_pdfa(PdfaLevel::A1b);
let a2 = doc.check_pdfa(PdfaLevel::A2b);
for clause in stricter {
assert!(
a1.by_clause(clause).count() >= a2.by_clause(clause).count(),
"{stem}: {clause:?} must not fire more often under A-2 than A-1"
);
}
}
}
#[test]
fn scored_against_verapdf() {
let Some(tool) = verapdf() else {
println!(
"\nNOTE: $PDFRUM_VERAPDF is unset or names no file, so the oracle \
half did not run. The other two tests in this file still checked \
all {} corpus files at both levels. veraPDF is a Java tool; \
says how to install it.",
CORPUS.len()
);
return;
};
let mut scored = 0;
let mut agreed = 0;
let mut ours_only: Vec<String> = Vec::new();
let mut theirs_only: BTreeSet<String> = BTreeSet::new();
let mut broken: Vec<String> = Vec::new();
let mut unparsable: Vec<String> = Vec::new();
for doc_entry in CORPUS {
let stem = doc_entry.stem;
let path = pdfrum_corpus::path(stem);
let Ok(doc) = Document::open(&path) else {
continue;
};
for (level, flavour) in [(PdfaLevel::A1b, "1b"), (PdfaLevel::A2b, "2b")] {
let theirs = match oracle_clauses(&tool, &path, flavour) {
None => {
broken.push(format!("{stem} at {flavour}"));
continue;
}
Some(Verdict::Unparsable) => {
unparsable.push(format!("{stem} at {flavour}"));
continue;
}
Some(Verdict::Clauses(clauses)) => clauses,
};
scored += 1;
let ours = our_clauses(&doc.check_pdfa(level), level);
let covered = |clause: &String| {
theirs.contains(clause) || aliases(clause).iter().any(|a| theirs.contains(*a))
};
let uncovered: Vec<&String> = ours.iter().filter(|c| !covered(c)).collect();
for clause in &uncovered {
ours_only.push(format!("{stem} {flavour}: {clause}"));
}
for clause in theirs.difference(&ours) {
theirs_only.insert(format!("{flavour} {clause}"));
}
if uncovered.is_empty() {
agreed += 1;
}
}
}
println!("\nveraPDF divergence, {scored} file-level pairs scored");
if !unparsable.is_empty() {
println!(
" {} pairs veraPDF declined to parse (our parser opens all of \
them): {unparsable:?}",
unparsable.len()
);
}
println!(" {agreed} where every clause we report, veraPDF also reports");
println!(
" {} clauses reported by us and not by veraPDF",
ours_only.len()
);
println!(
" {} distinct clauses veraPDF reports that we do not implement",
theirs_only.len()
);
for clause in &theirs_only {
println!(" theirs only: {clause}");
}
for entry in &ours_only {
println!(" OURS ONLY: {entry}");
}
assert!(
broken.is_empty(),
"$PDFRUM_VERAPDF is set but produced no usable report for: {broken:?}"
);
assert!(
scored > 0,
"$PDFRUM_VERAPDF is set, so the oracle must have scored something"
);
let adjudicated = ["6.3.4", "6.2.11.4.1", "6.1.3", "6.6.1"];
let (known, other): (Vec<&String>, Vec<&String>) = ours_only
.iter()
.partition(|entry| adjudicated.iter().any(|c| entry.ends_with(c)));
assert!(
other.is_empty(),
"we report clauses veraPDF does not see on the same file, which means \
we are inventing violations: {other:#?}"
);
assert_eq!(
known.len(),
8,
"the adjudicated divergences are the eight §6 \
records; the set changed: {known:#?}"
);
}