use std::collections::{HashMap, HashSet};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result, anyhow, bail};
use hayro::hayro_syntax::object::{
Dict, FromBytes, MaybeRef, Object as LazyObject, ObjectIdentifier, Stream as LazyStream,
};
use hayro::hayro_syntax::{Filter, 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> {
Ok(load_noting(path, password)?.0)
}
pub fn load_noting(path: &Path, password: Option<&str>) -> Result<(Document, bool)> {
let bytes =
Arc::new(std::fs::read(path).map_err(|e| anyhow!("cannot read {}: {e}", path.display()))?);
let strict = parse(&bytes, path, password);
let encrypted = trailer_has_encrypt(&bytes);
let (reason, state) = match strict {
Ok(doc) => match damage(&doc, bytes.clone(), password) {
None => return Ok((doc, false)),
Some(reason) => (reason, doc.encryption_state),
},
Err(e) => {
let state = match encrypted.then(|| stated_encryption(&bytes)) {
None => None,
Some(Err(_)) => return Err(e),
Some(Ok(stated)) => {
match lopdf::EncryptionState::decode(&stated, password.unwrap_or("")) {
Ok(state) => Some(state),
Err(_) => return Err(locked(path, password)),
}
}
};
match rebuild(bytes.clone(), password, state) {
Ok(doc) => return Ok((note_repaired(path, doc), true)),
Err(_) => return Err(e),
}
}
};
let rebuilt = match (encrypted, state) {
(true, None) => Err(anyhow!("the key of its encryption could not be worked out")),
(_, state) => rebuild(bytes, password, state),
};
match rebuilt {
Ok(doc) => Ok((note_repaired(path, doc), true)),
Err(why) => bail!(
"{} is damaged ({reason}) and could not be rebuilt ({why}). It can be read but not rewritten safely; repair it first, e.g. with `qpdf in.pdf repaired.pdf`",
path.display()
),
}
}
static REPAIRED: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
fn note_repaired(path: &Path, doc: Document) -> Document {
let mut repaired = REPAIRED.lock().unwrap_or_else(|e| e.into_inner());
if !repaired.iter().any(|p| p == path) {
repaired.push(path.to_path_buf());
}
doc
}
pub fn repaired_inputs() -> Vec<PathBuf> {
REPAIRED.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn read(path: &Path, password: Option<&str>) -> Result<(Document, Arc<Vec<u8>>)> {
let bytes =
Arc::new(std::fs::read(path).map_err(|e| anyhow!("cannot read {}: {e}", path.display()))?);
Ok((parse(&bytes, path, password)?, bytes))
}
fn parse(bytes: &[u8], path: &Path, password: Option<&str>) -> Result<Document> {
let doc = match password {
Some(p) => Document::load_mem_with_options(bytes, lopdf::LoadOptions::with_password(p)),
None => Document::load_mem(bytes),
}
.map_err(|e| anyhow!("cannot open {}: {e}", path.display()))?;
if doc.is_encrypted() {
return Err(locked(path, password));
}
Ok(doc)
}
fn locked(path: &Path, password: Option<&str>) -> anyhow::Error {
anyhow!(
"{} is encrypted: {}",
path.display(),
if password.is_some() {
"wrong password"
} else {
"pass a password"
}
)
}
fn convert(object: &LazyObject<'_>, open: &mut Vec<ObjectId>, depth: u32) -> Result<Object> {
if depth > 200 {
bail!("objects are nested too deeply");
}
let item = |item: MaybeRef<LazyObject<'_>>, open: &mut Vec<ObjectId>| match item {
MaybeRef::Ref(r) => {
match (u32::try_from(r.obj_number), u16::try_from(r.gen_number)) {
(Ok(number), Ok(generation)) if number > 0 => {
open.push((number, generation));
Ok(Object::Reference((number, generation)))
}
_ => Ok(Object::Null),
}
}
MaybeRef::NotRef(inner) => convert(&inner, open, depth + 1),
};
let dictionary = |dict: &Dict<'_>, open: &mut Vec<ObjectId>| -> Result<Dictionary> {
let mut out = Dictionary::new();
for (key, value) in dict.entries() {
out.set(key.to_vec(), item(value, open)?);
}
Ok(out)
};
Ok(match object {
LazyObject::Null(_) => Object::Null,
LazyObject::Boolean(b) => Object::Boolean(*b),
LazyObject::Number(n) => {
let value = n.as_f64();
if value.fract() == 0.0 && value.abs() < 9.0e15 {
Object::Integer(value as i64)
} else {
Object::Real(value as f32)
}
}
LazyObject::String(s) => Object::String(s.to_vec(), lopdf::StringFormat::Literal),
LazyObject::Name(n) => Object::Name(n.to_vec()),
LazyObject::Dict(dict) => Object::Dictionary(dictionary(dict, open)?),
LazyObject::Array(array) => Object::Array(
array
.raw_iter()
.map(|i| item(i, open))
.collect::<Result<_>>()?,
),
LazyObject::Stream(stream) => {
let dict = dictionary(stream.dict(), open)?;
Object::Stream(lopdf::Stream::new(dict, stream.raw_data().into_owned()))
}
})
}
pub(crate) fn direct_dictionary(dict: &Dict<'_>) -> Result<Dictionary> {
match convert(&LazyObject::Dict(dict.clone()), &mut Vec::new(), 0)? {
Object::Dictionary(dict) => Ok(dict),
_ => bail!("not a dictionary"),
}
}
fn stated_encryption(bytes: &[u8]) -> Result<Document> {
let last = |pattern: &str| {
let pattern = regex::bytes::Regex::new(pattern).expect("the pattern is valid");
pattern.find_iter(bytes).last().map(|found| found.end())
};
let direct = |from: usize| -> Result<Object> {
let lazy = LazyObject::from_bytes(&bytes[from..])
.ok_or_else(|| anyhow!("the encryption dictionary cannot be read"))?;
convert(&lazy, &mut Vec::new(), 0)
};
let named = regex::bytes::Regex::new(r"(?-u)/Encrypt\s+(\d{1,10})\s+(\d{1,5})\s+R")
.expect("the pattern is valid");
let dictionary = match named.captures_iter(bytes).last() {
Some(found) => {
let part = |i: usize| String::from_utf8_lossy(&found[i]).into_owned();
let object = format!(r"(?-u)(?:^|[^0-9]){}\s+{}\s+obj\s*", part(1), part(2));
direct(last(&object).ok_or_else(|| anyhow!("no encryption dictionary"))?)?
}
None => {
direct(last(r"(?-u)/Encrypt\s*").ok_or_else(|| anyhow!("no encryption dictionary"))?)?
}
};
if dictionary.as_dict().is_err() {
bail!("the encryption dictionary cannot be read");
}
let mut doc = Document::new();
let id = doc.add_object(dictionary);
doc.trailer.set("Encrypt", id);
if let Some(identifier) = last(r"(?-u)/ID\s*").and_then(|at| direct(at).ok()) {
doc.trailer.set("ID", identifier);
}
Ok(doc)
}
fn rebuild(
bytes: Arc<Vec<u8>>,
password: Option<&str>,
state: Option<lopdf::EncryptionState>,
) -> Result<Document> {
let pdf = Pdf::new_with_password(bytes.clone(), password.unwrap_or(""))
.map_err(|_| anyhow!("it has no readable structure"))?;
let xref = pdf.xref();
let key = |id: ObjectIdentifier| -> Option<ObjectId> {
let number = u32::try_from(id.obj_number).ok().filter(|n| *n > 0)?;
Some((number, u16::try_from(id.gen_number).ok()?))
};
let pages: Vec<ObjectId> = pdf
.pages()
.iter()
.map(|page| page.raw().obj_id().and_then(key))
.collect::<Option<_>>()
.ok_or_else(|| anyhow!("a page is not an object of its own"))?;
if pages.is_empty() {
bail!("no page could be read");
}
let root = key(xref.root_id()).ok_or_else(|| anyhow!("it has no catalog"))?;
let info = info_reference(&bytes);
let mut doc = Document::with_version("1.7");
let mut open: Vec<ObjectId> = pages.clone();
open.push(root);
open.extend(info);
while let Some(id) = open.pop() {
if doc.objects.contains_key(&id) {
continue;
}
if doc.objects.len() > 5_000_000 {
bail!("it has too many objects");
}
let lazy = xref.get::<LazyObject<'_>>(ObjectIdentifier::new(id.0 as i32, id.1 as i32));
if let Some(lazy) = lazy {
let mut object = convert(&lazy, &mut open, 0)?;
if let (LazyObject::Stream(source), Object::Stream(stream)) = (&lazy, &mut object) {
let packed = source.filters().iter().all(|f| {
matches!(
f,
Filter::FlateDecode
| Filter::LzwDecode
| Filter::AsciiHexDecode
| Filter::Ascii85Decode
| Filter::RunLengthDecode
)
});
let strict = || {
use std::io::Read;
let mut sink = Vec::new();
flate2::read::ZlibDecoder::new(&stream.content[..])
.read_to_end(&mut sink)
.is_ok()
};
let unsound = match source.filters().as_slice() {
[] => false,
[Filter::FlateDecode] => !strict(),
_ => packed && source.decoded().is_err(),
};
if unsound {
let data = source.decoded().map(|d| d.into_owned()).unwrap_or_default();
stream.dict.remove(b"Filter");
stream.dict.remove(b"DecodeParms");
stream.set_content(data);
let _ = stream.compress();
}
}
doc.objects.insert(id, object);
}
}
if doc.get_dictionary(root).is_err() {
bail!("its catalog cannot be read");
}
doc.max_id = doc.objects.keys().map(|id| id.0).max().unwrap_or(0);
let mut own: Vec<Vec<(&[u8], Object)>> = Vec::with_capacity(pages.len());
for &page in &pages {
if doc.get_dictionary(page).is_err() {
bail!("a page cannot be read");
}
own.push(
INHERITABLE
.iter()
.filter_map(|key| Some((*key, inherited(&doc, page, key)?.clone())))
.collect(),
);
}
let drawable = |object: &Object| {
object.as_reference().is_ok_and(|id| {
xref.get::<LazyStream<'_>>(ObjectIdentifier::new(id.0 as i32, id.1 as i32))
.is_some_and(|stream| stream.decoded().is_ok())
})
};
let lists: HashMap<ObjectId, Vec<Object>> = doc
.objects
.iter()
.filter_map(|(id, object)| Some((*id, object.as_array().ok()?.clone())))
.collect();
let tree = doc.new_object_id();
for (&page, attributes) in pages.iter().zip(own) {
let dict = doc.get_dictionary_mut(page)?;
for (key, value) in attributes {
dict.set(key, value);
}
if !dict.has(b"MediaBox") {
dict.set(
"MediaBox",
vec![0.into(), 0.into(), 612.into(), 792.into()] as Vec<Object>,
);
}
let listed = |object: &Object| lists.get(&object.as_reference().ok()?);
let contents = match dict.get(b"Contents").ok() {
Some(one) if listed(one).is_some() => Some(Object::Array(
listed(one)
.into_iter()
.flatten()
.filter(|p| drawable(p))
.cloned()
.collect(),
)),
Some(Object::Array(parts)) => Some(Object::Array(
parts.iter().filter(|p| drawable(p)).cloned().collect(),
)),
Some(one) if drawable(one) => Some(one.clone()),
_ => None,
};
match contents {
Some(contents) => dict.set("Contents", contents),
None => {
dict.remove(b"Contents");
}
}
dict.set("Type", Object::Name(b"Page".to_vec()));
dict.set("Parent", tree);
}
let mut node = Dictionary::new();
node.set("Type", Object::Name(b"Pages".to_vec()));
node.set("Count", pages.len() as i64);
node.set(
"Kids",
pages
.iter()
.map(|&id| Object::Reference(id))
.collect::<Vec<_>>(),
);
doc.objects.insert(tree, Object::Dictionary(node));
let catalog = doc.get_dictionary_mut(root)?;
catalog.set("Type", Object::Name(b"Catalog".to_vec()));
catalog.set("Pages", tree);
doc.trailer.set("Root", root);
if let Some(info) = info.filter(|id| doc.get_dictionary(*id).is_ok()) {
doc.trailer.set("Info", info);
}
crate::ops::edit::prune(&mut doc);
if doc.get_pages().len() != pages.len() {
bail!("its pages cannot be laid out again");
}
if state.is_some() {
let identifier = regex::bytes::Regex::new(r"(?-u)/ID\s*")
.expect("the pattern is valid")
.find_iter(&bytes)
.last()
.and_then(|found| LazyObject::from_bytes(&bytes[found.end()..]))
.and_then(|lazy| convert(&lazy, &mut Vec::new(), 0).ok());
if let Some(identifier) = identifier {
doc.trailer.set("ID", identifier);
}
doc.encryption_state = state;
}
Ok(doc)
}
fn info_reference(bytes: &[u8]) -> Option<ObjectId> {
let pattern = regex::bytes::Regex::new(r"(?-u)/Info\s+(\d{1,10})\s+(\d{1,5})\s+R")
.expect("the pattern is valid");
let found = pattern.captures_iter(bytes).last()?;
let number = |i: usize| std::str::from_utf8(&found[i]).ok()?.parse::<u32>().ok();
Some((
number(1).filter(|n| *n > 0)?,
u16::try_from(number(2)?).ok()?,
))
}
fn damage(
doc: &Document,
bytes: std::sync::Arc<Vec<u8>>,
password: Option<&str>,
) -> Option<String> {
let pages = doc.get_pages();
let pdf = Pdf::new_with_password(bytes, password.unwrap_or("")).ok();
let seen = pdf.as_ref().map(|pdf| pdf.pages().len());
if pages.is_empty() {
return Some("its page tree could not be read".to_string());
}
if seen.is_some_and(|n| n != pages.len()) {
return Some(format!(
"{} of its {} pages could be read",
pages.len(),
seen.unwrap_or(0)
));
}
for (number, id) in pages {
let Ok(page) = doc.get_dictionary(id) else {
return Some(format!("page {number} is missing"));
};
for key in [&b"Contents"[..], b"Resources"] {
let target = page.get(key).ok();
let missing = |o: &Object| o.as_reference().is_ok_and(|r| !doc.has_object(r));
let lost = target.is_some_and(|o| match o {
Object::Array(items) => items.iter().any(missing),
other => missing(other),
});
if lost {
return Some(format!("objects of page {number} are missing"));
}
}
}
let unsized_stream = doc
.objects
.values()
.any(|o| o.as_stream().is_ok_and(|s| !s.dict.has(b"Length")));
let emptied = || {
let pdf = pdf.as_ref().filter(|_| doc.encryption_state.is_some());
pdf.is_some_and(|pdf| {
doc.objects.iter().any(|(id, object)| {
object.as_stream().is_ok_and(|s| s.content.is_empty())
&& pdf
.xref()
.get::<LazyStream<'_>>(ObjectIdentifier::new(id.0 as i32, id.1 as i32))
.is_some_and(|stream| !stream.raw_data().is_empty())
})
})
};
(unsized_stream || emptied()).then(|| "a stream has no length".to_string())
}
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);
seal(doc)?;
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 save_unless(dry_run: bool, doc: &mut Document, path: &Path) -> Result<u64> {
if !dry_run {
return save(doc, path);
}
seal(doc)?;
let mut bytes = Vec::new();
doc.save_to(&mut bytes)?;
Ok(bytes.len() as u64)
}
pub fn seal(doc: &mut Document) -> Result<bool> {
let highest = |doc: &Document| doc.objects.keys().map(|id| id.0).max().unwrap_or(0);
if highest(doc) as usize > doc.objects.len() * 2 + 100 {
doc.renumber_objects();
}
let encrypted = protect_again(doc)?;
doc.max_id = highest(doc);
Ok(encrypted)
}
pub fn ensure_id(doc: &mut Document) -> Result<()> {
let usable = doc
.trailer
.get(b"ID")
.ok()
.and_then(|id| id.as_array().ok())
.is_some_and(|parts| {
parts.len() == 2
&& parts
.iter()
.all(|p| p.as_str().is_ok_and(|s| !s.is_empty()))
});
if !usable {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|e| anyhow!("no system randomness: {e}"))?;
let part = Object::String(bytes.to_vec(), lopdf::StringFormat::Hexadecimal);
doc.trailer.set("ID", vec![part.clone(), part]);
}
Ok(())
}
fn protect_again(doc: &mut Document) -> Result<bool> {
let Some(state) = doc.encryption_state.clone().filter(|_| !doc.is_encrypted()) else {
return Ok(doc.is_encrypted());
};
doc.encrypt(&state)
.map_err(|e| anyhow!("cannot keep the document's encryption: {e}"))?;
let dict = doc
.get_encrypted()
.map_err(|e| anyhow!("cannot keep the document's encryption: {e}"))?;
let version = dict
.get(b"V")
.ok()
.and_then(|v| v.as_i64().ok())
.unwrap_or(0);
let defined = |key: &[u8]| {
let name = dict
.get(key)
.ok()
.and_then(|n| n.as_name().ok())
.unwrap_or(b"Identity");
name == b"Identity"
|| dict
.get(b"CF")
.ok()
.and_then(|cf| cf.as_dict().ok())
.is_some_and(|cf| cf.has(name))
};
if version >= 4 && !(defined(b"StmF") && defined(b"StrF")) {
bail!(
"this document's encryption cannot be reproduced; remove it first with the decrypt command"
);
}
Ok(true)
}
pub(crate) fn text_string(bytes: &[u8]) -> String {
match bytes {
[0xFE, 0xFF, rest @ ..] => {
let units: Vec<u16> = rest
.as_chunks::<2>()
.0
.iter()
.map(|p| u16::from_be_bytes(*p))
.collect();
String::from_utf16_lossy(&units)
}
_ => bytes.iter().map(|&b| b as char).collect(),
}
}
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>,
pub view: Vec<Object>,
}
fn destination(doc: &Document, dest: &Object) -> Option<(ObjectId, Vec<Object>)> {
let array = match resolve(doc, dest) {
Object::Array(a) => a,
Object::Dictionary(d) => resolve(doc, d.get(b"D").ok()?).as_array().ok()?,
_ => return None,
};
let page = array.first()?.as_reference().ok()?;
let view = array[1..].iter().map(|o| resolve(doc, o).clone()).collect();
Some((page, view))
}
pub fn named_destinations(doc: &Document) -> HashMap<Vec<u8>, (ObjectId, Vec<Object>)> {
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(doc, dest).map(|to| (name.clone(), to)));
}
}
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(to)) =
(resolve(doc, &pair[0]).as_str(), destination(doc, &pair[1]))
{
map.insert(name.to_vec(), to);
}
}
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 = 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 to = target.and_then(|t| match resolve(doc, t) {
Object::Name(name) | Object::String(name, _) => named
.get_or_insert_with(|| named_destinations(doc))
.get(name)
.cloned(),
other => destination(doc, other),
});
let (page, view) = to.map_or((None, Vec::new()), |(page, view)| (Some(page), view));
let title = item
.get(b"Title")
.ok()
.and_then(|t| text(resolve(doc, t)))
.unwrap_or_default();
items.push(OutlineItem {
level,
title,
page,
view,
});
}
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"));
}
}