use lopdf::{Dictionary, Document, Object, ObjectId};
use crate::{Result, RevenantError};
const MAX_PARENT_DEPTH: usize = 64;
const SIG_FLAGS_SIGNED_APPEND: i64 = 1 | 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ObjRef {
pub num: u32,
pub gen: u16,
}
impl ObjRef {
#[must_use]
pub fn new(num: u32, gen: u16) -> Self {
Self { num, gen }
}
}
impl std::fmt::Display for ObjRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {} R", self.num, self.gen)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PageInfo {
pub obj_num: u32,
pub width: f64,
pub height: f64,
pub annots: Vec<ObjRef>,
}
#[derive(Debug)]
pub struct PdfReader {
doc: Document,
}
impl PdfReader {
pub fn open(pdf_bytes: &[u8]) -> Result<Self> {
let doc = Document::load_mem(pdf_bytes)
.map_err(|e| RevenantError::Pdf(format!("Cannot parse PDF: {e}")))?;
Ok(Self { doc })
}
#[must_use]
pub fn page_count(&self) -> usize {
self.doc.get_pages().len()
}
#[must_use]
pub fn is_encrypted(&self) -> bool {
self.doc.is_encrypted() || self.doc.was_encrypted()
}
pub fn size(&self) -> Result<i64> {
self.doc
.trailer
.get(b"Size")
.and_then(Object::as_i64)
.map_err(|e| {
RevenantError::Pdf(format!("Cannot determine /Size from PDF trailer: {e}"))
})
}
#[must_use]
pub fn trailer_carry_forward(&self) -> Vec<String> {
let mut out = Vec::new();
if let Ok(Object::Reference(id)) = self.doc.trailer.get(b"Info") {
out.push(format!("/Info {} {} R", id.0, id.1));
}
if let Ok(id_obj) = self.doc.trailer.get(b"ID") {
let mut buf = Vec::new();
write_id_forcing_hex(id_obj, &mut buf);
if let Ok(id_str) = String::from_utf8(buf) {
out.push(format!("/ID {id_str}"));
}
}
out
}
pub fn page_info(&self, page_index: usize) -> Result<PageInfo> {
let pages = self.doc.get_pages();
let page_number = u32::try_from(page_index)
.ok()
.and_then(|i| i.checked_add(1))
.ok_or_else(|| RevenantError::Pdf(format!("Page index {page_index} too large")))?;
let page_id = *pages.get(&page_number).ok_or_else(|| {
RevenantError::Pdf(format!(
"Page {page_index} out of range (PDF has {} page(s), 0-based).",
pages.len()
))
})?;
let (width, height) = self.page_dimensions(page_id)?;
let annots = self.page_annots(page_id)?;
Ok(PageInfo {
obj_num: page_id.0,
width,
height,
annots,
})
}
fn page_dimensions(&self, page_id: ObjectId) -> Result<(f64, f64)> {
let box_obj = self
.get_inherited(page_id, b"CropBox")
.or_else(|| self.get_inherited(page_id, b"MediaBox"))
.ok_or_else(|| {
RevenantError::Pdf(format!("Page {} has no MediaBox or CropBox", page_id.0))
})?;
let [x0, y0, x1, y1] = self.array_f64_4(box_obj)?;
let mut w = (x1 - x0).abs();
let mut h = (y1 - y0).abs();
if let Some(rotate_obj) = self.get_inherited(page_id, b"Rotate") {
let rotate = rotate_obj.as_i64().unwrap_or(0).rem_euclid(360);
if rotate == 90 || rotate == 270 {
std::mem::swap(&mut w, &mut h);
}
}
Ok((w, h))
}
fn page_annots(&self, page_id: ObjectId) -> Result<Vec<ObjRef>> {
let page = self
.doc
.get_dictionary(page_id)
.map_err(|e| RevenantError::Pdf(format!("Cannot read page object: {e}")))?;
let Ok(annots_obj) = page.get(b"Annots") else {
return Ok(Vec::new());
};
let annots_obj = self.deref(annots_obj);
let Ok(array) = annots_obj.as_array() else {
return Ok(Vec::new());
};
let mut refs = Vec::with_capacity(array.len());
for elem in array {
let id = elem.as_reference().map_err(|_| {
RevenantError::Pdf(
"Existing annotation is inline, not an indirect reference; \
cannot carry it forward."
.to_owned(),
)
})?;
refs.push(ObjRef::new(id.0, id.1));
}
Ok(refs)
}
pub fn object_override(
&self,
obj_num: u32,
skip_key: &str,
new_entry: &str,
) -> Result<Vec<u8>> {
let obj = self
.doc
.get_object((obj_num, 0))
.map_err(|e| RevenantError::Pdf(format!("Cannot read object {obj_num}: {e}")))?;
let dict = obj.as_dict().map_err(|e| {
RevenantError::Pdf(format!("Object {obj_num} is not a dictionary: {e}"))
})?;
let skip = skip_key.strip_prefix('/').unwrap_or(skip_key).as_bytes();
let mut out = Vec::new();
out.extend_from_slice(format!("{obj_num} 0 obj\n<<\n").as_bytes());
for (key, value) in dict {
if key.as_slice() == skip {
continue;
}
out.extend_from_slice(b" ");
write_name(key, &mut out);
out.push(b' ');
write_object(value, &mut out);
out.push(b'\n');
}
out.extend_from_slice(new_entry.as_bytes());
out.push(b'\n');
out.extend_from_slice(b">>\nendobj\n");
Ok(out)
}
pub fn catalog_override_with_sig_field(
&self,
root_obj_num: u32,
new_field_obj_num: u32,
) -> Result<Vec<u8>> {
let obj = self
.doc
.get_object((root_obj_num, 0))
.map_err(|e| RevenantError::Pdf(format!("Cannot read catalog {root_obj_num}: {e}")))?;
let dict = obj.as_dict().map_err(|e| {
RevenantError::Pdf(format!("Catalog {root_obj_num} is not a dictionary: {e}"))
})?;
let mut out = Vec::new();
out.extend_from_slice(format!("{root_obj_num} 0 obj\n<<\n").as_bytes());
for (key, value) in dict {
if key.as_slice() == b"AcroForm" {
continue;
}
out.extend_from_slice(b" ");
write_name(key, &mut out);
out.push(b' ');
write_object(value, &mut out);
out.push(b'\n');
}
out.extend_from_slice(b" /AcroForm << ");
self.write_merged_acroform_body(dict.get(b"AcroForm").ok(), new_field_obj_num, &mut out);
out.extend_from_slice(b">>\n>>\nendobj\n");
Ok(out)
}
fn write_merged_acroform_body(
&self,
existing: Option<&Object>,
new_field_obj_num: u32,
out: &mut Vec<u8>,
) {
let existing_dict = existing
.map(|o| self.deref(o))
.and_then(|o| o.as_dict().ok());
let mut wrote_fields = false;
let mut wrote_sigflags = false;
if let Some(acroform) = existing_dict {
for (key, value) in acroform {
match key.as_slice() {
b"Fields" => {
self.write_merged_fields(value, new_field_obj_num, out);
wrote_fields = true;
}
b"SigFlags" => {
let flags =
self.deref(value).as_i64().unwrap_or(0) | SIG_FLAGS_SIGNED_APPEND;
out.extend_from_slice(format!("/SigFlags {flags} ").as_bytes());
wrote_sigflags = true;
}
_ => {
write_name(key, out);
out.push(b' ');
write_object(value, out);
out.push(b' ');
}
}
}
}
if !wrote_fields {
out.extend_from_slice(format!("/Fields [{new_field_obj_num} 0 R] ").as_bytes());
}
if !wrote_sigflags {
out.extend_from_slice(format!("/SigFlags {SIG_FLAGS_SIGNED_APPEND} ").as_bytes());
}
}
fn write_merged_fields(
&self,
fields_value: &Object,
new_field_obj_num: u32,
out: &mut Vec<u8>,
) {
out.extend_from_slice(b"/Fields [");
if let Ok(array) = self.deref(fields_value).as_array() {
for elem in array {
write_object(elem, out);
out.push(b' ');
}
}
out.extend_from_slice(format!("{new_field_obj_num} 0 R] ").as_bytes());
}
fn get_inherited(&self, start: ObjectId, key: &[u8]) -> Option<&Object> {
let mut current = start;
for _ in 0..MAX_PARENT_DEPTH {
let dict = self.doc.get_dictionary(current).ok()?;
if let Ok(value) = dict.get(key) {
return Some(self.deref(value));
}
let parent = dict.get(b"Parent").ok()?;
current = parent.as_reference().ok()?;
}
None
}
fn deref<'a>(&'a self, obj: &'a Object) -> &'a Object {
self.doc
.dereference(obj)
.map_or(obj, |(_, resolved)| resolved)
}
fn array_f64_4(&self, obj: &Object) -> Result<[f64; 4]> {
let array = self
.deref(obj)
.as_array()
.map_err(|e| RevenantError::Pdf(format!("Page box is not an array: {e}")))?;
if array.len() < 4 {
return Err(RevenantError::Pdf(format!(
"Page box has {} elements, expected 4",
array.len()
)));
}
let mut out = [0.0f64; 4];
for (slot, elem) in out.iter_mut().zip(array.iter()) {
*slot =
f64::from(self.deref(elem).as_float().map_err(|e| {
RevenantError::Pdf(format!("Page box value is not numeric: {e}"))
})?);
}
Ok(out)
}
}
fn write_name(name: &[u8], out: &mut Vec<u8>) {
out.push(b'/');
for &byte in name {
if is_name_special(byte) {
out.extend_from_slice(format!("#{byte:02X}").as_bytes());
} else {
out.push(byte);
}
}
}
fn is_name_special(byte: u8) -> bool {
b" \t\n\r\x0C()<>[]{}/%#".contains(&byte) || !(33..=126).contains(&byte)
}
fn write_object(obj: &Object, out: &mut Vec<u8>) {
match obj {
Object::Null => out.extend_from_slice(b"null"),
Object::Boolean(true) => out.extend_from_slice(b"true"),
Object::Boolean(false) => out.extend_from_slice(b"false"),
Object::Integer(value) => out.extend_from_slice(value.to_string().as_bytes()),
Object::Real(value) => out.extend_from_slice(format_real(*value).as_bytes()),
Object::Name(name) => write_name(name, out),
Object::String(text, format) => write_string(text, *format, out),
Object::Array(array) => write_array(array, out),
Object::Dictionary(dict) => write_dictionary(dict, out),
Object::Reference(id) => out.extend_from_slice(format!("{} {} R", id.0, id.1).as_bytes()),
Object::Stream(stream) => write_dictionary(&stream.dict, out),
}
}
fn format_real(value: f32) -> String {
format!("{value}")
}
fn write_id_forcing_hex(obj: &Object, out: &mut Vec<u8>) {
match obj {
Object::String(text, _) => write_string(text, lopdf::StringFormat::Hexadecimal, out),
Object::Array(array) => {
out.push(b'[');
for (i, elem) in array.iter().enumerate() {
if i > 0 {
out.push(b' ');
}
write_id_forcing_hex(elem, out);
}
out.push(b']');
}
other => write_object(other, out),
}
}
fn write_string(text: &[u8], format: lopdf::StringFormat, out: &mut Vec<u8>) {
match format {
lopdf::StringFormat::Literal => {
out.push(b'(');
for &byte in text {
if matches!(byte, b'(' | b')' | b'\\' | b'\r') {
out.push(b'\\');
}
out.push(byte);
}
out.push(b')');
}
lopdf::StringFormat::Hexadecimal => {
out.push(b'<');
for &byte in text {
out.extend_from_slice(format!("{byte:02X}").as_bytes());
}
out.push(b'>');
}
}
}
fn write_array(array: &[Object], out: &mut Vec<u8>) {
out.push(b'[');
for (i, elem) in array.iter().enumerate() {
if i > 0 {
out.push(b' ');
}
write_object(elem, out);
}
out.push(b']');
}
fn write_dictionary(dict: &Dictionary, out: &mut Vec<u8>) {
out.extend_from_slice(b"<< ");
for (key, value) in dict {
write_name(key, out);
out.push(b' ');
write_object(value, out);
out.push(b' ');
}
out.extend_from_slice(b">>");
}
#[cfg(test)]
mod tests {
use super::*;
const BLANK_LETTER: &[u8] = include_bytes!("testdata/blank_letter.pdf");
const TWO_PAGE_A4: &[u8] = include_bytes!("testdata/two_page_a4.pdf");
const XREF_STREAM: &[u8] = include_bytes!("testdata/blank_letter_xref_stream.pdf");
const ENCRYPTED: &[u8] = include_bytes!("testdata/encrypted.pdf");
const ENCRYPTED_EMPTY_PW: &[u8] = include_bytes!("testdata/encrypted_empty_password.pdf");
#[test]
fn detects_encryption() {
let r = PdfReader::open(ENCRYPTED).unwrap();
assert!(r.is_encrypted());
assert!(!PdfReader::open(BLANK_LETTER).unwrap().is_encrypted());
}
#[test]
fn detects_empty_password_encryption() {
let r = PdfReader::open(ENCRYPTED_EMPTY_PW).unwrap();
assert!(r.is_encrypted());
}
#[test]
fn id_carried_forward_as_hex_preserves_binary() {
let id = Object::Array(vec![
Object::String(vec![0x00, 0xFF, 0x41, 0x9A], lopdf::StringFormat::Literal),
Object::String(vec![0xDE, 0xAD], lopdf::StringFormat::Hexadecimal),
]);
let mut buf = Vec::new();
write_id_forcing_hex(&id, &mut buf);
let text = String::from_utf8(buf).expect("forced-hex /ID output must be ASCII");
assert_eq!(text, "[<00FF419A> <DEAD>]");
}
#[test]
fn reads_single_letter_page() {
let r = PdfReader::open(BLANK_LETTER).unwrap();
assert_eq!(r.page_count(), 1);
let info = r.page_info(0).unwrap();
assert!((info.width - 612.0).abs() < 1e-6, "width {}", info.width);
assert!((info.height - 792.0).abs() < 1e-6, "height {}", info.height);
assert!(info.annots.is_empty());
assert!(info.obj_num > 0);
}
#[test]
fn reads_two_a4_pages() {
let r = PdfReader::open(TWO_PAGE_A4).unwrap();
assert_eq!(r.page_count(), 2);
let p1 = r.page_info(0).unwrap();
let p2 = r.page_info(1).unwrap();
assert!((p1.width - 595.0).abs() < 1e-6);
assert!((p1.height - 842.0).abs() < 1e-6);
assert!((p2.height - 842.0).abs() < 1e-6);
assert_ne!(p1.obj_num, p2.obj_num);
}
#[test]
fn out_of_range_page_errors() {
let r = PdfReader::open(BLANK_LETTER).unwrap();
assert!(r.page_info(1).is_err());
}
#[test]
fn size_matches_object_count() {
let r = PdfReader::open(BLANK_LETTER).unwrap();
assert!(r.size().unwrap() >= 4);
}
#[test]
fn xref_stream_pdf_reads() {
let r = PdfReader::open(XREF_STREAM).unwrap();
assert_eq!(r.page_count(), 1);
let info = r.page_info(0).unwrap();
assert!((info.width - 612.0).abs() < 1e-6);
assert!(r.size().unwrap() >= 4);
}
#[test]
fn object_override_preserves_entries_and_appends() {
let r = PdfReader::open(BLANK_LETTER).unwrap();
let page_num = r.page_info(0).unwrap().obj_num;
let raw = r
.object_override(page_num, "/Annots", " /Annots [99 0 R]")
.unwrap();
let text = String::from_utf8_lossy(&raw);
assert!(
text.starts_with(&format!("{page_num} 0 obj\n<<\n")),
"{text}"
);
assert!(text.contains("/Type /Page"), "{text}");
assert!(text.contains("/MediaBox"), "{text}");
assert!(text.contains("/Annots [99 0 R]"), "{text}");
assert!(text.ends_with(">>\nendobj\n"), "{text}");
}
#[test]
fn object_override_skips_named_key() {
let r = PdfReader::open(BLANK_LETTER).unwrap();
let page_num = r.page_info(0).unwrap().obj_num;
let raw = r.object_override(page_num, "/Type", " /Extra 1").unwrap();
let text = String::from_utf8_lossy(&raw);
assert!(!text.contains("/Type /Page"), "{text}");
assert!(text.contains("/Extra 1"), "{text}");
}
fn root_obj_num(pdf: &[u8]) -> u32 {
let doc = Document::load_mem(pdf).unwrap();
doc.trailer.get(b"Root").unwrap().as_reference().unwrap().0
}
fn pdf_with_acroform() -> (Vec<u8>, u32, u32) {
use lopdf::{dictionary, Object};
let mut doc = Document::with_version("1.7");
let page_tree_id = doc.new_object_id();
let field_id = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Widget",
"FT" => "Tx",
"T" => Object::string_literal("existing_field"),
"Rect" => vec![0.into(), 0.into(), 100.into(), 20.into()],
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => page_tree_id,
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Annots" => vec![Object::Reference(field_id)],
});
doc.objects.insert(
page_tree_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => 1,
}),
);
let acroform_id = doc.add_object(dictionary! {
"Fields" => vec![Object::Reference(field_id)],
"SigFlags" => 1,
"NeedAppearances" => true,
});
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"Pages" => page_tree_id,
"AcroForm" => Object::Reference(acroform_id),
});
doc.trailer.set("Root", catalog_id);
let mut buf = Vec::new();
doc.save_to(&mut buf).expect("save fixture PDF");
(buf, field_id.0, catalog_id.0)
}
#[test]
fn catalog_override_creates_acroform_when_absent() {
let r = PdfReader::open(BLANK_LETTER).unwrap();
let root = root_obj_num(BLANK_LETTER);
let raw = r.catalog_override_with_sig_field(root, 99).unwrap();
let text = String::from_utf8_lossy(&raw);
assert!(
text.contains("/AcroForm << /Fields [99 0 R] /SigFlags 3 >>"),
"{text}"
);
assert!(text.contains("/Type /Catalog"), "{text}");
}
#[test]
fn catalog_override_merges_existing_acroform() {
let (pdf, field_num, root) = pdf_with_acroform();
let r = PdfReader::open(&pdf).unwrap();
let raw = r.catalog_override_with_sig_field(root, 99).unwrap();
let text = String::from_utf8_lossy(&raw);
assert!(text.contains("/Type /Catalog"), "{text}");
assert!(
text.contains(&format!("/Fields [{field_num} 0 R 99 0 R]")),
"{text}"
);
assert!(text.contains("/SigFlags 3"), "{text}");
assert!(text.contains("/NeedAppearances true"), "{text}");
}
}