use std::cell::RefCell;
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::Path;
use crate::content::text::extract_text_with_fonts;
use crate::core::objects::{
DictExt, Dictionary, IndirectRef, Object, ObjectId, PdfName, PdfString, StringFormat,
};
use crate::error::{PdfError, PdfResult};
use crate::fonts::Font;
use crate::forms::{self, FormField};
use crate::images::PdfImage;
use crate::parser::file_structure::{
find_startxref, load_xref_chain, parse_header, parse_indirect_object, parse_object_stream,
rebuild_xref_from_scan, PdfVersion,
};
use crate::structure::{Annotation, Metadata, Outline};
type CompressedEntries = Vec<(u32, u32, u16)>;
fn find_header_offset(data: &[u8]) -> PdfResult<usize> {
let search_limit = data.len().min(1024);
data[..search_limit]
.windows(5)
.position(|w| w == b"%PDF-")
.ok_or_else(|| PdfError::SyntaxError {
position: 0,
message: "%PDF- header not found in first 1024 bytes".to_string(),
})
}
#[derive(Debug)]
pub struct Document {
pub version: PdfVersion,
pub trailer: Dictionary,
objects: HashMap<ObjectId, Object>,
font_cache: RefCell<HashMap<ObjectId, HashMap<String, Font>>>,
raw_data: Option<Vec<u8>>,
deferred: RefCell<HashMap<ObjectId, u64>>,
lazy_cache: RefCell<HashMap<ObjectId, Object>>,
}
impl Default for Document {
fn default() -> Self {
Self::new()
}
}
impl Document {
pub fn open<P: AsRef<Path>>(path: P) -> PdfResult<Self> {
let data = fs::read(path)?;
Self::from_bytes(&data)
}
pub fn open_mmap<P: AsRef<Path>>(path: P) -> PdfResult<Self> {
let file = fs::File::open(path)?;
let mmap = unsafe { memmap2::Mmap::map(&file) }
.map_err(|e| PdfError::Io(std::io::Error::other(e)))?;
Self::from_bytes(&mmap)
}
pub fn from_bytes_lazy(data: &[u8]) -> PdfResult<Self> {
let header_offset = find_header_offset(data)?;
let (_, version) =
parse_header(&data[header_offset..]).map_err(|e| PdfError::SyntaxError {
position: header_offset,
message: format!("Failed to parse PDF header: {}", e),
})?;
let xref_chain_result = find_startxref(data)
.and_then(|offset| load_xref_chain(data, offset))
.and_then(|chain| {
if chain.is_empty() {
Err(PdfError::InvalidStructure(
"No xref sections found".to_string(),
))
} else {
Ok(chain)
}
});
let xref_chain = match xref_chain_result {
Ok(chain) => chain,
Err(_) => {
let (rebuilt_xref, rebuilt_trailer) = rebuild_xref_from_scan(data)?;
vec![(rebuilt_xref, rebuilt_trailer)]
}
};
let trailer = xref_chain
.last()
.ok_or_else(|| PdfError::InvalidStructure("Empty xref chain (lazy)".to_string()))?
.1
.clone();
let mut deferred_map: HashMap<ObjectId, u64> = HashMap::new();
let mut objects = HashMap::new();
for (xref_table, _) in &xref_chain {
for subsection in &xref_table.subsections {
for (i, entry) in subsection.entries.iter().enumerate() {
let obj_num = subsection.first_id + i as u32;
if !entry.in_use || entry.entry_type == 2 {
continue;
}
let id: ObjectId = (obj_num, entry.generation);
deferred_map.insert(id, entry.offset);
}
}
}
let root_ref = trailer
.get_str("Root")
.and_then(|o| o.as_reference())
.map(|r| r.id());
if let Some(root_id) = root_ref {
if let Some(&offset) = deferred_map.get(&root_id) {
if let Ok((_, indirect)) = parse_indirect_object(&data[offset as usize..]) {
objects.insert(root_id, indirect.object);
deferred_map.remove(&root_id);
}
}
}
Ok(Document {
version,
trailer,
objects,
font_cache: RefCell::new(HashMap::new()),
raw_data: Some(data.to_vec()),
deferred: RefCell::new(deferred_map),
lazy_cache: RefCell::new(HashMap::new()),
})
}
pub fn from_bytes(data: &[u8]) -> PdfResult<Self> {
let (mut doc, compressed_entries) = Self::parse_raw(data)?;
doc.expand_object_streams(&compressed_entries);
Ok(doc)
}
fn parse_raw(data: &[u8]) -> PdfResult<(Self, CompressedEntries)> {
let header_offset = find_header_offset(data)?;
let (_, version) =
parse_header(&data[header_offset..]).map_err(|e| PdfError::SyntaxError {
position: header_offset,
message: format!("Failed to parse PDF header: {}", e),
})?;
let xref_chain_result = find_startxref(data)
.and_then(|offset| load_xref_chain(data, offset))
.and_then(|chain| {
if chain.is_empty() {
Err(PdfError::InvalidStructure(
"No xref sections found".to_string(),
))
} else {
Ok(chain)
}
});
let xref_chain = match xref_chain_result {
Ok(chain) => chain,
Err(_) => {
let (rebuilt_xref, rebuilt_trailer) = rebuild_xref_from_scan(data)?;
vec![(rebuilt_xref, rebuilt_trailer)]
}
};
let trailer = xref_chain
.last()
.ok_or_else(|| PdfError::InvalidStructure("Empty xref chain".to_string()))?
.1
.clone();
let mut objects = HashMap::new();
let mut freed: std::collections::HashSet<(u32, u16)> = std::collections::HashSet::new();
let mut compressed_entries: CompressedEntries = Vec::new();
for (xref_table, _) in &xref_chain {
for subsection in &xref_table.subsections {
for (i, entry) in subsection.entries.iter().enumerate() {
let expected_num = subsection.first_id + i as u32;
if !entry.in_use {
let id = (expected_num, entry.generation);
objects.remove(&id);
freed.insert(id);
continue;
}
if entry.entry_type == 2 {
compressed_entries.push((
expected_num,
entry.offset as u32,
entry.generation,
));
continue;
}
let offset = entry.offset as usize;
if offset >= data.len() {
continue;
}
match parse_indirect_object(&data[offset..]) {
Ok((_, indirect_obj)) => {
let id = if indirect_obj.object_number == expected_num {
(indirect_obj.object_number, indirect_obj.generation)
} else {
(expected_num, entry.generation)
};
objects.insert(id, indirect_obj.object);
}
Err(e) => {
tracing::debug!(
"Skipping malformed object at offset {}: {e}",
entry.offset
);
continue;
}
}
}
}
}
let doc = Document {
version,
trailer,
objects,
font_cache: RefCell::new(HashMap::new()),
raw_data: None,
deferred: RefCell::new(HashMap::new()),
lazy_cache: RefCell::new(HashMap::new()),
};
Ok((doc, compressed_entries))
}
fn expand_object_streams(&mut self, compressed_entries: &[(u32, u32, u16)]) {
let obj_stream_ids: Vec<ObjectId> = self
.objects
.iter()
.filter(|(_, obj)| {
if let Object::Stream(s) = obj {
s.dict.get_name("Type") == Some("ObjStm")
} else {
false
}
})
.map(|(id, _)| *id)
.collect();
let mut objstm_contents: HashMap<(u32, u16), Object> = HashMap::new();
for stream_id in obj_stream_ids {
if let Some(Object::Stream(stream)) = self.objects.remove(&stream_id) {
if let Ok(embedded) = parse_object_stream(&stream) {
for (index, (obj_num, obj)) in embedded.into_iter().enumerate() {
objstm_contents.insert((stream_id.0, index as u16), obj.clone());
self.objects.entry((obj_num, 0)).or_insert(obj);
}
}
}
}
for &(obj_num, stream_obj_num, index) in compressed_entries {
if let Some(obj) = objstm_contents.get(&(stream_obj_num, index)) {
self.objects
.entry((obj_num, 0))
.or_insert_with(|| obj.clone());
}
}
}
pub fn from_bytes_with_password(data: &[u8], password: &[u8]) -> PdfResult<Self> {
let (mut doc, compressed_entries) = Self::parse_raw(data)?;
if let Some(encrypt_ref) = doc.trailer.get_str("Encrypt") {
let encrypt_ref = encrypt_ref.clone();
let encrypt_dict = doc
.resolve(&encrypt_ref)
.and_then(|o| o.as_dict())
.ok_or_else(|| {
PdfError::InvalidStructure("Cannot resolve /Encrypt dictionary".to_string())
})?
.clone();
let id_first = doc
.trailer
.get_str("ID")
.and_then(|o| o.as_array())
.and_then(|arr| arr.first())
.and_then(|o| o.as_pdf_string().map(|s| s.bytes.clone()))
.ok_or_else(|| {
PdfError::EncryptionError(
"Encrypted PDF missing /ID in trailer (required for key derivation)"
.to_string(),
)
})?;
let handler = crate::encryption::EncryptionHandler::from_dict(
&encrypt_dict,
&id_first,
password,
)?;
let encrypt_obj_id = encrypt_ref.as_reference().map(|r| r.id());
for (&obj_id, obj) in doc.objects.iter_mut() {
if Some(obj_id) == encrypt_obj_id {
continue;
}
if let Object::Stream(s) = obj {
if s.dict.get_name("Type") == Some("XRef") {
continue;
}
}
handler.decrypt_object(obj_id, obj);
}
}
doc.expand_object_streams(&compressed_entries);
Ok(doc)
}
pub fn object_count(&self) -> usize {
self.objects.len()
}
pub fn get_object(&self, id: ObjectId) -> Option<&Object> {
if let Some(obj) = self.objects.get(&id) {
return Some(obj);
}
if self.lazy_cache.borrow().contains_key(&id) {
let cache = self.lazy_cache.as_ptr();
return unsafe { (*cache).get(&id) };
}
let offset = self.deferred.borrow().get(&id).copied();
if let (Some(offset), Some(data)) = (offset, self.raw_data.as_ref()) {
let offset_usize = offset as usize;
if offset_usize < data.len() {
if let Ok((_, indirect)) = parse_indirect_object(&data[offset_usize..]) {
self.deferred.borrow_mut().remove(&id);
self.lazy_cache.borrow_mut().insert(id, indirect.object);
let cache = self.lazy_cache.as_ptr();
return unsafe { (*cache).get(&id) };
}
}
}
None
}
pub fn get_object_by_number(&self, object_number: u32) -> Option<&Object> {
self.get_object((object_number, 0))
}
pub fn resolve<'a>(&'a self, obj: &'a Object) -> Option<&'a Object> {
match obj {
Object::Reference(r) => self.get_object(r.id()),
_ => Some(obj),
}
}
fn resolve_reference(&self, obj: &Object) -> Option<&Object> {
match obj {
Object::Reference(r) => self.get_object(r.id()),
_ => None,
}
}
pub fn catalog_object_id(&self) -> Option<ObjectId> {
match self.trailer.get_str("Root")? {
Object::Reference(r) => Some(r.id()),
_ => None,
}
}
pub fn catalog(&self) -> PdfResult<&Dictionary> {
let root_ref = self
.trailer
.get_str("Root")
.ok_or_else(|| PdfError::InvalidStructure("No /Root in trailer".to_string()))?;
let root_obj = self.resolve(root_ref).ok_or_else(|| {
PdfError::InvalidReference("Cannot resolve /Root reference".to_string())
})?;
root_obj.as_dict().ok_or_else(|| PdfError::TypeError {
expected: "Dictionary".to_string(),
found: root_obj.type_name().to_string(),
})
}
pub fn page_count(&self) -> PdfResult<usize> {
let catalog = self.catalog()?;
let pages_ref = catalog
.get_str("Pages")
.ok_or_else(|| PdfError::InvalidStructure("No /Pages in catalog".to_string()))?;
let pages_obj = self.resolve(pages_ref).ok_or_else(|| {
PdfError::InvalidReference("Cannot resolve /Pages reference".to_string())
})?;
let pages_dict = pages_obj.as_dict().ok_or_else(|| PdfError::TypeError {
expected: "Dictionary".to_string(),
found: pages_obj.type_name().to_string(),
})?;
let count = pages_dict
.get_i64("Count")
.ok_or_else(|| PdfError::InvalidStructure("No /Count in Pages".to_string()))?;
if count < 0 {
return Err(PdfError::InvalidStructure(format!(
"Negative page count: {}",
count
)));
}
Ok(count as usize)
}
pub fn info(&self) -> Option<&Dictionary> {
let info_ref = self.trailer.get_str("Info")?;
let info_obj = self.resolve(info_ref)?;
info_obj.as_dict()
}
pub fn title(&self) -> Option<&str> {
let info = self.info()?;
let title_obj = info.get_str("Title")?;
title_obj.as_pdf_string()?.as_text()
}
pub fn pages(&self) -> PdfResult<Vec<&Dictionary>> {
let catalog = self.catalog()?;
let pages_ref = catalog
.get_str("Pages")
.ok_or_else(|| PdfError::InvalidStructure("No /Pages in catalog".to_string()))?;
let pages_obj = self.resolve(pages_ref).ok_or_else(|| {
PdfError::InvalidReference("Cannot resolve /Pages reference".to_string())
})?;
let pages_dict = pages_obj.as_dict().ok_or_else(|| PdfError::TypeError {
expected: "Dictionary".to_string(),
found: pages_obj.type_name().to_string(),
})?;
let mut result = Vec::new();
self.collect_pages_inner(pages_dict, &mut result, Self::MAX_PAGE_TREE_DEPTH)?;
Ok(result)
}
const MAX_PAGE_TREE_DEPTH: usize = 64;
pub fn get_page(&self, index: usize) -> PdfResult<&Dictionary> {
let pages = self.pages()?;
pages.get(index).copied().ok_or_else(|| {
PdfError::InvalidPage(format!(
"Page index {} out of range (total: {})",
index,
pages.len()
))
})
}
fn find_inherited<'a>(&'a self, page_dict: &'a Dictionary, key: &str) -> Option<&'a Object> {
self.find_inherited_inner(page_dict, key, 32)
}
fn find_inherited_inner<'a>(
&'a self,
page_dict: &'a Dictionary,
key: &str,
depth: usize,
) -> Option<&'a Object> {
if let Some(obj) = page_dict.get_str(key) {
return Some(obj);
}
if depth == 0 {
return None;
}
let parent_ref = page_dict.get_str("Parent")?;
let parent_obj = self.resolve(parent_ref)?;
let parent_dict = parent_obj.as_dict()?;
self.find_inherited_inner(parent_dict, key, depth - 1)
}
pub fn page_media_box(&self, page_dict: &Dictionary) -> PdfResult<[f64; 4]> {
let mb = self.find_inherited(page_dict, "MediaBox").ok_or_else(|| {
PdfError::InvalidPage("No MediaBox found in page or ancestors".to_string())
})?;
mb.parse_rect().ok_or_else(|| {
PdfError::InvalidStructure("MediaBox must be a 4-element numeric array".to_string())
})
}
fn collect_pages_inner<'a>(
&'a self,
node: &'a Dictionary,
result: &mut Vec<&'a Dictionary>,
depth: usize,
) -> PdfResult<()> {
if depth == 0 {
return Err(PdfError::InvalidStructure(
"Page tree exceeds maximum depth (circular reference or excessive nesting)"
.to_string(),
));
}
let type_name = node.get_name("Type");
match type_name {
Some("Page") => {
result.push(node);
}
Some("Pages") => {
let kids = node
.get_str("Kids")
.and_then(|o| o.as_array())
.ok_or_else(|| {
PdfError::InvalidStructure("No /Kids in /Pages node".to_string())
})?;
for kid_ref in kids {
let kid_obj = self.resolve(kid_ref).ok_or_else(|| {
PdfError::InvalidReference("Cannot resolve page tree child".to_string())
})?;
let kid_dict = kid_obj.as_dict().ok_or_else(|| PdfError::TypeError {
expected: "Dictionary".to_string(),
found: kid_obj.type_name().to_string(),
})?;
self.collect_pages_inner(kid_dict, result, depth - 1)?;
}
}
_ => {
result.push(node);
}
}
Ok(())
}
pub fn extract_page_text(&self, page_index: usize) -> PdfResult<String> {
let page = self.get_page(page_index)?;
self.extract_text_from_page_dict(page)
}
pub fn extract_all_text(&self) -> PdfResult<String> {
let pages = self.pages()?;
let mut result = String::new();
for (i, page) in pages.iter().enumerate() {
if i > 0 {
result.push('\x0C'); }
if let Ok(text) = self.extract_text_from_page_dict(page) {
result.push_str(&text);
}
}
Ok(result)
}
pub fn extract_text_runs(
&self,
page_index: usize,
) -> PdfResult<Vec<crate::content::analysis::TextRun>> {
let page = self.get_page(page_index)?;
let contents = match page.get_str("Contents") {
Some(c) => c,
None => return Ok(Vec::new()),
};
let data = self.resolve_content_data(contents)?;
let fonts = self.page_fonts(page);
crate::content::analysis::extract_text_runs(&data, &fonts)
}
pub fn analyze_page_structure(
&self,
page_index: usize,
) -> PdfResult<Vec<crate::content::structure_detection::TextBlock>> {
let runs = self.extract_text_runs(page_index)?;
if runs.is_empty() {
return Ok(Vec::new());
}
let page = self.get_page(page_index)?;
let media_box = self.page_media_box(page)?;
let page_width = media_box[2] - media_box[0];
let page_height = media_box[3] - media_box[1];
Ok(crate::content::structure_detection::classify_blocks(
&runs,
page_width,
page_height,
))
}
pub fn auto_tag(&mut self, language: &str) -> PdfResult<usize> {
if self.structure_tree().is_some() {
return Ok(0);
}
let page_count = self.page_count()?;
let mut all_blocks = Vec::new();
for i in 0..page_count {
match self.analyze_page_structure(i) {
Ok(blocks) => all_blocks.extend(blocks),
Err(_) => continue, }
}
if all_blocks.is_empty() {
return Ok(0);
}
crate::accessibility::auto_tag::auto_tag_from_blocks(self, &all_blocks, language)?;
Ok(all_blocks.len())
}
pub fn check_accessibility(&self) -> Vec<crate::accessibility::auto_tag::TagIssue> {
use crate::accessibility::auto_tag::TagIssue;
let mut all_issues = Vec::new();
if self.structure_tree().is_none() {
all_issues.push(TagIssue {
description: "Document has no structure tree (untagged)".to_string(),
suggestion: "Run auto_tag() to add tags from detected structure".to_string(),
severity: "error".to_string(),
});
}
let page_count = self.page_count().unwrap_or(0);
for i in 0..page_count {
let blocks = self.analyze_page_structure(i).unwrap_or_default();
let issues = crate::accessibility::auto_tag::check_tag_quality(self, &blocks, i);
all_issues.extend(issues);
}
all_issues.sort_by(|a, b| a.description.cmp(&b.description));
all_issues.dedup_by(|a, b| a.description == b.description);
all_issues
}
pub fn hybrid_ocr_page(
&mut self,
page_index: usize,
engine: &dyn crate::ocr::engine::OcrEngine,
config: &crate::ocr::OcrConfig,
) -> PdfResult<crate::ocr::hybrid::HybridResult> {
let runs = self.extract_text_runs(page_index)?;
let page = self.get_page(page_index)?;
let media_box = self.page_media_box(page)?;
let page_width = media_box[2] - media_box[0];
let page_height = media_box[3] - media_box[1];
let renderer = crate::rendering::Renderer::new(
self,
crate::rendering::RenderOptions {
dpi: config.dpi,
..Default::default()
},
);
let pixmap = renderer.render_page(page_index)?;
let grayscale = crate::ocr::pixmap_to_grayscale(&pixmap);
let ocr_image = if config.preprocess {
crate::ocr::preprocess::preprocess_for_ocr(&grayscale)
} else {
grayscale
};
let ocr_result = engine.recognize(&ocr_image)?;
let hybrid = crate::ocr::hybrid::compare_text_sources(&runs, &ocr_result);
let should_apply = hybrid.source == crate::ocr::hybrid::TextSource::Ocr
|| hybrid.source == crate::ocr::hybrid::TextSource::Both;
if should_apply && !ocr_result.words.is_empty() {
let layer = crate::ocr::text_layer::build_ocr_text_layer(
&ocr_result,
page_width,
page_height,
config,
);
let mut font_dict = Dictionary::new();
font_dict.insert(PdfName::new("Type"), Object::Name(PdfName::new("Font")));
font_dict.insert(PdfName::new("Subtype"), Object::Name(PdfName::new("Type1")));
font_dict.insert(
PdfName::new("BaseFont"),
Object::Name(PdfName::new("Helvetica")),
);
if let Some(cmap) = layer.to_unicode_cmap {
let cmap_id = self.add_object(Object::Stream(cmap));
font_dict.insert(
PdfName::new("ToUnicode"),
Object::Reference(crate::core::objects::IndirectRef::new(cmap_id.0, cmap_id.1)),
);
}
let mut fonts = Dictionary::new();
fonts.insert(
PdfName::new(crate::ocr::text_layer::OCR_FONT_NAME),
Object::Dictionary(font_dict),
);
self.append_content_stream(page_index, &layer.content, Some(fonts))?;
}
Ok(hybrid)
}
pub fn page_resources<'a>(&'a self, page_dict: &'a Dictionary) -> Option<&'a Dictionary> {
let res_obj = self.find_inherited(page_dict, "Resources")?;
self.resolve(res_obj)?.as_dict()
}
pub fn page_fonts(&self, page_dict: &Dictionary) -> HashMap<String, Font> {
let resources = match self.page_resources(page_dict) {
Some(r) => r,
None => return HashMap::new(),
};
let font_entry = match resources.get_str("Font") {
Some(obj) => obj,
None => return HashMap::new(),
};
let cache_key = font_entry.as_reference().map(|r| r.id());
if let Some(key) = cache_key {
if let Some(cached) = self.font_cache.borrow().get(&key) {
return cached.clone();
}
}
let font_dict = match self.resolve(font_entry).and_then(|o| o.as_dict()) {
Some(d) => d,
None => return HashMap::new(),
};
let resolve = |obj: &Object| self.resolve_reference(obj);
let mut fonts = HashMap::new();
for (name, font_ref) in font_dict {
let font_obj = match self.resolve(font_ref) {
Some(obj) => obj,
None => continue,
};
if let Some(font_dict) = font_obj.as_dict() {
match Font::from_dict(font_dict, &resolve) {
Ok(font) => {
fonts.insert(name.as_str().to_string(), font);
}
Err(e) => {
tracing::debug!(
"Font '{}' failed to parse: {e}. Using default encoding.",
name.as_str()
);
fonts.insert(name.as_str().to_string(), Font::default_fallback());
}
}
}
}
if let Some(key) = cache_key {
self.font_cache.borrow_mut().insert(key, fonts.clone());
}
fonts
}
pub fn page_images(&self, page_dict: &Dictionary) -> Vec<(String, PdfImage)> {
let mut images = Vec::new();
if let Some(resources) = self.page_resources(page_dict) {
if let Some(xobject_entry) = resources.get_str("XObject") {
if let Some(xobject_dict) = self.resolve(xobject_entry).and_then(|o| o.as_dict()) {
for (name, xobj_ref) in xobject_dict {
let xobj = match self.resolve(xobj_ref) {
Some(obj) => obj,
None => continue,
};
let stream = match xobj.as_stream() {
Some(s) => s,
None => continue,
};
if stream.dict.get_name("Subtype") != Some("Image") {
continue;
}
match PdfImage::from_stream(stream) {
Ok(img) => images.push((name.as_str().to_string(), img)),
Err(e) => {
tracing::debug!("Skipping image '{}': {e}", name.as_str());
continue;
}
}
}
}
}
}
if let Ok(content_data) = self.page_content_bytes(page_dict) {
let inline_images = extract_inline_images(&content_data);
for (idx, img) in inline_images.into_iter().enumerate() {
images.push((format!("Inline{}", idx), img));
}
}
images
}
fn page_content_bytes(&self, page_dict: &Dictionary) -> PdfResult<Vec<u8>> {
let contents = page_dict
.get(&PdfName::new("Contents"))
.ok_or_else(|| PdfError::InvalidStructure("No /Contents on page".to_string()))?;
let mut result = Vec::new();
match contents {
Object::Reference(r) => {
if let Some(Object::Stream(s)) = self.get_object(r.id()) {
match s.decode_data() {
Ok(data) => result.extend_from_slice(&data),
Err(e) => {
tracing::warn!(
"Content stream {:?} decode failed ({e}), using raw bytes",
r.id()
);
result.extend_from_slice(&s.data);
}
}
}
}
Object::Array(refs) => {
for item in refs {
if let Object::Reference(r) = item {
if let Some(Object::Stream(s)) = self.get_object(r.id()) {
match s.decode_data() {
Ok(data) => {
result.extend_from_slice(&data);
result.push(b'\n');
}
Err(e) => {
tracing::warn!(
"Content stream {:?} decode failed ({e}), using raw bytes",
r.id()
);
result.extend_from_slice(&s.data);
result.push(b'\n');
}
}
}
}
}
}
_ => {}
}
Ok(result)
}
pub fn extract_all_images(&self) -> PdfResult<Vec<(usize, String, PdfImage)>> {
let pages = self.pages()?;
let mut all_images = Vec::new();
for (i, page) in pages.iter().enumerate() {
for (name, img) in self.page_images(page) {
all_images.push((i, name, img));
}
}
Ok(all_images)
}
pub fn outlines(&self) -> Vec<Outline> {
let catalog = match self.catalog() {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let outlines_ref = match catalog.get_str("Outlines") {
Some(obj) => obj,
None => return Vec::new(),
};
let outlines_dict = match self.resolve(outlines_ref).and_then(|o| o.as_dict()) {
Some(d) => d,
None => return Vec::new(),
};
let resolve = |obj: &Object| self.resolve_reference(obj);
Outline::from_outlines_dict(outlines_dict, &resolve)
}
pub fn page_annotations(&self, page_dict: &Dictionary) -> Vec<Annotation> {
let resolve = |obj: &Object| self.resolve_reference(obj);
Annotation::from_page(page_dict, &resolve)
}
pub fn metadata(&self) -> Metadata {
let mut meta = Metadata::default();
if let Some(info_ref) = self.trailer.get_str("Info") {
if let Some(info_dict) = self.resolve(info_ref).and_then(|o| o.as_dict()) {
meta = Metadata::from_info_dict(info_dict);
}
}
if let Ok(catalog) = self.catalog() {
if let Some(meta_ref) = catalog.get_str("Metadata") {
if let Some(meta_stream) = self.resolve(meta_ref).and_then(|o| o.as_stream()) {
if let Ok(xmp_bytes) = meta_stream.decode_data() {
if let Ok(xmp_str) = std::str::from_utf8(&xmp_bytes) {
let xmp_meta = Metadata::from_xmp(xmp_str);
meta = meta.merge(&xmp_meta);
}
}
}
}
}
meta
}
pub(crate) fn pages_id(&self) -> PdfResult<ObjectId> {
let catalog = self.catalog()?;
let pages_ref = catalog
.get_str("Pages")
.ok_or_else(|| PdfError::InvalidStructure("No /Pages in catalog".to_string()))?;
pages_ref
.as_reference()
.map(|r| r.id())
.ok_or_else(|| PdfError::InvalidStructure("/Pages is not a reference".to_string()))
}
pub fn append_content_stream(
&mut self,
page_index: usize,
content: &[u8],
fonts: Option<Dictionary>,
) -> PdfResult<()> {
let page_id = self.page_object_id(page_index)?;
let mut stream_dict = Dictionary::new();
stream_dict.insert(
PdfName::new("Length"),
Object::Integer(content.len() as i64),
);
let stream = crate::core::objects::PdfStream::new(stream_dict, content.to_vec());
let stream_id = self.add_object(Object::Stream(stream));
let stream_ref = Object::Reference(IndirectRef::new(stream_id.0, stream_id.1));
let page = self
.get_object_mut(page_id)
.and_then(|o| {
if let Object::Dictionary(d) = o {
Some(d)
} else {
None
}
})
.ok_or_else(|| PdfError::InvalidStructure("Page is not a dictionary".to_string()))?;
match page.get_str("Contents").cloned() {
Some(existing @ Object::Reference(_)) => {
page.insert(
PdfName::new("Contents"),
Object::Array(vec![existing, stream_ref]),
);
}
Some(Object::Array(mut arr)) => {
arr.push(stream_ref);
page.insert(PdfName::new("Contents"), Object::Array(arr));
}
_ => {
page.insert(PdfName::new("Contents"), stream_ref);
}
}
if let Some(new_fonts) = fonts {
let res_ref = page.get_str("Resources").cloned();
let res_id = match &res_ref {
Some(Object::Reference(r)) => Some(r.id()),
_ => None,
};
if let Some(rid) = res_id {
if let Some(Object::Dictionary(res)) = self.get_object_mut(rid) {
let font_entry = res
.entry(PdfName::new("Font"))
.or_insert_with(|| Object::Dictionary(Dictionary::new()));
if let Object::Dictionary(fd) = font_entry {
for (key, val) in new_fonts.iter() {
fd.entry(key.clone()).or_insert_with(|| val.clone());
}
}
}
} else {
let page = self
.get_object_mut(page_id)
.and_then(|o| {
if let Object::Dictionary(d) = o {
Some(d)
} else {
None
}
})
.ok_or_else(|| PdfError::InvalidStructure("Page dict lost".to_string()))?;
let resources = page
.entry(PdfName::new("Resources"))
.or_insert_with(|| Object::Dictionary(Dictionary::new()));
if let Object::Dictionary(res) = resources {
let font_dict = res
.entry(PdfName::new("Font"))
.or_insert_with(|| Object::Dictionary(Dictionary::new()));
if let Object::Dictionary(fd) = font_dict {
for (key, val) in new_fonts.iter() {
fd.entry(key.clone()).or_insert_with(|| val.clone());
}
}
}
}
}
Ok(())
}
pub fn page_object_id(&self, index: usize) -> PdfResult<ObjectId> {
let pages_id = self.pages_id()?;
let pages = self
.get_object(pages_id)
.and_then(|o| o.as_dict())
.ok_or_else(|| PdfError::InvalidStructure("Cannot get /Pages dict".to_string()))?;
let kids = pages
.get_str("Kids")
.and_then(|o| o.as_array())
.ok_or_else(|| PdfError::InvalidStructure("No /Kids in /Pages".to_string()))?;
if index >= kids.len() {
return Err(PdfError::InvalidPage(format!(
"Page index {} out of range (total: {})",
index,
kids.len()
)));
}
kids[index]
.as_reference()
.ok_or_else(|| PdfError::InvalidStructure("Page ref is not a reference".to_string()))
.map(|r| r.id())
}
pub fn add_page(&mut self, width: f64, height: f64) -> PdfResult<usize> {
let pages_id = self.pages_id()?;
let mut page_dict = Dictionary::new();
page_dict.insert(PdfName::new("Type"), Object::Name(PdfName::new("Page")));
page_dict.insert(
PdfName::new("Parent"),
Object::Reference(IndirectRef::new(pages_id.0, pages_id.1)),
);
page_dict.insert(
PdfName::new("MediaBox"),
Object::Array(vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(width),
Object::Real(height),
]),
);
let page_id = self.add_object(Object::Dictionary(page_dict));
let pages = self
.get_object_mut(pages_id)
.and_then(|o| {
if let Object::Dictionary(d) = o {
Some(d)
} else {
None
}
})
.ok_or_else(|| PdfError::InvalidStructure("Cannot get /Pages dict".to_string()))?;
if let Some(Object::Array(kids)) = pages.get_mut(&PdfName::new("Kids")) {
kids.push(Object::Reference(IndirectRef::new(page_id.0, page_id.1)));
}
if let Some(Object::Integer(count)) = pages.get_mut(&PdfName::new("Count")) {
let idx = *count as usize;
*count += 1;
Ok(idx)
} else {
Err(PdfError::InvalidStructure(
"No /Count in /Pages".to_string(),
))
}
}
pub fn remove_page(&mut self, index: usize) -> PdfResult<()> {
let pages_id = self.pages_id()?;
let pages = self
.get_object_mut(pages_id)
.and_then(|o| {
if let Object::Dictionary(d) = o {
Some(d)
} else {
None
}
})
.ok_or_else(|| PdfError::InvalidStructure("Cannot get /Pages dict".to_string()))?;
if let Some(Object::Array(kids)) = pages.get_mut(&PdfName::new("Kids")) {
if index >= kids.len() {
return Err(PdfError::InvalidPage(format!(
"Page index {} out of range (total: {})",
index,
kids.len()
)));
}
kids.remove(index);
} else {
return Err(PdfError::InvalidStructure("No /Kids in /Pages".to_string()));
}
if let Some(Object::Integer(count)) = pages.get_mut(&PdfName::new("Count")) {
*count -= 1;
}
Ok(())
}
pub fn rotate_page(&mut self, page_index: usize, degrees: i32) -> PdfResult<()> {
if degrees % 90 != 0 {
return Err(PdfError::InvalidPage(
"Rotation must be a multiple of 90".to_string(),
));
}
let pages_id = self.pages_id()?;
let page_ref = {
let pages = self
.get_object(pages_id)
.and_then(|o| o.as_dict())
.ok_or_else(|| PdfError::InvalidStructure("Cannot get /Pages dict".to_string()))?;
let kids = pages
.get_str("Kids")
.and_then(|o| o.as_array())
.ok_or_else(|| PdfError::InvalidStructure("No /Kids in /Pages".to_string()))?;
if page_index >= kids.len() {
return Err(PdfError::InvalidPage(format!(
"Page index {} out of range (total: {})",
page_index,
kids.len()
)));
}
kids[page_index]
.as_reference()
.ok_or_else(|| {
PdfError::InvalidStructure("Page ref is not a reference".to_string())
})?
.id()
};
let page_dict = self
.get_object_mut(page_ref)
.and_then(|o| {
if let Object::Dictionary(d) = o {
Some(d)
} else {
None
}
})
.ok_or_else(|| PdfError::InvalidStructure("Cannot get page dict".to_string()))?;
page_dict.insert(PdfName::new("Rotate"), Object::Integer(degrees as i64));
Ok(())
}
pub fn reorder_pages(&mut self, order: &[usize]) -> PdfResult<()> {
let pages_id = self.pages_id()?;
let pages = self
.get_object_mut(pages_id)
.and_then(|o| {
if let Object::Dictionary(d) = o {
Some(d)
} else {
None
}
})
.ok_or_else(|| PdfError::InvalidStructure("Cannot get /Pages dict".to_string()))?;
if let Some(Object::Array(kids)) = pages.get_mut(&PdfName::new("Kids")) {
if order.len() != kids.len() {
return Err(PdfError::InvalidPage(format!(
"Order length ({}) doesn't match page count ({})",
order.len(),
kids.len()
)));
}
let old_kids = kids.clone();
for (i, &src) in order.iter().enumerate() {
if src >= old_kids.len() {
return Err(PdfError::InvalidPage(format!(
"Invalid page index {} in order",
src
)));
}
kids[i] = old_kids[src].clone();
}
}
Ok(())
}
pub fn merge(&mut self, other: &Document) -> PdfResult<()> {
let mut id_map: HashMap<ObjectId, ObjectId> = HashMap::new();
let base_num = self.objects.keys().map(|&(n, _)| n).max().unwrap_or(0) + 1;
for (i, &old_id) in other.objects.keys().enumerate() {
let new_id = (base_num + i as u32, 0);
id_map.insert(old_id, new_id);
}
for (&old_id, obj) in &other.objects {
let new_id = id_map[&old_id];
let mut new_obj = obj.clone();
Self::remap_references(&mut new_obj, &id_map);
self.objects.insert(new_id, new_obj);
}
let other_catalog = other.catalog()?;
let other_pages_ref = other_catalog
.get_str("Pages")
.ok_or_else(|| PdfError::InvalidStructure("No /Pages in other catalog".to_string()))?;
let other_pages = other
.resolve(other_pages_ref)
.and_then(|o| o.as_dict())
.ok_or_else(|| PdfError::InvalidStructure("Cannot resolve other /Pages".to_string()))?;
let other_kids = other_pages
.get_str("Kids")
.and_then(|o| o.as_array())
.unwrap_or(&[]);
let pages_id = self.pages_id()?;
for kid in other_kids {
if let Some(kid_ref) = kid.as_reference() {
if let Some(&new_id) = id_map.get(&kid_ref.id()) {
if let Some(Object::Dictionary(page_dict)) = self.get_object_mut(new_id) {
page_dict.insert(
PdfName::new("Parent"),
Object::Reference(IndirectRef::new(pages_id.0, pages_id.1)),
);
}
}
}
}
let pages = self
.get_object_mut(pages_id)
.and_then(|o| {
if let Object::Dictionary(d) = o {
Some(d)
} else {
None
}
})
.ok_or_else(|| PdfError::InvalidStructure("Cannot get /Pages dict".to_string()))?;
if let Some(Object::Array(kids)) = pages.get_mut(&PdfName::new("Kids")) {
for kid in other_kids {
let mut new_kid = kid.clone();
Self::remap_references(&mut new_kid, &id_map);
kids.push(new_kid);
}
}
let other_count = other_pages.get_i64("Count").unwrap_or(0);
if let Some(Object::Integer(count)) = pages.get_mut(&PdfName::new("Count")) {
*count += other_count;
}
Ok(())
}
fn remap_references(obj: &mut Object, id_map: &HashMap<ObjectId, ObjectId>) {
match obj {
Object::Reference(r) => {
if let Some(&new_id) = id_map.get(&r.id()) {
*r = IndirectRef::new(new_id.0, new_id.1);
}
}
Object::Array(arr) => {
for item in arr.iter_mut() {
Self::remap_references(item, id_map);
}
}
Object::Dictionary(dict) => {
for (_, val) in dict.iter_mut() {
Self::remap_references(val, id_map);
}
}
Object::Stream(stream) => {
for (_, val) in stream.dict.iter_mut() {
Self::remap_references(val, id_map);
}
}
_ => {}
}
}
pub fn form_fields(&self) -> Vec<FormField> {
let catalog = match self.catalog() {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let acroform = match catalog.get_str("AcroForm") {
Some(obj) => match self.resolve(obj).and_then(|o| o.as_dict()) {
Some(d) => d,
None => return Vec::new(),
},
None => return Vec::new(),
};
let fields = match acroform.get_str("Fields").and_then(|o| o.as_array()) {
Some(arr) => arr,
None => return Vec::new(),
};
forms::field::collect_fields(fields, "", &|obj| self.resolve(obj))
}
pub fn set_form_field(&mut self, name: &str, value: &str) -> PdfResult<()> {
let field = self
.form_fields()
.into_iter()
.find(|f| f.name == name)
.ok_or_else(|| {
PdfError::InvalidStructure(format!("Form field '{}' not found", name))
})?;
if field.obj_id == (0, 0) {
return Err(PdfError::InvalidStructure(
"Cannot set field value: field has no object ID".to_string(),
));
}
let dict = self
.get_object_mut(field.obj_id)
.and_then(|o| {
if let Object::Dictionary(d) = o {
Some(d)
} else {
None
}
})
.ok_or_else(|| PdfError::InvalidStructure("Cannot get field dictionary".to_string()))?;
dict.insert(
PdfName::new("V"),
Object::String(PdfString {
bytes: value.as_bytes().to_vec(),
format: StringFormat::Literal,
}),
);
dict.remove(&PdfName::new("AP"));
Ok(())
}
pub fn signatures(&self) -> Vec<crate::signatures::SignatureInfo> {
self.form_fields()
.iter()
.filter(|f| f.field_type == crate::forms::field::FieldType::Signature)
.filter_map(|f| {
if f.obj_id == (0, 0) {
return None;
}
let field_dict = self.get_object(f.obj_id)?.as_dict()?;
let sig_obj = field_dict.get_str("V")?;
let sig_dict = self.resolve(sig_obj).and_then(|o| o.as_dict())?;
crate::signatures::SignatureInfo::from_dict(sig_dict).ok()
})
.collect()
}
pub fn structure_tree(&self) -> Option<crate::accessibility::StructTree> {
let catalog = self.catalog().ok()?;
let struct_tree_ref = catalog.get_str("StructTreeRoot")?;
let struct_tree_dict = self.resolve(struct_tree_ref)?.as_dict()?;
let resolve = |obj: &Object| self.resolve_reference(obj);
Some(crate::accessibility::StructTree::from_dict(
struct_tree_dict,
&resolve,
))
}
pub fn accessibility_report(&self) -> crate::accessibility::AccessibilityReport {
match self.structure_tree() {
Some(tree) => crate::accessibility::validate_pdf_ua(&tree),
None => {
let empty = crate::accessibility::StructTree {
role_map: Default::default(),
children: vec![],
lang: None,
};
crate::accessibility::validate_pdf_ua(&empty)
}
}
}
pub fn validate_pdfa(
&self,
level: crate::standards::PdfALevel,
) -> crate::standards::StandardsReport {
crate::standards::validate_pdfa(self, level)
}
pub fn render_page(&self, page_index: usize, dpi: f64) -> PdfResult<tiny_skia::Pixmap> {
let opts = crate::rendering::RenderOptions {
dpi,
..Default::default()
};
crate::rendering::Renderer::new(self, opts).render_page(page_index)
}
pub fn validate_pdfx(
&self,
level: crate::standards::PdfXLevel,
) -> crate::standards::StandardsReport {
crate::standards::validate_pdfx(self, level)
}
pub fn new() -> Self {
let mut objects = HashMap::new();
let mut catalog = Dictionary::new();
catalog.insert(PdfName::new("Type"), Object::Name(PdfName::new("Catalog")));
catalog.insert(
PdfName::new("Pages"),
Object::Reference(IndirectRef::new(2, 0)),
);
objects.insert((1, 0), Object::Dictionary(catalog));
let mut pages = Dictionary::new();
pages.insert(PdfName::new("Type"), Object::Name(PdfName::new("Pages")));
pages.insert(PdfName::new("Kids"), Object::Array(Vec::new()));
pages.insert(PdfName::new("Count"), Object::Integer(0));
objects.insert((2, 0), Object::Dictionary(pages));
let mut trailer = Dictionary::new();
trailer.insert(PdfName::new("Size"), Object::Integer(3));
trailer.insert(
PdfName::new("Root"),
Object::Reference(IndirectRef::new(1, 0)),
);
Document {
version: PdfVersion::new(1, 7),
trailer,
objects,
font_cache: RefCell::new(HashMap::new()),
raw_data: None,
deferred: RefCell::new(HashMap::new()),
lazy_cache: RefCell::new(HashMap::new()),
}
}
pub fn add_object(&mut self, obj: Object) -> ObjectId {
let next_num = self.objects.keys().map(|&(num, _)| num).max().unwrap_or(0) + 1;
let id = (next_num, 0);
self.objects.insert(id, obj);
id
}
pub fn get_object_mut(&mut self, id: ObjectId) -> Option<&mut Object> {
self.objects.get_mut(&id)
}
pub(crate) fn iter_objects(&self) -> impl Iterator<Item = (&ObjectId, &Object)> {
self.objects.iter()
}
pub fn to_bytes(&self) -> PdfResult<Vec<u8>> {
let mut buf = Vec::new();
writeln!(buf, "%PDF-{}.{}", self.version.major, self.version.minor)
.map_err(PdfError::Io)?;
buf.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
let mut ids: Vec<ObjectId> = self.objects.keys().copied().collect();
ids.sort();
let mut offsets: Vec<(ObjectId, usize)> = Vec::with_capacity(ids.len());
for id in &ids {
let obj = &self.objects[id];
offsets.push((*id, buf.len()));
writeln!(buf, "{} {} obj", id.0, id.1).map_err(PdfError::Io)?;
obj.write_pdf(&mut buf).map_err(PdfError::Io)?;
buf.extend_from_slice(b"\nendobj\n");
}
let xref_offset = buf.len();
let max_obj_num = ids.iter().map(|&(n, _)| n).max().unwrap_or(0);
let xref_size = (max_obj_num + 1) as usize;
write!(buf, "xref\n0 {}\n", xref_size).map_err(PdfError::Io)?;
writeln!(buf, "{:010} 65535 f ", 0).map_err(PdfError::Io)?;
let offset_map: HashMap<u32, usize> =
offsets.iter().map(|&((num, _), off)| (num, off)).collect();
for num in 1..=max_obj_num {
if let Some(&off) = offset_map.get(&num) {
writeln!(buf, "{:010} 00000 n ", off).map_err(PdfError::Io)?;
} else {
writeln!(buf, "{:010} 00000 f ", 0).map_err(PdfError::Io)?;
}
}
let mut trailer = self.trailer.clone();
trailer.insert(PdfName::new("Size"), Object::Integer(xref_size as i64));
buf.extend_from_slice(b"trailer\n");
Object::Dictionary(trailer)
.write_pdf(&mut buf)
.map_err(PdfError::Io)?;
write!(buf, "\nstartxref\n{}\n%%EOF\n", xref_offset).map_err(PdfError::Io)?;
Ok(buf)
}
pub fn to_linearized_bytes(&self) -> PdfResult<Vec<u8>> {
let mut buf = Vec::new();
writeln!(buf, "%PDF-{}.{}", self.version.major, self.version.minor)
.map_err(PdfError::Io)?;
buf.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n");
let mut all_ids: Vec<ObjectId> = self.objects.keys().copied().collect();
all_ids.sort();
let first_page_ids = self.collect_first_page_object_ids();
let mut first_page: Vec<ObjectId> = all_ids
.iter()
.copied()
.filter(|id| first_page_ids.contains(id))
.collect();
first_page.sort();
let rest: Vec<ObjectId> = all_ids
.iter()
.copied()
.filter(|id| !first_page_ids.contains(id))
.collect();
let max_num = all_ids.iter().map(|&(n, _)| n).max().unwrap_or(0);
let lin_id: ObjectId = (max_num + 1, 0);
let lin_offset = buf.len();
let lin_placeholder = format!(
"{} 0 obj\n<< /Linearized 1 /L {} /O {} /E {} /N {} /T {} /H [ 0 0 ] >>\nendobj\n",
lin_id.0,
0, first_page.first().map(|&(n, _)| n).unwrap_or(0), 0, self.page_count().unwrap_or(0),
0, );
buf.extend_from_slice(lin_placeholder.as_bytes());
let mut offsets: Vec<(ObjectId, usize)> = Vec::with_capacity(all_ids.len() + 1);
offsets.push((lin_id, lin_offset));
for id in &first_page {
let obj = &self.objects[id];
offsets.push((*id, buf.len()));
writeln!(buf, "{} {} obj", id.0, id.1).map_err(PdfError::Io)?;
obj.write_pdf(&mut buf).map_err(PdfError::Io)?;
buf.extend_from_slice(b"\nendobj\n");
}
let end_of_first_page = buf.len();
for id in &rest {
let obj = &self.objects[id];
offsets.push((*id, buf.len()));
writeln!(buf, "{} {} obj", id.0, id.1).map_err(PdfError::Io)?;
obj.write_pdf(&mut buf).map_err(PdfError::Io)?;
buf.extend_from_slice(b"\nendobj\n");
}
let xref_offset = buf.len();
let xref_size = (lin_id.0 + 1) as usize;
write!(buf, "xref\n0 {}\n", xref_size).map_err(PdfError::Io)?;
writeln!(buf, "{:010} 65535 f ", 0).map_err(PdfError::Io)?;
let offset_map: HashMap<u32, usize> =
offsets.iter().map(|&((num, _), off)| (num, off)).collect();
for num in 1..lin_id.0 + 1 {
if let Some(&off) = offset_map.get(&num) {
writeln!(buf, "{:010} 00000 n ", off).map_err(PdfError::Io)?;
} else {
writeln!(buf, "{:010} 00000 f ", 0).map_err(PdfError::Io)?;
}
}
let mut trailer = self.trailer.clone();
trailer.insert(PdfName::new("Size"), Object::Integer(xref_size as i64));
buf.extend_from_slice(b"trailer\n");
Object::Dictionary(trailer)
.write_pdf(&mut buf)
.map_err(PdfError::Io)?;
write!(buf, "\nstartxref\n{}\n%%EOF\n", xref_offset).map_err(PdfError::Io)?;
let file_length = buf.len();
let patched_lin = format!(
"{} 0 obj\n<< /Linearized 1 /L {} /O {} /E {} /N {} /T {} /H [ 0 0 ] >>\nendobj\n",
lin_id.0,
file_length,
first_page.first().map(|&(n, _)| n).unwrap_or(0),
end_of_first_page,
self.page_count().unwrap_or(0),
xref_offset,
);
let patched_bytes = patched_lin.as_bytes();
if patched_bytes.len() <= lin_placeholder.len() {
buf[lin_offset..lin_offset + patched_bytes.len()].copy_from_slice(patched_bytes);
for b in &mut buf[lin_offset + patched_bytes.len()..lin_offset + lin_placeholder.len()]
{
*b = b' ';
}
}
Ok(buf)
}
fn collect_first_page_object_ids(&self) -> std::collections::HashSet<ObjectId> {
let mut ids = std::collections::HashSet::new();
let page_dict = match self.get_page(0) {
Ok(d) => d,
Err(_) => return ids,
};
self.collect_refs_from_dict(page_dict, &mut ids, 8);
if let Ok(catalog) = self.catalog() {
self.collect_refs_from_dict(catalog, &mut ids, 4);
}
ids
}
fn collect_refs_from_dict(
&self,
dict: &Dictionary,
ids: &mut std::collections::HashSet<ObjectId>,
depth: usize,
) {
if depth == 0 {
return;
}
for (_, val) in dict.iter() {
self.collect_refs_from_object(val, ids, depth);
}
}
fn collect_refs_from_object(
&self,
obj: &Object,
ids: &mut std::collections::HashSet<ObjectId>,
depth: usize,
) {
if depth == 0 {
return;
}
match obj {
Object::Reference(r) => {
let id = r.id();
if ids.insert(id) {
if let Some(target) = self.get_object(id) {
match target {
Object::Dictionary(d) => self.collect_refs_from_dict(d, ids, depth - 1),
Object::Stream(s) => {
self.collect_refs_from_dict(&s.dict, ids, depth - 1)
}
Object::Array(arr) => {
for item in arr {
self.collect_refs_from_object(item, ids, depth - 1);
}
}
_ => {}
}
}
}
}
Object::Array(arr) => {
for item in arr {
self.collect_refs_from_object(item, ids, depth - 1);
}
}
Object::Dictionary(d) => self.collect_refs_from_dict(d, ids, depth - 1),
_ => {}
}
}
pub fn to_incremental_update(&self, original: &[u8]) -> PdfResult<Vec<u8>> {
let mut buf = original.to_vec();
let prev_startxref = find_startxref(original)?;
let mut ids: Vec<ObjectId> = self.objects.keys().copied().collect();
ids.sort();
let mut offsets: Vec<(ObjectId, usize)> = Vec::with_capacity(ids.len());
for id in &ids {
let obj = &self.objects[id];
offsets.push((*id, buf.len()));
writeln!(buf, "{} {} obj", id.0, id.1).map_err(PdfError::Io)?;
obj.write_pdf(&mut buf).map_err(PdfError::Io)?;
buf.extend_from_slice(b"\nendobj\n");
}
let xref_offset = buf.len();
let max_obj_num = ids.iter().map(|&(n, _)| n).max().unwrap_or(0);
let xref_size = (max_obj_num + 1) as usize;
write!(buf, "xref\n0 {}\n", xref_size).map_err(PdfError::Io)?;
writeln!(buf, "{:010} 65535 f ", 0).map_err(PdfError::Io)?;
let offset_map: HashMap<u32, usize> =
offsets.iter().map(|&((num, _), off)| (num, off)).collect();
for num in 1..=max_obj_num {
if let Some(&off) = offset_map.get(&num) {
writeln!(buf, "{:010} 00000 n ", off).map_err(PdfError::Io)?;
} else {
writeln!(buf, "{:010} 00000 f ", 0).map_err(PdfError::Io)?;
}
}
let mut trailer = self.trailer.clone();
trailer.insert(PdfName::new("Size"), Object::Integer(xref_size as i64));
trailer.insert(PdfName::new("Prev"), Object::Integer(prev_startxref as i64));
buf.extend_from_slice(b"trailer\n");
Object::Dictionary(trailer)
.write_pdf(&mut buf)
.map_err(PdfError::Io)?;
write!(buf, "\nstartxref\n{}\n%%EOF\n", xref_offset).map_err(PdfError::Io)?;
Ok(buf)
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> PdfResult<()> {
let bytes = self.to_bytes()?;
fs::write(path, &bytes)?;
Ok(())
}
fn extract_text_from_page_dict(&self, page: &Dictionary) -> PdfResult<String> {
let contents = page
.get_str("Contents")
.ok_or_else(|| PdfError::InvalidPage("No /Contents in page".to_string()))?;
let content_data = self.resolve_content_data(contents)?;
let fonts = self.page_fonts(page);
extract_text_with_fonts(&content_data, &fonts)
}
pub(crate) fn resolve_content_data(&self, contents: &Object) -> PdfResult<Vec<u8>> {
match contents {
Object::Reference(_) => {
let resolved = self.resolve(contents).ok_or_else(|| {
PdfError::InvalidReference("Cannot resolve /Contents".to_string())
})?;
self.resolve_content_data(resolved)
}
Object::Stream(stream) => stream.decode_data(),
Object::Array(arr) => {
let mut data = Vec::new();
for item in arr {
let resolved = self.resolve(item).ok_or_else(|| {
PdfError::InvalidReference(
"Cannot resolve content stream array element".to_string(),
)
})?;
if let Some(stream) = resolved.as_stream() {
let decoded = stream.decode_data()?;
data.extend_from_slice(&decoded);
data.push(b'\n'); }
}
Ok(data)
}
_ => Err(PdfError::TypeError {
expected: "Stream, Reference, or Array".to_string(),
found: contents.type_name().to_string(),
}),
}
}
}
fn extract_inline_images(data: &[u8]) -> Vec<PdfImage> {
use crate::content::operators::ContentToken;
let tokens = match crate::content::operators::tokenize_content_stream(data) {
Ok(t) => t,
Err(e) => {
tracing::debug!("Content stream tokenization failed for inline image extraction: {e}");
return Vec::new();
}
};
let mut images = Vec::new();
for token in &tokens {
if let ContentToken::InlineImage { dict, data, .. } = token {
match PdfImage::from_inline(dict, data.clone()) {
Ok(img) => images.push(img),
Err(e) => tracing::debug!("Skipping malformed inline image: {e}"),
}
}
}
images
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::objects::{IndirectRef, PdfName};
fn make_minimal_pdf() -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
let obj1 = b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
let obj1_offset = pdf.len();
pdf.extend_from_slice(obj1);
let obj2 = b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
let obj2_offset = pdf.len();
pdf.extend_from_slice(obj2);
let obj3 = b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n";
let obj3_offset = pdf.len();
pdf.extend_from_slice(obj3);
let obj4 = b"4 0 obj\n<< /Title (Test Document) /Author (PDFPurr) >>\nendobj\n";
let obj4_offset = pdf.len();
pdf.extend_from_slice(obj4);
let xref_offset = pdf.len();
pdf.extend_from_slice(b"xref\n");
pdf.extend_from_slice(b"0 5\n");
pdf.extend_from_slice(format!("{:010} 65535 f \n", 0).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj1_offset).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj2_offset).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj3_offset).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj4_offset).as_bytes());
pdf.extend_from_slice(b"trailer\n");
pdf.extend_from_slice(b"<< /Size 5 /Root 1 0 R /Info 4 0 R >>\n");
pdf.extend_from_slice(format!("startxref\n{}\n%%EOF\n", xref_offset).as_bytes());
pdf
}
#[test]
fn parse_minimal_pdf() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
assert_eq!(doc.version, PdfVersion::new(1, 4));
assert_eq!(doc.object_count(), 4); }
#[test]
fn document_catalog() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let catalog = doc.catalog().unwrap();
assert_eq!(
catalog.get(&PdfName::new("Type")).unwrap().as_name(),
Some("Catalog")
);
}
#[test]
fn document_page_count() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
assert_eq!(doc.page_count().unwrap(), 1);
}
#[test]
fn document_info() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let info = doc.info().unwrap();
assert_eq!(
info.get(&PdfName::new("Author"))
.unwrap()
.as_pdf_string()
.unwrap()
.as_text(),
Some("PDFPurr")
);
}
#[test]
fn document_title() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
assert_eq!(doc.title(), Some("Test Document"));
}
#[test]
fn get_object_by_number() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let obj = doc.get_object_by_number(1).unwrap();
assert!(obj.is_dictionary());
}
#[test]
fn get_nonexistent_object() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
assert!(doc.get_object_by_number(99).is_none());
}
#[test]
fn resolve_reference() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let reference = Object::Reference(IndirectRef::new(1, 0));
let resolved = doc.resolve(&reference).unwrap();
assert!(resolved.is_dictionary());
}
#[test]
fn resolve_non_reference() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let obj = Object::Integer(42);
let resolved = doc.resolve(&obj).unwrap();
assert_eq!(resolved.as_i64(), Some(42));
}
#[test]
fn document_pages() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let pages = doc.pages().unwrap();
assert_eq!(pages.len(), 1);
assert_eq!(
pages[0].get(&PdfName::new("Type")).unwrap().as_name(),
Some("Page")
);
}
#[test]
fn document_get_page() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let page = doc.get_page(0).unwrap();
assert_eq!(
page.get(&PdfName::new("Type")).unwrap().as_name(),
Some("Page")
);
}
#[test]
fn document_get_page_out_of_range() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
assert!(doc.get_page(5).is_err());
}
#[test]
fn document_page_media_box() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let page = doc.get_page(0).unwrap();
let media_box = doc.page_media_box(page).unwrap();
assert_eq!(media_box, [0.0, 0.0, 612.0, 792.0]);
}
fn make_pdf_with_text(text_content: &[u8]) -> Vec<u8> {
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
let obj1 = b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
let obj1_offset = pdf.len();
pdf.extend_from_slice(obj1);
let obj2 = b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
let obj2_offset = pdf.len();
pdf.extend_from_slice(obj2);
let obj4_offset = pdf.len();
let stream_header = format!("4 0 obj\n<< /Length {} >>\nstream\n", text_content.len());
pdf.extend_from_slice(stream_header.as_bytes());
pdf.extend_from_slice(text_content);
pdf.extend_from_slice(b"\nendstream\nendobj\n");
let obj3_offset = pdf.len();
pdf.extend_from_slice(
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend_from_slice(b"xref\n");
pdf.extend_from_slice(b"0 5\n");
pdf.extend_from_slice(format!("{:010} 65535 f \n", 0).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj1_offset).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj2_offset).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj3_offset).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj4_offset).as_bytes());
pdf.extend_from_slice(b"trailer\n");
pdf.extend_from_slice(b"<< /Size 5 /Root 1 0 R >>\n");
pdf.extend_from_slice(format!("startxref\n{}\n%%EOF\n", xref_offset).as_bytes());
pdf
}
#[test]
fn extract_page_text_simple() {
let pdf = make_pdf_with_text(b"BT /F1 12 Tf (Hello World) Tj ET");
let doc = Document::from_bytes(&pdf).unwrap();
let text = doc.extract_page_text(0).unwrap();
assert_eq!(text, "Hello World");
}
#[test]
fn extract_page_text_multiple_strings() {
let pdf = make_pdf_with_text(b"BT /F1 12 Tf (Hello ) Tj (PDF) Tj ET");
let doc = Document::from_bytes(&pdf).unwrap();
let text = doc.extract_page_text(0).unwrap();
assert_eq!(text, "Hello PDF");
}
#[test]
fn extract_all_text_single_page() {
let pdf = make_pdf_with_text(b"BT (Test) Tj ET");
let doc = Document::from_bytes(&pdf).unwrap();
let text = doc.extract_all_text().unwrap();
assert_eq!(text, "Test");
}
#[test]
fn extract_page_text_out_of_range() {
let pdf = make_pdf_with_text(b"BT (Test) Tj ET");
let doc = Document::from_bytes(&pdf).unwrap();
assert!(doc.extract_page_text(5).is_err());
}
#[test]
fn extract_text_runs_simple() {
let pdf = make_pdf_with_text(b"BT /F1 12 Tf 100 700 Td (Hello) Tj ET");
let doc = Document::from_bytes(&pdf).unwrap();
let runs = doc.extract_text_runs(0).unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].text, "Hello");
assert!((runs[0].font_size - 12.0).abs() < 0.1);
assert!((runs[0].x - 100.0).abs() < 1.0);
assert!((runs[0].y - 700.0).abs() < 1.0);
}
#[test]
fn extract_text_runs_detects_bold() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let bold = crate::fonts::standard14::Standard14Font::from_name("Helvetica-Bold").unwrap();
let mut fonts = crate::core::objects::Dictionary::new();
fonts.insert(
PdfName::new("F1"),
Object::Dictionary(bold.to_font_dictionary()),
);
let content = b"BT /F1 18 Tf 100 700 Td (Title) Tj ET";
doc.append_content_stream(0, content, Some(fonts)).unwrap();
let runs = doc.extract_text_runs(0).unwrap();
assert_eq!(runs.len(), 1);
assert!(runs[0].is_bold, "Helvetica-Bold should be detected as bold");
assert!(!runs[0].is_italic);
}
#[test]
fn extract_text_runs_empty_page() {
let doc = Document::new();
let mut doc = doc;
doc.add_page(612.0, 792.0).unwrap();
let runs = doc.extract_text_runs(0).unwrap();
assert!(runs.is_empty());
}
#[test]
fn extract_text_runs_on_real_pdf() {
let path = std::path::Path::new("tests/corpus/basic/tracemonkey.pdf");
if !path.exists() {
return;
}
let data = std::fs::read(path).unwrap();
let doc = Document::from_bytes(&data).unwrap();
let runs = doc.extract_text_runs(0).unwrap();
assert!(!runs.is_empty(), "tracemonkey page 0 should have text runs");
let sizes: std::collections::HashSet<u32> =
runs.iter().map(|r| (r.font_size * 10.0) as u32).collect();
assert!(
sizes.len() >= 2,
"tracemonkey should have multiple font sizes, got {:?}",
sizes
);
for run in &runs {
assert!(
run.x >= -10.0 && run.x <= 700.0,
"x={} out of page bounds for '{}'",
run.x,
run.text
);
assert!(
run.y >= -10.0 && run.y <= 900.0,
"y={} out of page bounds for '{}'",
run.y,
run.text
);
}
}
#[test]
fn analyze_structure_detects_headings_on_real_pdf() {
use crate::content::structure_detection::BlockRole;
let path = std::path::Path::new("tests/corpus/basic/tracemonkey.pdf");
if !path.exists() {
return;
}
let data = std::fs::read(path).unwrap();
let doc = Document::from_bytes(&data).unwrap();
let blocks = doc.analyze_page_structure(0).unwrap();
assert!(!blocks.is_empty(), "tracemonkey should have blocks");
let headings: Vec<_> = blocks
.iter()
.filter(|b| matches!(b.role, BlockRole::Heading(_)))
.collect();
let paragraphs: Vec<_> = blocks
.iter()
.filter(|b| matches!(b.role, BlockRole::Paragraph))
.collect();
assert!(
!headings.is_empty(),
"tracemonkey should have headings (title is larger font)"
);
assert!(
!paragraphs.is_empty(),
"tracemonkey should have body paragraphs"
);
}
#[test]
fn analyze_structure_empty_page() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let blocks = doc.analyze_page_structure(0).unwrap();
assert!(blocks.is_empty());
}
#[test]
fn auto_tag_creates_structure_on_untagged_pdf() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let bold = crate::fonts::standard14::Standard14Font::from_name("Helvetica-Bold").unwrap();
let regular = crate::fonts::standard14::Standard14Font::from_name("Helvetica").unwrap();
let mut fonts = crate::core::objects::Dictionary::new();
fonts.insert(
PdfName::new("F1"),
Object::Dictionary(bold.to_font_dictionary()),
);
fonts.insert(
PdfName::new("F2"),
Object::Dictionary(regular.to_font_dictionary()),
);
let content = b"BT /F1 24 Tf 100 700 Td (Big Title) Tj ET BT /F2 12 Tf 100 650 Td (Body text here) Tj ET";
doc.append_content_stream(0, content, Some(fonts)).unwrap();
assert!(
doc.structure_tree().is_none(),
"Should be untagged before auto_tag"
);
let block_count = doc.auto_tag("en-US").unwrap();
assert!(block_count > 0, "Should tag some blocks");
assert!(
doc.structure_tree().is_some(),
"Should be tagged after auto_tag"
);
}
#[test]
fn auto_tag_skips_already_tagged() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let helv = crate::fonts::standard14::Standard14Font::from_name("Helvetica").unwrap();
let mut fonts = crate::core::objects::Dictionary::new();
fonts.insert(
PdfName::new("F1"),
Object::Dictionary(helv.to_font_dictionary()),
);
let content = b"BT /F1 12 Tf 100 700 Td (Text) Tj ET";
doc.append_content_stream(0, content, Some(fonts)).unwrap();
doc.auto_tag("en").unwrap();
let count = doc.auto_tag("en").unwrap();
assert_eq!(count, 0, "Should skip already tagged document");
}
#[test]
fn check_accessibility_on_untagged_pdf() {
let doc = Document::new();
let issues = doc.check_accessibility();
assert!(
issues.iter().any(|i| i.description.contains("untagged")),
"Should report untagged"
);
}
#[test]
fn check_accessibility_on_tagged_pdf() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let helv = crate::fonts::standard14::Standard14Font::from_name("Helvetica").unwrap();
let mut fonts = crate::core::objects::Dictionary::new();
fonts.insert(
PdfName::new("F1"),
Object::Dictionary(helv.to_font_dictionary()),
);
let content = b"BT /F1 12 Tf 100 700 Td (Text) Tj ET";
doc.append_content_stream(0, content, Some(fonts)).unwrap();
doc.auto_tag("en-US").unwrap();
let issues = doc.check_accessibility();
assert!(
!issues.iter().any(|i| i.description.contains("untagged")),
"Tagged doc should not report untagged"
);
}
#[test]
fn hybrid_ocr_with_mock_engine() {
use crate::ocr::engine::{OcrEngine, OcrImage, OcrResult, OcrWord};
use crate::ocr::OcrConfig;
struct MockEngine;
impl OcrEngine for MockEngine {
fn recognize(&self, _: &OcrImage) -> crate::error::PdfResult<OcrResult> {
Ok(OcrResult {
words: vec![OcrWord {
text: "MockText".to_string(),
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();
let result = doc
.hybrid_ocr_page(0, &MockEngine, &OcrConfig::default())
.unwrap();
assert_eq!(
result.source,
crate::ocr::hybrid::TextSource::Ocr,
"Blank page should use OCR"
);
assert!(result.accessible_text.contains("MockText"));
}
#[test]
fn hybrid_ocr_on_page_with_text() {
use crate::ocr::engine::{OcrEngine, OcrImage, OcrResult, OcrWord};
use crate::ocr::OcrConfig;
struct MatchingEngine;
impl OcrEngine for MatchingEngine {
fn recognize(&self, _: &OcrImage) -> crate::error::PdfResult<OcrResult> {
Ok(OcrResult {
words: vec![
OcrWord {
text: "Hello".to_string(),
x: 100,
y: 100,
width: 100,
height: 30,
confidence: 0.9,
},
OcrWord {
text: "World".to_string(),
x: 250,
y: 100,
width: 100,
height: 30,
confidence: 0.9,
},
],
image_width: 2550,
image_height: 3300,
})
}
}
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let helv = crate::fonts::standard14::Standard14Font::from_name("Helvetica").unwrap();
let mut fonts = crate::core::objects::Dictionary::new();
fonts.insert(
PdfName::new("F1"),
Object::Dictionary(helv.to_font_dictionary()),
);
let content = b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET";
doc.append_content_stream(0, content, Some(fonts)).unwrap();
let result = doc
.hybrid_ocr_page(0, &MatchingEngine, &OcrConfig::default())
.unwrap();
assert_eq!(
result.source,
crate::ocr::hybrid::TextSource::ContentStream,
"Matching text should prefer content stream"
);
}
#[test]
fn auto_tag_on_real_pdf() {
let path = std::path::Path::new("tests/corpus/basic/tracemonkey.pdf");
if !path.exists() {
return;
}
let data = std::fs::read(path).unwrap();
let mut doc = Document::from_bytes(&data).unwrap();
let result = doc.auto_tag("en-US");
assert!(
result.is_ok(),
"auto_tag should not error: {:?}",
result.err()
);
}
#[test]
fn invalid_pdf_no_header() {
let result = Document::from_bytes(b"not a pdf");
assert!(result.is_err());
}
#[test]
fn invalid_pdf_no_startxref() {
let result = Document::from_bytes(b"%PDF-1.4\nsome content but no xref");
assert!(result.is_err());
}
#[test]
fn incremental_update_new_object() {
let mut pdf = make_minimal_pdf();
let obj5_offset = pdf.len();
pdf.extend_from_slice(b"5 0 obj\n(Incremental Object)\nendobj\n");
let xref2_offset = pdf.len();
let orig_xref = find_startxref(&pdf).unwrap();
pdf.extend_from_slice(
format!(
"xref\n5 1\n\
{:010} 00000 n \n\
trailer\n<< /Size 6 /Root 1 0 R /Prev {} >>\n\
startxref\n{}\n%%EOF",
obj5_offset, orig_xref, xref2_offset
)
.as_bytes(),
);
let doc = Document::from_bytes(&pdf).unwrap();
assert_eq!(doc.object_count(), 5);
let obj5 = doc.get_object_by_number(5).unwrap();
assert!(obj5.is_string());
}
#[test]
fn incremental_update_overrides_object() {
let mut pdf = make_minimal_pdf();
let orig_xref = find_startxref(&pdf).unwrap();
let obj4_offset = pdf.len();
pdf.extend_from_slice(b"4 0 obj\n<< /Title (Updated Title) >>\nendobj\n");
let xref2_offset = pdf.len();
pdf.extend_from_slice(
format!(
"xref\n4 1\n\
{:010} 00000 n \n\
trailer\n<< /Size 5 /Root 1 0 R /Info 4 0 R /Prev {} >>\n\
startxref\n{}\n%%EOF",
obj4_offset, orig_xref, xref2_offset
)
.as_bytes(),
);
let doc = Document::from_bytes(&pdf).unwrap();
assert_eq!(doc.title(), Some("Updated Title"));
}
#[test]
fn document_metadata() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let meta = doc.metadata();
assert_eq!(meta.title, Some("Test Document".to_string()));
assert_eq!(meta.author, Some("PDFPurr".to_string()));
}
#[test]
fn document_metadata_no_info() {
let mut pdf = Vec::new();
pdf.extend_from_slice(b"%PDF-1.4\n");
let obj1_offset = pdf.len();
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let obj2_offset = pdf.len();
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
let obj3_offset = pdf.len();
pdf.extend_from_slice(
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
);
let xref_offset = pdf.len();
pdf.extend_from_slice(b"xref\n0 4\n");
pdf.extend_from_slice(format!("{:010} 65535 f \n", 0).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj1_offset).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj2_offset).as_bytes());
pdf.extend_from_slice(format!("{:010} 00000 n \n", obj3_offset).as_bytes());
pdf.extend_from_slice(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
pdf.extend_from_slice(format!("startxref\n{}\n%%EOF\n", xref_offset).as_bytes());
let doc = Document::from_bytes(&pdf).unwrap();
let meta = doc.metadata();
assert_eq!(meta, crate::structure::Metadata::default());
}
#[test]
fn document_outlines_empty() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
assert!(doc.outlines().is_empty());
}
#[test]
fn document_page_annotations_empty() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let page = doc.get_page(0).unwrap();
assert!(doc.page_annotations(page).is_empty());
}
#[test]
fn document_page_images_empty() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
let page = doc.get_page(0).unwrap();
assert!(doc.page_images(page).is_empty());
}
#[test]
fn page_images_finds_inline_images() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let content = b"BI\n/W 2\n/H 2\n/CS /RGB\n/BPC 8\nID\n\xFF\x00\x00\x00\xFF\x00\x00\x00\xFF\xFF\xFF\x00\nEI\n";
doc.append_content_stream(0, content, None).unwrap();
let page = doc.get_page(0).unwrap();
assert!(
page.get(&PdfName::new("Contents")).is_some(),
"Page should have /Contents after append"
);
let bytes = doc.page_content_bytes(page);
assert!(
bytes.is_ok(),
"page_content_bytes should work: {:?}",
bytes.err()
);
let bytes = bytes.unwrap();
assert!(!bytes.is_empty(), "Content bytes should not be empty");
let tokens = crate::content::operators::tokenize_content_stream(&bytes).unwrap();
let inline_count = tokens
.iter()
.filter(|t| {
matches!(
t,
crate::content::operators::ContentToken::InlineImage { .. }
)
})
.count();
assert!(
inline_count > 0,
"Tokenizer should find inline image, got {} tokens total: {:?}",
tokens.len(),
tokens
.iter()
.map(|t| format!("{:?}", std::mem::discriminant(t)))
.collect::<Vec<_>>()
);
let images = doc.page_images(page);
assert!(
!images.is_empty(),
"page_images should find inline images, got 0"
);
}
#[test]
fn document_new() {
let doc = Document::new();
assert_eq!(doc.version, PdfVersion::new(1, 7));
assert!(doc.catalog().is_ok());
assert_eq!(doc.page_count().unwrap(), 0);
}
#[test]
fn document_add_object() {
let mut doc = Document::new();
let id = doc.add_object(Object::Integer(42));
assert_eq!(doc.get_object(id), Some(&Object::Integer(42)));
}
#[test]
fn document_to_bytes_roundtrip() {
let doc = Document::new();
let bytes = doc.to_bytes().unwrap();
assert!(bytes.starts_with(b"%PDF-1.7"));
let tail = std::str::from_utf8(&bytes[bytes.len() - 7..]).unwrap();
assert!(tail.contains("%%EOF"));
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.page_count().unwrap(), 0);
assert!(doc2.catalog().is_ok());
}
#[test]
fn document_save_and_open() {
let doc = Document::new();
let tmp = std::env::temp_dir().join("pdfpurr_test_save.pdf");
doc.save(&tmp).unwrap();
let doc2 = Document::open(&tmp).unwrap();
assert_eq!(doc2.page_count().unwrap(), 0);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn add_page_to_new_document() {
let mut doc = Document::new();
assert_eq!(doc.page_count().unwrap(), 0);
let idx = doc.add_page(612.0, 792.0).unwrap();
assert_eq!(idx, 0);
assert_eq!(doc.page_count().unwrap(), 1);
let idx2 = doc.add_page(595.0, 842.0).unwrap();
assert_eq!(idx2, 1);
assert_eq!(doc.page_count().unwrap(), 2);
}
#[test]
fn add_page_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);
let page0 = doc2.get_page(0).unwrap();
let mb0 = doc2.page_media_box(page0).unwrap();
assert_eq!(mb0, [0.0, 0.0, 612.0, 792.0]);
let page1 = doc2.get_page(1).unwrap();
let mb1 = doc2.page_media_box(page1).unwrap();
assert_eq!(mb1, [0.0, 0.0, 595.0, 842.0]);
}
#[test]
fn remove_page() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
doc.add_page(595.0, 842.0).unwrap();
assert_eq!(doc.page_count().unwrap(), 2);
doc.remove_page(0).unwrap();
assert_eq!(doc.page_count().unwrap(), 1);
}
#[test]
fn remove_page_out_of_range() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
assert!(doc.remove_page(5).is_err());
}
#[test]
fn rotate_page() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
doc.rotate_page(0, 90).unwrap();
let pages_id = doc.pages_id().unwrap();
let pages = doc.get_object(pages_id).unwrap().as_dict().unwrap();
let kids = pages.get_str("Kids").unwrap().as_array().unwrap();
let page_ref = kids[0].as_reference().unwrap().id();
let page = doc.get_object(page_ref).unwrap().as_dict().unwrap();
assert_eq!(page.get_i64("Rotate"), Some(90));
}
#[test]
fn rotate_page_invalid_degrees() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
assert!(doc.rotate_page(0, 45).is_err());
}
#[test]
fn rotate_page_out_of_range() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
assert!(doc.rotate_page(5, 90).is_err());
}
#[test]
fn reorder_pages() {
let mut doc = Document::new();
doc.add_page(100.0, 100.0).unwrap(); doc.add_page(200.0, 200.0).unwrap(); doc.add_page(300.0, 300.0).unwrap();
doc.reorder_pages(&[2, 1, 0]).unwrap();
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.page_count().unwrap(), 3);
let page0 = doc2.get_page(0).unwrap();
let mb = doc2.page_media_box(page0).unwrap();
assert_eq!(mb, [0.0, 0.0, 300.0, 300.0]);
}
#[test]
fn reorder_pages_wrong_length() {
let mut doc = Document::new();
doc.add_page(100.0, 100.0).unwrap();
doc.add_page(200.0, 200.0).unwrap();
assert!(doc.reorder_pages(&[0]).is_err());
}
#[test]
fn merge_documents() {
let mut doc1 = Document::new();
doc1.add_page(612.0, 792.0).unwrap();
let mut doc2 = Document::new();
doc2.add_page(595.0, 842.0).unwrap();
doc2.add_page(400.0, 600.0).unwrap();
doc1.merge(&doc2).unwrap();
assert_eq!(doc1.page_count().unwrap(), 3);
}
#[test]
fn form_fields_empty_document() {
let doc = Document::new();
assert!(doc.form_fields().is_empty());
}
#[test]
fn form_fields_no_acroform() {
let pdf = make_minimal_pdf();
let doc = Document::from_bytes(&pdf).unwrap();
assert!(doc.form_fields().is_empty());
}
#[test]
fn form_fields_with_text_field() {
let mut doc = Document::new();
let mut field_dict = Dictionary::new();
field_dict.insert(PdfName::new("FT"), Object::Name(PdfName::new("Tx")));
field_dict.insert(
PdfName::new("T"),
Object::String(crate::core::objects::PdfString {
bytes: b"username".to_vec(),
format: crate::core::objects::StringFormat::Literal,
}),
);
field_dict.insert(
PdfName::new("V"),
Object::String(crate::core::objects::PdfString {
bytes: b"alice".to_vec(),
format: crate::core::objects::StringFormat::Literal,
}),
);
let field_id = doc.add_object(Object::Dictionary(field_dict));
let acroform_dict = Object::Dictionary({
let mut d = Dictionary::new();
d.insert(
PdfName::new("Fields"),
Object::Array(vec![Object::Reference(IndirectRef::new(
field_id.0, field_id.1,
))]),
);
d
});
if let Some(Object::Dictionary(catalog)) = doc.get_object_mut((1, 0)) {
catalog.insert(PdfName::new("AcroForm"), acroform_dict);
}
let fields = doc.form_fields();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].name, "username");
assert_eq!(fields[0].field_type, crate::forms::FieldType::Text);
assert_eq!(fields[0].value, Some("alice".to_string()));
}
#[test]
fn set_form_field_value() {
let mut doc = Document::new();
let mut field_dict = Dictionary::new();
field_dict.insert(PdfName::new("FT"), Object::Name(PdfName::new("Tx")));
field_dict.insert(
PdfName::new("T"),
Object::String(crate::core::objects::PdfString {
bytes: b"email".to_vec(),
format: crate::core::objects::StringFormat::Literal,
}),
);
let field_id = doc.add_object(Object::Dictionary(field_dict));
if let Some(Object::Dictionary(catalog)) = doc.get_object_mut((1, 0)) {
let mut acro = Dictionary::new();
acro.insert(
PdfName::new("Fields"),
Object::Array(vec![Object::Reference(IndirectRef::new(
field_id.0, field_id.1,
))]),
);
catalog.insert(PdfName::new("AcroForm"), Object::Dictionary(acro));
}
doc.set_form_field("email", "alice@example.com").unwrap();
let fields = doc.form_fields();
assert_eq!(fields[0].value, Some("alice@example.com".to_string()));
}
#[test]
fn set_form_field_not_found() {
let doc = &mut Document::new();
assert!(doc.set_form_field("nonexistent", "value").is_err());
}
#[test]
fn merge_roundtrip() {
let mut doc1 = Document::new();
doc1.add_page(612.0, 792.0).unwrap();
let mut doc2 = Document::new();
doc2.add_page(595.0, 842.0).unwrap();
doc1.merge(&doc2).unwrap();
let bytes = doc1.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
assert_eq!(reopened.page_count().unwrap(), 2);
let page0 = reopened.get_page(0).unwrap();
let mb0 = reopened.page_media_box(page0).unwrap();
assert_eq!(mb0, [0.0, 0.0, 612.0, 792.0]);
let page1 = reopened.get_page(1).unwrap();
let mb1 = reopened.page_media_box(page1).unwrap();
assert_eq!(mb1, [0.0, 0.0, 595.0, 842.0]);
}
#[test]
fn from_bytes_recovers_corrupt_xref() {
let pdf = b"%PDF-1.4\n\
1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
3 0 obj\n<< /Type /Page /MediaBox [0 0 612 792] /Parent 2 0 R >>\nendobj\n\
startxref\n99999\n%%EOF";
let doc = Document::from_bytes(pdf)
.unwrap_or_else(|e| panic!("should recover corrupt xref: {}", e));
assert_eq!(doc.page_count().unwrap(), 1);
}
#[test]
fn circular_page_tree_does_not_hang() {
let mut doc = Document::new();
let catalog = Object::Dictionary({
let mut d = Dictionary::new();
d.insert(PdfName::new("Type"), Object::Name(PdfName::new("Catalog")));
d.insert(
PdfName::new("Pages"),
Object::Reference(IndirectRef::new(2, 0)),
);
d
});
doc.objects.insert((1, 0), catalog);
let pages = Object::Dictionary({
let mut d = Dictionary::new();
d.insert(PdfName::new("Type"), Object::Name(PdfName::new("Pages")));
d.insert(PdfName::new("Count"), Object::Integer(1));
d.insert(
PdfName::new("Kids"),
Object::Array(vec![Object::Reference(IndirectRef::new(2, 0))]),
);
d
});
doc.objects.insert((2, 0), pages);
doc.trailer.insert(
PdfName::new("Root"),
Object::Reference(IndirectRef::new(1, 0)),
);
let result = doc.pages();
assert!(result.is_err(), "circular page tree should error, not hang");
}
#[test]
fn deeply_nested_page_tree_errors_at_depth_limit() {
let mut doc = Document::new();
let depth = 200u32;
for i in 2..=depth + 1 {
let pages = Object::Dictionary({
let mut d = Dictionary::new();
d.insert(PdfName::new("Type"), Object::Name(PdfName::new("Pages")));
d.insert(PdfName::new("Count"), Object::Integer(1));
d.insert(
PdfName::new("Kids"),
Object::Array(vec![Object::Reference(IndirectRef::new(i + 1, 0))]),
);
d
});
doc.objects.insert((i, 0), pages);
}
let leaf = Object::Dictionary({
let mut d = Dictionary::new();
d.insert(PdfName::new("Type"), Object::Name(PdfName::new("Page")));
d.insert(
PdfName::new("MediaBox"),
Object::Array(vec![
Object::Integer(0),
Object::Integer(0),
Object::Integer(612),
Object::Integer(792),
]),
);
d
});
doc.objects.insert((depth + 2, 0), leaf);
let catalog = Object::Dictionary({
let mut d = Dictionary::new();
d.insert(PdfName::new("Type"), Object::Name(PdfName::new("Catalog")));
d.insert(
PdfName::new("Pages"),
Object::Reference(IndirectRef::new(2, 0)),
);
d
});
doc.objects.insert((1, 0), catalog);
doc.trailer.insert(
PdfName::new("Root"),
Object::Reference(IndirectRef::new(1, 0)),
);
let result = doc.pages();
assert!(
result.is_err(),
"deeply nested page tree should error at depth limit"
);
}
#[test]
fn to_linearized_bytes_roundtrips() {
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_linearized_bytes().unwrap();
let parsed = Document::from_bytes(&bytes).unwrap();
assert_eq!(parsed.page_count().unwrap(), 2);
assert!(
bytes.windows(10).any(|w| w == b"Linearized"),
"output should contain /Linearized dictionary"
);
}
#[test]
fn to_linearized_bytes_first_page_objects_come_first() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let bytes = doc.to_linearized_bytes().unwrap();
let lin_pos = bytes.windows(10).position(|w| w == b"Linearized").unwrap();
assert!(
lin_pos < 200,
"/Linearized should be near the start, found at byte {}",
lin_pos
);
}
#[test]
fn to_incremental_update_preserves_original_bytes() {
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(595.0, 842.0).unwrap();
let updated = doc2.to_incremental_update(&original).unwrap();
assert!(
updated.starts_with(&original),
"incremental update must preserve original bytes"
);
assert!(
updated.len() > original.len(),
"incremental update must be longer than original"
);
let doc3 = Document::from_bytes(&updated).unwrap();
assert_eq!(doc3.page_count().unwrap(), 2);
}
#[test]
fn open_mmap_roundtrip() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
doc.add_page(595.0, 842.0).unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
doc.save(tmp.path()).unwrap();
let mapped = Document::open_mmap(tmp.path()).unwrap();
assert_eq!(mapped.page_count().unwrap(), 2);
}
#[test]
fn from_bytes_lazy_resolves_pages() {
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 lazy = Document::from_bytes_lazy(&bytes).unwrap();
assert_eq!(lazy.page_count().unwrap(), 2);
let pages = lazy.pages().unwrap();
assert_eq!(pages.len(), 2);
let mb0 = lazy.page_media_box(pages[0]).unwrap();
assert_eq!(mb0, [0.0, 0.0, 612.0, 792.0]);
let mb1 = lazy.page_media_box(pages[1]).unwrap();
assert_eq!(mb1, [0.0, 0.0, 595.0, 842.0]);
}
#[test]
fn from_bytes_lazy_uses_fewer_initial_objects() {
let mut doc = Document::new();
for _ in 0..5 {
doc.add_page(612.0, 792.0).unwrap();
}
let bytes = doc.to_bytes().unwrap();
let eager = Document::from_bytes(&bytes).unwrap();
let lazy = Document::from_bytes_lazy(&bytes).unwrap();
assert!(
lazy.object_count() < eager.object_count(),
"lazy ({}) should have fewer objects than eager ({})",
lazy.object_count(),
eager.object_count()
);
assert_eq!(lazy.page_count().unwrap(), 5);
}
#[test]
fn append_content_stream_adds_text_to_page() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let content = b"BT 3 Tr /F1 12 Tf 100 700 Td (Hello OCR) Tj ET";
let helv = crate::fonts::standard14::Standard14Font::from_name("Helvetica").unwrap();
let mut fonts = Dictionary::new();
fonts.insert(
PdfName::new("F1"),
Object::Dictionary(helv.to_font_dictionary()),
);
doc.append_content_stream(0, content, Some(fonts)).unwrap();
let bytes = doc.to_bytes().unwrap();
let parsed = Document::from_bytes(&bytes).unwrap();
assert_eq!(parsed.page_count().unwrap(), 1);
let page = parsed.get_page(0).unwrap();
assert!(page.get_str("Contents").is_some());
}
#[test]
fn append_content_stream_preserves_existing_content() {
use crate::content::ContentStreamBuilder;
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
let mut builder = ContentStreamBuilder::new();
builder
.begin_text()
.set_font("F1", 12.0)
.move_to(72.0, 720.0)
.show_text("Original")
.end_text();
let first = builder.build();
let helv = crate::fonts::standard14::Standard14Font::from_name("Helvetica").unwrap();
let mut fonts1 = Dictionary::new();
fonts1.insert(
PdfName::new("F1"),
Object::Dictionary(helv.to_font_dictionary()),
);
doc.append_content_stream(0, &first, Some(fonts1)).unwrap();
let second = b"BT 3 Tr /F1 12 Tf 100 600 Td (OCR Text) Tj ET";
doc.append_content_stream(0, second, None).unwrap();
let bytes = doc.to_bytes().unwrap();
let parsed = Document::from_bytes(&bytes).unwrap();
let page = parsed.get_page(0).unwrap();
let contents = page.get_str("Contents").unwrap();
assert!(
contents.as_array().is_some(),
"/Contents should be an array"
);
assert_eq!(contents.as_array().unwrap().len(), 2);
}
#[test]
fn page_object_id_returns_valid_id() {
let mut doc = Document::new();
doc.add_page(612.0, 792.0).unwrap();
doc.add_page(595.0, 842.0).unwrap();
let id0 = doc.page_object_id(0).unwrap();
let id1 = doc.page_object_id(1).unwrap();
assert_ne!(id0, id1);
assert!(doc.get_object(id0).is_some());
assert!(doc.get_object(id1).is_some());
}
#[test]
fn page_object_id_out_of_range() {
let doc = Document::new();
assert!(doc.page_object_id(0).is_err());
}
}