use std::collections::{HashMap, HashSet};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use hayro::hayro_syntax::{LoadPdfError, Pdf};
use lopdf::{Dictionary, Document, Object, ObjectId};
pub const INHERITABLE: [&[u8]; 4] = [b"Resources", b"MediaBox", b"CropBox", b"Rotate"];
pub fn load(path: &Path, password: Option<&str>) -> Result<Document> {
let doc = match password {
Some(p) => Document::load_with_password(path, p),
None => Document::load(path),
}
.map_err(|e| match e {
lopdf::Error::IO(io) => anyhow!("cannot read {}: {io}", path.display()),
other => anyhow!("cannot open {}: {other}", path.display()),
})?;
if doc.is_encrypted() {
bail!(
"{} is encrypted: {}",
path.display(),
if password.is_some() {
"wrong password"
} else {
"pass a password"
}
);
}
Ok(doc)
}
pub fn save(doc: &mut Document, path: &Path) -> Result<u64> {
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
}
let mut tmp = path.as_os_str().to_owned();
tmp.push(format!(".tmp{}", std::process::id()));
let tmp = PathBuf::from(tmp);
doc.max_id = doc.objects.keys().map(|id| id.0).max().unwrap_or(0);
let written = (|| -> Result<()> {
let mut w = BufWriter::new(std::fs::File::create(&tmp)?);
doc.save_to(&mut w)?;
w.flush()?;
Ok(())
})();
if let Err(e) = written.and_then(|_| Ok(std::fs::rename(&tmp, path)?)) {
let _ = std::fs::remove_file(&tmp);
return Err(e.context(format!("cannot write {}", path.display())));
}
Ok(std::fs::metadata(path)?.len())
}
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let mut tmp = path.as_os_str().to_owned();
tmp.push(format!(".tmp{}", std::process::id()));
std::fs::write(&tmp, bytes)
.and_then(|_| std::fs::rename(&tmp, path))
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
anyhow!("cannot write {}: {e}", path.display())
})
}
pub fn page_ids(doc: &Document) -> Vec<ObjectId> {
doc.get_pages().into_values().collect()
}
pub fn resolve<'a>(doc: &'a Document, obj: &'a Object) -> &'a Object {
doc.dereference(obj).map(|(_, o)| o).unwrap_or(obj)
}
pub fn inherited<'a>(doc: &'a Document, page: ObjectId, key: &[u8]) -> Option<&'a Object> {
let mut dict = doc.get_dictionary(page).ok()?;
for _ in 0..64 {
if let Ok(v) = dict.get(key) {
return Some(resolve(doc, v));
}
dict = doc
.get_dictionary(dict.get(b"Parent").ok()?.as_reference().ok()?)
.ok()?;
}
None
}
pub fn number(doc: &Document, obj: &Object) -> Option<f64> {
match resolve(doc, obj) {
Object::Integer(i) => Some(*i as f64),
Object::Real(r) => Some(*r as f64),
_ => None,
}
}
pub fn page_box(doc: &Document, page: ObjectId) -> [f64; 4] {
let read = |key: &[u8]| -> Option<[f64; 4]> {
let arr = inherited(doc, page, key)?.as_array().ok()?;
let v: Vec<f64> = arr.iter().filter_map(|o| number(doc, o)).collect();
<[f64; 4]>::try_from(v).ok()
};
read(b"CropBox")
.or_else(|| read(b"MediaBox"))
.unwrap_or([0.0, 0.0, 612.0, 792.0])
}
pub fn rotation(doc: &Document, page: ObjectId) -> i64 {
inherited(doc, page, b"Rotate")
.and_then(|o| o.as_i64().ok())
.unwrap_or(0)
.rem_euclid(360)
}
pub fn text(obj: &Object) -> Option<String> {
match obj {
Object::String(..) => lopdf::decode_text_string(obj).ok(),
Object::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
_ => None,
}
}
pub fn ensure_indirect_dict(
doc: &mut Document,
owner: Option<ObjectId>,
key: &[u8],
) -> Result<ObjectId> {
let current = match owner {
Some(id) => doc.get_dictionary(id)?.get(key).ok().cloned(),
None => doc.trailer.get(key).ok().cloned(),
};
let id = match current {
Some(Object::Reference(id)) if doc.get_dictionary(id).is_ok() => return Ok(id),
Some(Object::Dictionary(d)) => doc.add_object(d),
_ => doc.add_object(Dictionary::new()),
};
match owner {
Some(o) => doc.get_dictionary_mut(o)?.set(key, id),
None => doc.trailer.set(key, id),
}
Ok(id)
}
pub fn catalog_id(doc: &Document) -> Result<ObjectId> {
Ok(doc.trailer.get(b"Root")?.as_reference()?)
}
pub struct OutlineItem {
pub level: usize,
pub title: String,
pub page: Option<ObjectId>,
}
fn destination_page(doc: &Document, dest: &Object) -> Option<ObjectId> {
match resolve(doc, dest) {
Object::Array(a) => a.first()?.as_reference().ok(),
Object::Dictionary(d) => resolve(doc, d.get(b"D").ok()?)
.as_array()
.ok()?
.first()?
.as_reference()
.ok(),
_ => None,
}
}
fn named_destinations(doc: &Document) -> HashMap<Vec<u8>, ObjectId> {
let mut map = HashMap::new();
let Ok(catalog) = doc.catalog() else {
return map;
};
let dict = |owner: &Dictionary, key: &[u8]| {
owner
.get(key)
.ok()
.and_then(|o| resolve(doc, o).as_dict().ok())
.cloned()
};
if let Some(dests) = dict(catalog, b"Dests") {
for (name, dest) in dests.iter() {
map.extend(destination_page(doc, dest).map(|page| (name.clone(), page)));
}
}
let mut open: Vec<Dictionary> = dict(catalog, b"Names")
.and_then(|n| dict(&n, b"Dests"))
.into_iter()
.collect();
for _ in 0..100_000 {
let Some(node) = open.pop() else { break };
let array = |key: &[u8]| {
node.get(key)
.ok()
.and_then(|o| resolve(doc, o).as_array().ok())
};
for pair in array(b"Names")
.map(|n| n.as_chunks::<2>().0.iter())
.into_iter()
.flatten()
{
if let (Ok(name), Some(page)) = (
resolve(doc, &pair[0]).as_str(),
destination_page(doc, &pair[1]),
) {
map.insert(name.to_vec(), page);
}
}
for kid in array(b"Kids").into_iter().flatten() {
open.extend(resolve(doc, kid).as_dict().ok().cloned());
}
}
map
}
pub fn outline(doc: &Document) -> Vec<OutlineItem> {
let mut items = Vec::new();
let first =
|dict: &Dictionary, key: &[u8]| dict.get(key).ok().and_then(|o| o.as_reference().ok());
let root = doc
.catalog()
.ok()
.and_then(|c| c.get(b"Outlines").ok())
.and_then(|o| resolve(doc, o).as_dict().ok());
let mut open: Vec<(ObjectId, usize)> = root
.and_then(|r| first(r, b"First"))
.map(|id| (id, 1))
.into_iter()
.collect();
let mut seen = HashSet::new();
let mut named: Option<HashMap<Vec<u8>, ObjectId>> = None;
while let Some((id, level)) = open.pop() {
let Ok(item) = doc.get_dictionary(id) else {
continue;
};
if !seen.insert(id) {
continue;
}
open.extend(first(item, b"Next").map(|next| (next, level)));
open.extend(first(item, b"First").map(|child| (child, level + 1)));
let target = item.get(b"Dest").ok().or_else(|| {
let action = resolve(doc, item.get(b"A").ok()?).as_dict().ok()?;
(action.get(b"S").ok()?.as_name().ok()? == b"GoTo").then(|| action.get(b"D").ok())?
});
let page = target.and_then(|t| match resolve(doc, t) {
Object::Name(name) | Object::String(name, _) => named
.get_or_insert_with(|| named_destinations(doc))
.get(name)
.copied(),
other => destination_page(doc, other),
});
let title = item
.get(b"Title")
.ok()
.and_then(|t| text(resolve(doc, t)))
.unwrap_or_default();
items.push(OutlineItem { level, title, page });
}
items
}
pub fn open_lazy(path: &Path, password: Option<&str>) -> Result<(Pdf, bool)> {
let bytes = std::fs::read(path).map_err(|e| anyhow!("cannot read {}: {e}", path.display()))?;
let encrypted = trailer_has_encrypt(&bytes);
let pdf = Pdf::new_with_password(bytes, password.unwrap_or("")).map_err(|e| match e {
LoadPdfError::Decryption(_) => anyhow!(
"{} is encrypted: {}",
path.display(),
if password.is_some() {
"wrong password"
} else {
"pass a password"
}
),
LoadPdfError::Invalid => anyhow!("cannot open {}: not a valid PDF", path.display()),
})?;
Ok((pdf, encrypted))
}
pub fn trailer_has_encrypt(bytes: &[u8]) -> bool {
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn rfind(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).rposition(|w| w == needle)
}
let tail = &bytes[bytes.len().saturating_sub(2048)..];
let Some(at) = rfind(tail, b"startxref") else {
return false;
};
let digits: String = tail[at + 9..]
.iter()
.skip_while(|b| b.is_ascii_whitespace())
.take_while(|b| b.is_ascii_digit())
.map(|&b| b as char)
.collect();
let Some(section) = digits
.parse::<usize>()
.ok()
.and_then(|offset| bytes.get(offset..))
else {
return false;
};
let trailer = if section.starts_with(b"xref") {
let start = find(section, b"trailer").unwrap_or(section.len());
let rest = §ion[start..];
&rest[..find(rest, b"startxref").unwrap_or(rest.len())]
} else {
§ion[..find(section, b"stream").unwrap_or(section.len().min(4096))]
};
find(trailer, b"/Encrypt").is_some()
}
#[cfg(test)]
mod tests {
use super::trailer_has_encrypt;
#[test]
fn detects_encrypt_in_classic_trailers_and_xref_streams() {
let classic = |trailer: &str| {
format!(
"%PDF-1.4\nxref\n0 1\n0000000000 65535 f \ntrailer\n<< {trailer} >>\nstartxref\n9\n%%EOF"
)
};
assert!(trailer_has_encrypt(
classic("/Root 1 0 R /Encrypt 5 0 R").as_bytes()
));
assert!(!trailer_has_encrypt(classic("/Root 1 0 R").as_bytes()));
let stream = |dict: &str| {
format!(
"%PDF-1.5\n7 0 obj\n<< /Type /XRef {dict} >>\nstream\n/Encrypt\nendstream\nendobj\nstartxref\n9\n%%EOF"
)
};
assert!(trailer_has_encrypt(stream("/Encrypt 5 0 R").as_bytes()));
assert!(!trailer_has_encrypt(stream("/Size 8").as_bytes()));
assert!(!trailer_has_encrypt(b"not a pdf"));
assert!(!trailer_has_encrypt(b"startxref\n99999\n%%EOF"));
}
}