//! Test fixture builders for `kebab-parse-pdf`. //! //! PDFs are constructed in-memory at test time via `lopdf` rather than //! committed as binary fixtures. Same rationale as //! `kebab-parse-image::tests::common`: fixture provenance is auditable //! from source, no `include_bytes!` paths to keep in sync, and the test //! binary stays self-contained. #![allow(dead_code)] use std::path::PathBuf; use kebab_core::{ AssetStorage, Checksum, ExtractConfig, ExtractContext, MediaType, RawAsset, SourceUri, WorkspacePath, }; use lopdf::content::{Content, Operation}; use lopdf::{Document, Object, Stream, dictionary}; use time::OffsetDateTime; /// `/Info` dict fields a fixture wants to surface (all optional). #[derive(Default, Clone)] pub struct InfoDict { pub title: Option>, // raw bytes — caller controls PDFDocEncoding vs UTF-16BE pub producer: Option<&'static str>, pub creator: Option<&'static str>, } /// Build a Helvetica-text PDF. `pages` is one entry per page; `None` /// means the page exists in `/Pages` but has no `/Contents` stream /// (the "scanned candidate" shape — `extract_text` returns empty). pub fn build_text_pdf(pages: &[Option<&str>]) -> Vec { build_text_pdf_with_info(pages, &InfoDict::default()) } pub fn build_text_pdf_with_info(pages: &[Option<&str>], info: &InfoDict) -> Vec { let mut doc = Document::with_version("1.5"); let pages_id = doc.new_object_id(); let font_id = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", }); let resources_id = doc.add_object(dictionary! { "Font" => dictionary! { "F1" => font_id }, }); let mut page_refs: Vec = Vec::new(); for page in pages { let mut page_dict = dictionary! { "Type" => "Page", "Parent" => pages_id, }; if let Some(text) = page { let content = Content { operations: vec![ Operation::new("BT", vec![]), Operation::new("Tf", vec!["F1".into(), 24.into()]), Operation::new("Td", vec![Object::Integer(100), Object::Integer(700)]), Operation::new("Tj", vec![Object::string_literal(*text)]), Operation::new("ET", vec![]), ], }; let stream_data = content.encode().expect("content encode"); let content_id = doc.add_object(Stream::new(dictionary! {}, stream_data)); page_dict.set("Contents", content_id); } let page_id = doc.add_object(page_dict); page_refs.push(page_id.into()); } let count = page_refs.len() as i64; let pages_dict = dictionary! { "Type" => "Pages", "Kids" => page_refs, "Count" => count, "Resources" => resources_id, "MediaBox" => vec![ Object::Integer(0), Object::Integer(0), Object::Integer(595), Object::Integer(842), ], }; doc.objects.insert(pages_id, Object::Dictionary(pages_dict)); let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, }); doc.trailer.set("Root", catalog_id); if info.title.is_some() || info.producer.is_some() || info.creator.is_some() { let mut info_dict = lopdf::Dictionary::new(); if let Some(title) = &info.title { info_dict.set( "Title", Object::String(title.clone(), lopdf::StringFormat::Literal), ); } if let Some(p) = info.producer { info_dict.set( "Producer", Object::String(p.as_bytes().to_vec(), lopdf::StringFormat::Literal), ); } if let Some(c) = info.creator { info_dict.set( "Creator", Object::String(c.as_bytes().to_vec(), lopdf::StringFormat::Literal), ); } let info_id = doc.add_object(Object::Dictionary(info_dict)); doc.trailer.set("Info", info_id); } let mut out: Vec = Vec::new(); doc.save_to(&mut out).expect("save PDF to memory"); out } /// Wrap any valid PDF byte buffer with a fake `/Encrypt` trailer entry /// so `Document::is_encrypted()` flips to true. We don't actually /// encrypt anything — the extractor refuses encrypted PDFs **before** /// touching streams, so the marker is sufficient. pub fn make_encrypted_pdf() -> Vec { let bytes = build_text_pdf(&[Some("placeholder")]); let mut doc = Document::load_mem(&bytes).expect("load round-tripped PDF"); let enc_id = doc.add_object(dictionary! { "Filter" => "Standard", "V" => 1, "R" => 2, "Length" => 40, "P" => -4, }); doc.trailer.set("Encrypt", enc_id); let mut out = Vec::new(); doc.save_to(&mut out).expect("save encrypted PDF"); out } /// 27-byte garbage with no `%PDF-` header — `Document::load_mem` errors. pub fn corrupt_pdf() -> Vec { b"NOT A PDF; just plain bytes".to_vec() } /// Encode a Rust `&str` as the PDF UTF-16BE-with-BOM string format. /// Used to verify `info::pdf_string` decodes the multilingual Title /// path correctly. pub fn utf16be_bom(s: &str) -> Vec { let mut out = Vec::with_capacity(2 + s.encode_utf16().count() * 2); out.extend_from_slice(&[0xFE, 0xFF]); for unit in s.encode_utf16() { out.extend_from_slice(&unit.to_be_bytes()); } out } /// Asset + ExtractContext fixture, mirroring `kebab-parse-image::tests::common`. pub struct PdfFixture { pub asset: RawAsset, workspace_root: PathBuf, config: ExtractConfig, } impl PdfFixture { pub fn ctx(&self) -> ExtractContext<'_> { ExtractContext { asset: &self.asset, workspace_root: &self.workspace_root, config: &self.config, } } } pub fn fixture_for(workspace_path: &str, bytes: &[u8]) -> PdfFixture { let blake = blake3::hash(bytes); let full_hex = blake.to_hex().to_string(); let asset_id = kebab_core::id_for_asset(&full_hex); let workspace_path = WorkspacePath::new(workspace_path.to_string()).unwrap(); let discovered_at = OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(); let asset = RawAsset { asset_id, source_uri: SourceUri::File(PathBuf::from(format!("/tmp/{}", workspace_path.0))), workspace_path, media_type: MediaType::Pdf, byte_len: bytes.len() as u64, checksum: Checksum(full_hex), discovered_at, stored: AssetStorage::Reference { path: PathBuf::from("/tmp/fake"), sha: Checksum("0".repeat(64)), }, }; PdfFixture { asset, workspace_root: PathBuf::from("/tmp/fake-root"), config: ExtractConfig::default(), } } /// Replace every provenance event timestamp after index 0 (Discovered) /// with `` so determinism / snapshot tests can compare JSON /// across runs. Same shape as `kebab-parse-image::tests::common::strip_dynamic_at`. pub fn strip_dynamic_at(json: &mut serde_json::Value) { if let Some(events) = json .get_mut("provenance") .and_then(|p| p.get_mut("events")) .and_then(|e| e.as_array_mut()) { for (i, ev) in events.iter_mut().enumerate() { if i > 0 && let Some(obj) = ev.as_object_mut() { obj.insert("at".into(), serde_json::Value::String("".into())); } } } }