use printpdf::{PdfDocument, PdfParseOptions, PdfSaveOptions, PdfWarnMsg};
fn roundtrip_dir() -> std::path::PathBuf {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("roundtrip");
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn parse(bytes: &[u8], ctx: &str) -> (PdfDocument, Vec<PdfWarnMsg>) {
let mut warnings = Vec::new();
let doc = PdfDocument::parse(bytes, &PdfParseOptions::default(), &mut warnings)
.unwrap_or_else(|e| panic!("{ctx}: parse failed: {e}"));
(doc, warnings)
}
fn errors(warnings: &[PdfWarnMsg]) -> Vec<String> {
warnings
.iter()
.filter(|w| w.severity == printpdf::PdfParseErrorSeverity::Error)
.map(|w| format!("p{} op{}: {}", w.page, w.op_id, w.msg))
.collect()
}
fn flat_text(doc: &PdfDocument) -> String {
doc.extract_text()
.iter()
.flatten()
.flat_map(|chunk| chunk.split_whitespace())
.collect::<Vec<_>>()
.join(" ")
}
fn audit_roundtrip(name: &str, bytes: &[u8]) -> (Vec<u8>, Vec<String>) {
let mut report = Vec::new();
let (doc, warnings) = parse(bytes, name);
let text_orig = flat_text(&doc);
let errs = errors(&warnings);
report.push(format!(
"{name}: parse ok, {} pages, {} fonts, {} xobjects, {} layers, {} bookmarks, {} parse \
errors",
doc.pages.len(),
doc.resources.fonts.map.len(),
doc.resources.xobjects.map.len(),
doc.resources.layers.map.len(),
doc.bookmarks.map.len(),
errs.len()
));
for e in errs.iter().take(8) {
report.push(format!(" parse-error: {e}"));
}
let mut save_warnings = Vec::new();
let resaved = doc.save(&PdfSaveOptions::default(), &mut save_warnings);
let (doc2, warnings2) = parse(&resaved, &format!("{name} (resaved)"));
let errs2 = errors(&warnings2);
report.push(format!(
"{name}: re-parse ok, {} pages, {} fonts, {} xobjects, {} layers, {} bookmarks, {} parse \
errors",
doc2.pages.len(),
doc2.resources.fonts.map.len(),
doc2.resources.xobjects.map.len(),
doc2.resources.layers.map.len(),
doc2.bookmarks.map.len(),
errs2.len()
));
for e in errs2.iter().take(8) {
report.push(format!(" reparse-error: {e}"));
}
let text_re = flat_text(&doc2);
if text_orig != text_re {
report.push(format!(
" TEXT DIVERGED:\n orig: {:?}\n re: {:?}",
&text_orig.chars().take(160).collect::<String>(),
&text_re.chars().take(160).collect::<String>()
));
} else {
report.push(format!(
" text stable ({} chars): {:?}",
text_orig.len(),
text_orig.chars().take(60).collect::<String>()
));
}
assert_eq!(
doc.pages.len(),
doc2.pages.len(),
"{name}: page count changed across round trip"
);
assert_eq!(
text_orig, text_re,
"{name}: extracted text changed across round trip"
);
assert!(
doc2.resources.xobjects.map.len() >= doc.resources.xobjects.map.len(),
"{name}: xobjects lost across round trip ({} -> {})",
doc.resources.xobjects.map.len(),
doc2.resources.xobjects.map.len()
);
assert!(
doc2.bookmarks.map.len() >= doc.bookmarks.map.len(),
"{name}: bookmarks lost across round trip ({} -> {})",
doc.bookmarks.map.len(),
doc2.bookmarks.map.len()
);
assert!(
doc2.resources.layers.map.len() >= doc.resources.layers.map.len(),
"{name}: layers lost across round trip ({} -> {})",
doc.resources.layers.map.len(),
doc2.resources.layers.map.len()
);
(resaved, report)
}
#[test]
fn corpus_roundtrip_files() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let out = roundtrip_dir();
let mut all = Vec::new();
let mut names: Vec<_> = std::fs::read_dir(root)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.extension().map(|e| e == "pdf").unwrap_or(false)
&& p.file_name()
.and_then(|f| f.to_str())
.map(|f| f.ends_with("_example.pdf") || f == "htmltest.pdf")
.unwrap_or(false)
})
.collect();
names.sort();
if names.is_empty() {
eprintln!("corpus_roundtrip_files: no corpus PDFs in repo root, skipping");
return;
}
for path in names {
let name = path.file_stem().unwrap().to_string_lossy().to_string();
let bytes = std::fs::read(&path).unwrap();
let (resaved, report) = audit_roundtrip(&name, &bytes);
std::fs::write(out.join(format!("{name}.roundtrip.pdf")), &resaved).unwrap();
let (doc2, _) = parse(&resaved, &format!("{name} roundtrip2"));
let resaved2 = doc2.save(&PdfSaveOptions::default(), &mut Vec::new());
let (_, _) = parse(&resaved2, &format!("{name} roundtrip3"));
all.extend(report);
}
eprintln!("== corpus round-trip report ==");
for line in &all {
eprintln!("{line}");
}
}