#![allow(clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use std::process::Command;
use pdfrum::{Document, PdfaConversion, PdfaLevel, PdfaPolicy};
use pdfrum_corpus::CORPUS;
const A2B_PASS_FLOOR: usize = 11;
fn verapdf() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("PDFRUM_VERAPDF")?);
path.is_file().then_some(path)
}
fn compliant_count(tool: &Path, dir: &Path, flavour: &str) -> Option<usize> {
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
.ok()?
.filter_map(|entry| entry.ok().map(|e| e.path()))
.filter(|path| path.extension().is_some_and(|e| e == "pdf"))
.collect();
files.sort();
if files.is_empty() {
return None;
}
let output = Command::new(tool)
.arg("-f")
.arg(flavour)
.arg("--format")
.arg("json")
.args(&files)
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
if !text.contains("\"validationResult\"") {
return None;
}
Some(text.matches("\"compliant\" : true").count())
}
fn convert_corpus(
dir: &Path,
level: PdfaLevel,
policy: PdfaPolicy,
) -> Vec<(&'static str, PdfaConversion)> {
std::fs::create_dir_all(dir).expect("the scratch directory is writable");
let mut out = Vec::new();
for entry in CORPUS {
let stem = entry.stem;
let Ok(doc) = Document::open(pdfrum_corpus::path(stem)) else {
continue;
};
let dest = dir.join(format!("{stem}.pdf"));
let conversion = doc
.to_pdfa(&dest, level, &policy)
.unwrap_or_else(|e| panic!("{stem}: converting must not error, got {e}"));
out.push((stem, conversion));
}
out
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pdfrum-pdfa-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn converted_files_pass_verapdf_that_did_not_before() {
let Some(tool) = verapdf() else {
println!(
"skipping: $PDFRUM_VERAPDF is unset or names no file. \
The conversion tests below still run on all {} corpus files.",
CORPUS.len()
);
return;
};
let before_dir = scratch("before");
std::fs::create_dir_all(&before_dir).expect("the scratch directory is writable");
for entry in CORPUS {
let source = pdfrum_corpus::path(entry.stem);
let dest = before_dir.join(format!("{}.pdf", entry.stem));
std::fs::copy(&source, &dest).expect("the corpus is readable");
}
let before = compliant_count(&tool, &before_dir, "2b")
.expect("$PDFRUM_VERAPDF is set, so a broken oracle is a failure, not a skip");
let after_dir = scratch("after");
let conversions = convert_corpus(&after_dir, PdfaLevel::A2b, PdfaPolicy::lossy());
let after = compliant_count(&tool, &after_dir, "2b")
.expect("$PDFRUM_VERAPDF is set, so a broken oracle is a failure, not a skip");
println!(
"veraPDF A-2b over {} corpus files: {before} passed before conversion, {after} after",
conversions.len()
);
assert_eq!(
before, 0,
"no corpus file is PDF/A to begin with; if this changes the corpus changed, \
and the after-number stops meaning what it says"
);
assert!(
after >= A2B_PASS_FLOOR,
"conversion produced {after} files veraPDF passes, below the recorded floor of \
{A2B_PASS_FLOOR}. Raising the floor when the number improves is intended; \
a drop is a regression."
);
let _ = std::fs::remove_dir_all(&before_dir);
let _ = std::fs::remove_dir_all(&after_dir);
}
#[test]
fn every_corpus_file_converts_and_the_output_reopens() {
let dir = scratch("reopen");
let conversions = convert_corpus(&dir, PdfaLevel::A2b, PdfaPolicy::lossy());
assert_eq!(
conversions.len(),
CORPUS.len(),
"every corpus file was converted"
);
for (stem, conversion) in &conversions {
assert!(
conversion.converted(),
"{stem}: the lossy policy authorizes every compromise, so nothing may refuse: {conversion}"
);
assert_eq!(conversion.level, PdfaLevel::A2b);
assert!(
conversion.rasterized_pages().is_empty(),
"{stem}: A-2b permits transparency, so nothing needs rasterizing"
);
let path = dir.join(format!("{stem}.pdf"));
let converted =
Document::open(&path).unwrap_or_else(|e| panic!("{stem}: the output reopens, got {e}"));
let report = converted.check_pdfa(PdfaLevel::A2b);
let clauses = report.clauses();
for unwanted in [
pdfrum::PdfaClause::XmpMissing,
pdfrum::PdfaClause::XmpMalformed,
pdfrum::PdfaClause::XmpIdentificationMissing,
pdfrum::PdfaClause::XmpIdentificationMismatch,
pdfrum::PdfaClause::DeviceColorWithoutOutputIntent,
pdfrum::PdfaClause::OutputIntentMissing,
pdfrum::PdfaClause::OutputIntentProfileMissing,
] {
assert!(
!clauses.contains(&unwanted),
"{stem}: the conversion writes the XMP packet and the output intent, \
so {unwanted:?} must not survive it. Remaining: {clauses:?}"
);
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn only_a_cmyk_document_is_given_a_cmyk_output_intent() {
const CMYK_CONDITION: &[u8] = b"CGATS TR 001";
const SRGB_CONDITION: &[u8] = b"sRGB IEC61966-2.1";
let dir = scratch("intent");
let conversions = convert_corpus(&dir, PdfaLevel::A2b, PdfaPolicy::lossy());
let mut cmyk = Vec::new();
for (stem, _) in &conversions {
let bytes = std::fs::read(dir.join(format!("{stem}.pdf")))
.unwrap_or_else(|e| panic!("{stem}: the output is readable, got {e}"));
let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
assert!(
has(CMYK_CONDITION) != has(SRGB_CONDITION),
"{stem}: a file carries one output intent, not both and not neither"
);
if has(CMYK_CONDITION) {
cmyk.push(*stem);
}
}
assert!(
cmyk.contains(&"image_bug_718762"),
"image_bug_718762 paints only in /DeviceCMYK, so it must get the CMYK intent; \
got {cmyk:?}"
);
assert!(
cmyk.len() * 4 < conversions.len(),
"only a document painting in CMYK alone takes the CMYK intent, and the corpus \
is mostly RGB; {} of {} is too many",
cmyk.len(),
conversions.len()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_strict_policy_refuses_what_the_lossy_policy_compromises() {
let strict_dir = scratch("strict");
let strict = convert_corpus(&strict_dir, PdfaLevel::A2b, PdfaPolicy::strict());
let mut refused = 0;
for (stem, conversion) in &strict {
if conversion.converted() {
assert!(
conversion.compromises.is_empty(),
"{stem}: the strict policy authorizes no compromise, so a conversion \
under it must make none: {:?}",
conversion.compromises
);
continue;
}
refused += 1;
assert!(
!conversion.refusals.is_empty(),
"{stem}: a conversion that did not convert must say why"
);
assert!(
!strict_dir.join(format!("{stem}.pdf")).exists(),
"{stem}: a refused conversion must write no file"
);
}
assert!(
refused > 0,
"the corpus carries JavaScript, widget /AA entries and forbidden annotations, \
so the strict policy must refuse something — otherwise the policy is not \
being consulted at all"
);
let _ = std::fs::remove_dir_all(&strict_dir);
}
#[test]
fn no_compromise_is_made_that_the_policy_did_not_authorize() {
let dir = scratch("authorized");
let policy = PdfaPolicy::strict().forbidden_feature(pdfrum::PdfaConcession::Accept);
for (stem, conversion) in convert_corpus(&dir, PdfaLevel::A2b, policy) {
for compromise in &conversion.compromises {
let authorized = matches!(
compromise,
pdfrum::PdfaCompromise::ActionRemoved { .. }
| pdfrum::PdfaCompromise::AnnotationRemoved { .. }
| pdfrum::PdfaCompromise::AnnotationFlagsChanged { .. }
| pdfrum::PdfaCompromise::EmbeddedFileRemoved { .. }
| pdfrum::PdfaCompromise::OptionalContentRemoved { .. }
| pdfrum::PdfaCompromise::ObjectMetadataRemoved { .. }
);
assert!(
authorized,
"{stem}: {compromise:?} was made under a policy that authorized only \
forbidden-feature removal"
);
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn widening_the_policy_a_refusal_names_makes_the_conversion_succeed() {
let strict_dir = scratch("loop-strict");
let refused: Vec<(&'static str, PdfaPolicy)> =
convert_corpus(&strict_dir, PdfaLevel::A2b, PdfaPolicy::strict())
.into_iter()
.filter(|(_, c)| !c.converted())
.map(|(stem, conversion)| {
let mut policy = PdfaPolicy::strict();
for refusal in &conversion.refusals {
policy = match refusal {
pdfrum::PdfaRefusal::UnembeddableFont { .. } => {
policy.unembeddable_font(pdfrum::PdfaConcession::Accept)
}
pdfrum::PdfaRefusal::UnrepresentableContent { .. } => {
policy.unrepresentable_content(pdfrum::PdfaConcession::Accept)
}
pdfrum::PdfaRefusal::ForbiddenFeature { .. } => {
policy.forbidden_feature(pdfrum::PdfaConcession::Accept)
}
_ => policy,
};
}
(stem, policy)
})
.collect();
let _ = std::fs::remove_dir_all(&strict_dir);
assert!(!refused.is_empty(), "the corpus must refuse under strict");
let retry_dir = scratch("loop-retry");
std::fs::create_dir_all(&retry_dir).expect("the scratch directory is writable");
for (stem, policy) in &refused {
let doc = Document::open(pdfrum_corpus::path(stem)).expect("the corpus reopens");
let dest = retry_dir.join(format!("{stem}.pdf"));
let conversion = doc
.to_pdfa(&dest, PdfaLevel::A2b, policy)
.expect("converting must not error");
assert!(
conversion.converted(),
"{stem} refused under the strict policy; granting exactly the concessions \
its own refusals named must convert it, or a refusal named the wrong \
field. Still refusing: {:?}",
conversion.refusals
);
}
let _ = std::fs::remove_dir_all(&retry_dir);
}