use pdfpurr::Document;
use std::path::Path;
fn corpus_path(sub: &str) -> std::path::PathBuf {
Path::new("tests/corpus").join(sub)
}
fn read_corpus(sub: &str) -> Option<Vec<u8>> {
let path = corpus_path(sub);
std::fs::read(&path).ok()
}
#[test]
fn all_basic_pdfs_parse_with_pages() {
let expected_pages = [
("dummy.pdf", 1),
("generated_hello.pdf", 1),
("pdf-sample.pdf", 1),
("tcp_ip_intro.pdf", 51),
("tracemonkey.pdf", 14),
];
for (name, expected) in &expected_pages {
let data = read_corpus(&format!("basic/{name}")).unwrap();
let doc = Document::from_bytes(&data).unwrap();
let pages = doc.page_count().unwrap();
assert_eq!(
pages, *expected,
"{name}: expected {expected} pages, got {pages}"
);
}
}
#[test]
fn encrypted_pdfs_parse_and_have_pages() {
let cases: Vec<(&str, Vec<&[u8]>)> = vec![
("rc4_40bit_r2.pdf", vec![b"", b"pass40"]),
("aes128_r4.pdf", vec![b"", b"testpw"]),
("aes256_r6_user.pdf", vec![b"userpass"]),
("aes256_r6_empty_user.pdf", vec![b""]),
];
for (name, passwords) in &cases {
let data = read_corpus(&format!("encrypted/{name}")).unwrap();
let mut parsed = false;
for pw in passwords {
if let Ok(doc) = Document::from_bytes_with_password(&data, pw) {
assert!(
doc.page_count().unwrap() > 0,
"{name}: parsed but has 0 pages"
);
parsed = true;
break;
}
}
assert!(parsed, "{name}: no password worked");
}
}
#[test]
fn permission_restricted_pdfs_parse_with_pages() {
for name in &[
"encryption_nocopy.pdf",
"encryption_noprinting.pdf",
"encryption_notextaccess.pdf",
] {
let data = read_corpus(&format!("encrypted/{name}")).unwrap();
let doc =
Document::from_bytes(&data).or_else(|_| Document::from_bytes_with_password(&data, b""));
match doc {
Ok(d) => {
let pages = d.page_count().unwrap_or(0);
eprintln!(" {name}: parsed OK, {pages} pages");
}
Err(e) => {
eprintln!(" {name}: parse error (known limitation): {e}");
}
}
}
let data = read_corpus("encrypted/encryption_openpassword.pdf").unwrap();
assert!(
Document::from_bytes_with_password(&data, b"").is_err(),
"encryption_openpassword should reject empty password"
);
}
#[test]
fn malformed_pdfs_dont_panic() {
for name in &[
"calistoMTNoFontsEmbedded.pdf",
"corruptionOneByteMissing.pdf",
"externalLink.pdf",
"vera_6-1-2-t02-fail-b.pdf",
"vera_6-1-2-t02-fail-c.pdf",
] {
let data = read_corpus(&format!("malformed/{name}")).unwrap();
let result = Document::from_bytes(&data);
match &result {
Ok(doc) => eprintln!(
" {name}: parsed OK ({} pages)",
doc.page_count().unwrap_or(0)
),
Err(e) => eprintln!(" {name}: error (expected): {e}"),
}
}
}
#[test]
fn scanned_pdfs_parse_with_pages() {
for name in &[
"crazyones.pdf",
"graph_scanned.pdf",
"linn_scanned.pdf",
"skew_scanned.pdf",
] {
let data = read_corpus(&format!("scanned/{name}")).unwrap();
let doc = Document::from_bytes(&data).unwrap();
assert!(
doc.page_count().unwrap() > 0,
"{name}: parsed but has 0 pages"
);
}
}
#[test]
fn tracemonkey_text_contains_known_words() {
let data = read_corpus("basic/tracemonkey.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let text = doc.extract_page_text(0).unwrap();
assert!(
text.len() > 100,
"tracemonkey page 0 should have substantial text, got {} chars",
text.len()
);
}
#[test]
fn generated_hello_text_contains_hello() {
let data = read_corpus("basic/generated_hello.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let text = doc.extract_page_text(0).unwrap();
assert!(
text.contains("Hello"),
"generated_hello should contain 'Hello', got: {:?}",
&text[..text.len().min(100)]
);
}
#[test]
fn scanned_pdfs_have_no_text_layer() {
for name in &["graph_scanned.pdf", "skew_scanned.pdf"] {
let data = read_corpus(&format!("scanned/{name}")).unwrap();
let doc = Document::from_bytes(&data).unwrap();
let text = match doc.extract_page_text(0) {
Ok(t) => t,
Err(_) => String::new(), };
assert!(
text.len() < 50,
"{name}: scanned page should have <50 chars, got {}",
text.len()
);
}
}
#[test]
fn render_basic_pdfs_produces_valid_pixmaps() {
use pdfpurr::{RenderOptions, Renderer};
for name in &["tracemonkey.pdf", "generated_hello.pdf"] {
let data = read_corpus(&format!("basic/{name}")).unwrap();
let doc = Document::from_bytes(&data).unwrap();
let renderer = Renderer::new(
&doc,
RenderOptions {
dpi: 72.0,
..Default::default()
},
);
let pixmap = renderer
.render_page(0)
.unwrap_or_else(|e| panic!("{name} render failed: {e}"));
assert!(
pixmap.width() > 100,
"{name}: pixmap too narrow: {}",
pixmap.width()
);
assert!(
pixmap.height() > 100,
"{name}: pixmap too short: {}",
pixmap.height()
);
}
}
#[test]
fn render_scanned_pdfs_produces_valid_pixmaps() {
use pdfpurr::{RenderOptions, Renderer};
for name in &["graph_scanned.pdf", "linn_scanned.pdf", "skew_scanned.pdf"] {
let data = read_corpus(&format!("scanned/{name}")).unwrap();
let doc = Document::from_bytes(&data).unwrap();
let renderer = Renderer::new(
&doc,
RenderOptions {
dpi: 72.0,
..Default::default()
},
);
let pixmap = renderer
.render_page(0)
.unwrap_or_else(|e| panic!("{name} render failed: {e}"));
assert!(
pixmap.width() > 0 && pixmap.height() > 0,
"{name}: empty pixmap"
);
}
}
#[test]
fn tracemonkey_has_metadata() {
let data = read_corpus("basic/tracemonkey.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let meta = doc.metadata();
assert!(
meta.title.is_some() || meta.producer.is_some() || meta.creator.is_some(),
"tracemonkey should have title, producer, or creator"
);
}
#[test]
fn outlines_returns_valid_vec() {
let data = read_corpus("basic/tcp_ip_intro.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let outlines = doc.outlines();
assert!(
outlines.len() < 1000,
"Outline count should be reasonable, got {}",
outlines.len()
);
}
#[test]
fn annotations_returns_valid_vecs() {
for name in &["tracemonkey.pdf", "generated_hello.pdf", "pdf-sample.pdf"] {
let data = read_corpus(&format!("basic/{name}")).unwrap();
let doc = Document::from_bytes(&data).unwrap();
let page = doc.get_page(0).unwrap();
let annots = doc.page_annotations(page);
assert!(
annots.len() < 10000,
"{name}: annotation count should be reasonable, got {}",
annots.len()
);
}
}
#[test]
fn scanned_pdf_has_images() {
let data = read_corpus("scanned/graph_scanned.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let images = doc.extract_all_images().unwrap();
assert!(!images.is_empty(), "graph_scanned must have images");
let (_, _, img) = &images[0];
assert!(
img.width > 0 && img.height > 0,
"Image should have valid dimensions"
);
}
#[test]
fn tagged_pdf_has_structure_tree_with_children() {
let data = read_corpus("tagged/ua1_7.2-t02-pass-a.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let tree = doc
.structure_tree()
.expect("Passing UA PDF must have structure tree");
assert!(
!tree.children.is_empty(),
"Structure tree should have children"
);
}
#[test]
fn untagged_pdf_has_no_structure_tree() {
let data = read_corpus("basic/generated_hello.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
assert!(
doc.structure_tree().is_none(),
"generated_hello should not be tagged"
);
}
#[test]
fn accessibility_report_on_tagged_pdf_has_checks() {
let data = read_corpus("tagged/ua1_7.2-t02-pass-a.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let report = doc.accessibility_report();
assert!(
!report.checks.is_empty(),
"Accessibility report should contain checks"
);
}
#[test]
fn accessibility_report_on_untagged_pdf_is_noncompliant() {
let data = read_corpus("basic/dummy.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let report = doc.accessibility_report();
assert!(!report.is_compliant(), "Untagged PDF must not be compliant");
let failed = report.checks.iter().filter(|c| !c.passed).count();
assert!(failed > 0, "Should have at least one failed check, got 0");
}
#[test]
fn pdfa_validation_produces_report_with_checks() {
use pdfpurr::PdfALevel;
let data = read_corpus("tagged/vera_6-1-2-t01-pass-a.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let report = doc.validate_pdfa(PdfALevel::A1b);
assert!(
!report.checks.is_empty(),
"PDF/A validation should produce checks"
);
}
#[test]
fn roundtrip_preserves_page_count() {
let data = read_corpus("basic/generated_hello.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let original_pages = doc.page_count().unwrap();
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.page_count().unwrap(), original_pages);
}
#[test]
fn roundtrip_preserves_text() {
let data = read_corpus("basic/generated_hello.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let original_text = doc.extract_page_text(0).unwrap();
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let roundtrip_text = doc2.extract_page_text(0).unwrap();
assert_eq!(
original_text, roundtrip_text,
"Text should survive roundtrip"
);
}
#[test]
fn create_two_pages_and_roundtrip() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
doc.add_page(595.0, 842.0).unwrap();
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.page_count().unwrap(), 2);
}
#[test]
fn rotate_page_roundtrips() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
doc.rotate_page(0, 90).unwrap();
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.page_count().unwrap(), 1);
}
#[test]
fn merge_adds_pages() {
let data1 = read_corpus("basic/generated_hello.pdf").unwrap();
let data2 = read_corpus("basic/dummy.pdf").unwrap();
let mut doc1 = Document::from_bytes(&data1).unwrap();
let doc2 = Document::from_bytes(&data2).unwrap();
let before = doc1.page_count().unwrap();
let added = doc2.page_count().unwrap();
doc1.merge(&doc2).unwrap();
assert_eq!(
doc1.page_count().unwrap(),
before + added,
"Merge should add exactly {} pages",
added
);
}
#[test]
fn ttf_font_subsets_to_exact_glyph_count() {
let font_path = Path::new("tests/fonts/NotoSans-Regular.ttf");
if !font_path.exists() {
eprintln!("SKIP: NotoSans font not found");
return;
}
let data = std::fs::read(font_path).unwrap();
let font = pdfpurr::EmbeddedFont::from_ttf(&data).unwrap();
let subset = font.subset(&['A', 'B', 'C']).unwrap();
assert!(
subset.glyph_count() >= 3 && subset.glyph_count() <= 5,
"Expected 3-5 glyphs for ABC subset, got {}",
subset.glyph_count()
);
}
#[test]
fn otf_cff_font_subsets_correctly() {
let font_path = Path::new("tests/fonts/SourceCodePro-Regular.otf");
if !font_path.exists() {
eprintln!("SKIP: SourceCodePro font not found");
return;
}
let data = std::fs::read(font_path).unwrap();
let font = pdfpurr::EmbeddedFont::from_otf(&data).unwrap();
let subset = font.subset(&['X', 'Y', 'Z']).unwrap();
assert!(
subset.glyph_count() >= 3 && subset.glyph_count() <= 5,
"Expected 3-5 glyphs for XYZ subset, got {}",
subset.glyph_count()
);
}
#[test]
fn ocr_produces_extractable_text() {
use pdfpurr::ocr::engine::{OcrEngine, OcrImage, OcrResult, OcrWord};
use pdfpurr::ocr::OcrConfig;
struct MockEngine;
impl OcrEngine for MockEngine {
fn recognize(&self, _: &OcrImage) -> pdfpurr::error::PdfResult<OcrResult> {
Ok(OcrResult {
words: vec![OcrWord {
text: "MockOCR".into(),
x: 100,
y: 100,
width: 200,
height: 40,
confidence: 0.95,
}],
image_width: 2550,
image_height: 3300,
})
}
}
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
assert!(doc.ocr_page(0, &MockEngine, &OcrConfig::default()).unwrap());
let text = doc.extract_page_text(0).unwrap();
assert!(
text.contains("MockOCR"),
"OCR text must be extractable, got: {:?}",
&text[..text.len().min(100)]
);
}
#[test]
fn ocr_redo_replaces_old_text() {
use pdfpurr::ocr::engine::{OcrEngine, OcrImage, OcrResult, OcrWord};
use pdfpurr::ocr::OcrConfig;
struct E1;
impl OcrEngine for E1 {
fn recognize(&self, _: &OcrImage) -> pdfpurr::error::PdfResult<OcrResult> {
Ok(OcrResult {
words: vec![OcrWord {
text: "First".into(),
x: 100,
y: 100,
width: 200,
height: 40,
confidence: 0.9,
}],
image_width: 2550,
image_height: 3300,
})
}
}
struct E2;
impl OcrEngine for E2 {
fn recognize(&self, _: &OcrImage) -> pdfpurr::error::PdfResult<OcrResult> {
Ok(OcrResult {
words: vec![OcrWord {
text: "Second".into(),
x: 100,
y: 100,
width: 200,
height: 40,
confidence: 0.95,
}],
image_width: 2550,
image_height: 3300,
})
}
}
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
doc.ocr_page(0, &E1, &OcrConfig::default()).unwrap();
doc.redo_ocr_page(
0,
&E2,
&OcrConfig {
should_redo: true,
..Default::default()
},
)
.unwrap();
let text = doc.extract_page_text(0).unwrap();
assert!(
text.contains("Second"),
"Redo text missing, got: {:?}",
&text[..text.len().min(100)]
);
assert!(
!text.contains("First"),
"Old text should be gone, got: {:?}",
&text[..text.len().min(100)]
);
}
#[test]
fn linearized_output_roundtrips() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let bytes = doc.to_linearized_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.page_count().unwrap(), 1);
}
#[test]
fn incremental_update_adds_page() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let original = doc.to_bytes().unwrap();
let mut doc2 = Document::from_bytes(&original).unwrap();
doc2.add_page(612.0, 792.0).unwrap();
let updated = doc2.to_incremental_update(&original).unwrap();
let doc3 = Document::from_bytes(&updated).unwrap();
assert_eq!(doc3.page_count().unwrap(), 2);
}
#[test]
fn tracemonkey_has_no_form_fields() {
let data = read_corpus("basic/tracemonkey.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let fields = doc.form_fields();
assert!(
fields.is_empty(),
"tracemonkey should have no form fields, got {}",
fields.len()
);
}
#[test]
fn tracemonkey_has_no_signatures() {
let data = read_corpus("basic/tracemonkey.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let sigs = doc.signatures();
assert!(
sigs.is_empty(),
"tracemonkey should have no signatures, got {}",
sigs.len()
);
}
#[test]
fn lazy_loading_matches_eager() {
let data = read_corpus("basic/tracemonkey.pdf").unwrap();
let doc_eager = Document::from_bytes(&data).unwrap();
let doc_lazy = Document::from_bytes_lazy(&data).unwrap();
assert_eq!(
doc_eager.page_count().unwrap(),
doc_lazy.page_count().unwrap(),
"Lazy and eager should have same page count"
);
let text_eager = doc_eager.extract_page_text(0).unwrap_or_default();
let text_lazy = doc_lazy.extract_page_text(0).unwrap_or_default();
assert_eq!(text_eager, text_lazy, "Lazy and eager text should match");
}
#[test]
fn private_corpus_parse_without_panic() {
let corpus_dir = Path::new("tests/private_corpus");
if !corpus_dir.exists() {
eprintln!("SKIP: private_corpus directory not found");
return;
}
let mut tested = 0;
let mut parsed_ok = 0;
let mut parse_err = 0;
for entry in walkdir(corpus_dir) {
let data = match std::fs::read(&entry) {
Ok(d) => d,
Err(_) => continue,
};
match Document::from_bytes(&data) {
Ok(_) => parsed_ok += 1,
Err(_) => match Document::from_bytes_with_password(&data, b"") {
Ok(_) => parsed_ok += 1,
Err(_) => parse_err += 1,
},
}
tested += 1;
}
eprintln!("Private corpus: {tested} tested, {parsed_ok} OK, {parse_err} errors");
assert!(tested > 0, "Should test at least one file");
}
#[test]
fn tracemonkey_has_text_runs_with_positions() {
let data = read_corpus("basic/tracemonkey.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let runs = doc.extract_text_runs(0).unwrap();
assert!(
runs.len() > 10,
"tracemonkey should have many text runs, got {}",
runs.len()
);
for run in &runs {
assert!(run.x >= -10.0 && run.x < 700.0, "x={} out of bounds", run.x);
assert!(run.y >= -10.0 && run.y < 900.0, "y={} out of bounds", run.y);
assert!(run.font_size > 0.0, "font_size should be positive");
}
}
#[test]
fn tracemonkey_structure_has_headings_and_paragraphs() {
let data = read_corpus("basic/tracemonkey.pdf").unwrap();
let doc = Document::from_bytes(&data).unwrap();
let blocks = doc.analyze_page_structure(0).unwrap();
use pdfpurr::content::structure_detection::BlockRole;
let headings = blocks
.iter()
.filter(|b| matches!(b.role, BlockRole::Heading(_)))
.count();
let paragraphs = blocks
.iter()
.filter(|b| b.role == BlockRole::Paragraph)
.count();
assert!(headings > 0, "tracemonkey should have headings");
assert!(paragraphs > 0, "tracemonkey should have paragraphs");
assert!(
paragraphs > headings,
"Should have more paragraphs than headings: {paragraphs} vs {headings}"
);
}
fn walkdir(dir: &Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(walkdir(&path));
} else if path
.extension()
.map_or(false, |e| e == "pdf" || e == "fuzz" || e == "pdf_")
{
files.push(path);
}
}
}
files
}