//! Xref-stream and object-stream acceptance tests. //! //! The merge criteria from `REVIEWS/adr/0013-pdf-xref-streams.md`. //! //! The defect these guard against was total: `XRefTable::parse_section` //! required the literal bytes `xref` at the `startxref` offset, so a //! PDF 1.5+ file — which has an indirect object there instead — failed with //! "expected xref keyword" and **the entire document was unreadable**. Every //! feature built on top of the parser was unreachable on any file produced //! in the last twenty years. //! //! Each test asserts real content, not merely that parsing returned `Ok`: a //! subtly wrong `/W` decode produces plausible offsets that resolve to the //! wrong objects, which is worse than failing. use std::path::PathBuf; use nigig_pdf_document::PdfDocument; use nigig_pdf_graphics::content::parse_content_stream; fn corpus(relative: &str) -> Vec { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus") .join(relative); std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) } /// Text drawn by the page, so a test can prove the *right* object resolved. fn page_text(doc: &mut PdfDocument) -> String { let page = doc.page(0).expect("page 0"); let ops = parse_content_stream(&page.content_data).expect("content parses"); ops.iter() .filter_map(|op| match op { nigig_pdf_graphics::content::PdfOp::ShowText(t) => { Some(String::from_utf8_lossy(t).into_owned()) } _ => None, }) .collect() } #[test] fn a_pdf_15_file_with_an_xref_stream_parses() { let bytes = corpus("xref/stream_basic.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("a PDF 1.5 file must parse; this used to fail outright"); assert_eq!(doc.page_count(), 1); assert!( page_text(&mut doc).contains("xrefstream"), "the page's own content must come back, not merely a successful parse" ); } /// The catalog and page tree of that fixture live *inside* an object stream, /// so this proves type-2 entries resolve rather than the file merely opening. #[test] fn objects_inside_an_object_stream_resolve() { let bytes = corpus("xref/stream_basic.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); // Object 1 is the catalog; it is stored compressed in object stream 6. let catalog = doc.resolve_obj_num(1).expect("catalog resolves"); let dict = catalog.as_dict().expect("catalog is a dict"); assert_eq!(dict.get_name("Type"), Some("Catalog")); // Object 2 is the page tree, also compressed, at a different index. let pages = doc.resolve_obj_num(2).expect("page tree resolves"); let dict = pages.as_dict().expect("page tree is a dict"); assert_eq!(dict.get_name("Type"), Some("Pages")); assert_eq!(dict.get_int("Count"), Some(1)); } /// `/W [0 4 2]`: a zero-width type column means the type defaults to 1. /// Ignoring that rule reads every entry as free and yields an apparently /// empty document instead of an error. #[test] fn a_zero_width_type_column_defaults_to_type_one() { let bytes = corpus("xref/stream_default_width.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); assert_eq!(doc.page_count(), 1); assert!(page_text(&mut doc).contains("defaultwidth")); } #[test] fn a_sparse_index_is_honoured() { let bytes = corpus("xref/stream_indexed.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); assert_eq!(doc.page_count(), 1); assert!(page_text(&mut doc).contains("xrefstream")); } /// A hybrid-reference file carries both a traditional table and an /// `/XRefStm`. Both must be read. #[test] fn a_hybrid_reference_file_reads_both_tables() { let bytes = corpus("xref/hybrid.pdf"); let mut doc = PdfDocument::parse(&bytes).expect("parses"); assert_eq!(doc.page_count(), 1); assert!( page_text(&mut doc).contains("hybrid"), "the content stream is described by the traditional table" ); } /// ADR 0013 rule 1: a malformed xref stream must not become an empty table, /// which would look like a valid document containing nothing. #[test] fn a_truncated_xref_stream_is_an_error_not_an_empty_document() { let bytes = corpus("xref/stream_truncated.pdf"); match PdfDocument::parse(&bytes) { Err(_) => {} Ok(mut doc) => { // If it parses at all it must not silently claim to be a valid // empty document; resolving the missing object must fail. assert!( doc.page(0).is_err() || doc.page_count() == 0, "a truncated xref stream produced an apparently valid document" ); } } } #[test] fn a_wrong_type_at_startxref_is_named_not_guessed() { let bytes = corpus("xref/stream_bad_type.pdf"); let err = PdfDocument::parse(&bytes) .err() .expect("a non-/XRef object at startxref must be refused"); let msg = err.to_string(); assert!( msg.contains("XRef") || msg.contains("Frobnicate"), "the error should name what was found: {msg}" ); } /// Traditional-xref documents must keep working: this is the whole existing /// corpus, and the change touched the shared entry point. #[test] fn traditional_xref_documents_still_parse() { for name in [ "basic/text.pdf", "basic/multipage.pdf", "forms/all_types.pdf", "annotations/links.pdf", ] { let bytes = corpus(name); let mut doc = PdfDocument::parse(&bytes).unwrap_or_else(|e| panic!("{name} regressed: {e}")); assert!(doc.page_count() > 0, "{name} lost its pages"); assert!(doc.page(0).is_ok(), "{name} page 0 stopped resolving"); } } #[test] fn every_xref_fixture_parses_without_panicking() { for name in [ "stream_basic.pdf", "stream_default_width.pdf", "stream_indexed.pdf", "stream_truncated.pdf", "stream_bad_type.pdf", "hybrid.pdf", ] { let bytes = corpus(&format!("xref/{name}")); // Either a document or a typed error; never a panic and never a hang. if let Ok(mut doc) = PdfDocument::parse(&bytes) { let _ = doc.page_count(); let _ = doc.page(0); let _ = doc.acroform(); } } }