use std::collections::{BTreeMap, BTreeSet, VecDeque};
use pdfrum_object::{Dict, Object, Resolve, names};
const MAX_INLINE_DEPTH: u32 = 128;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ReachCounts {
counts: BTreeMap<u32, u32>,
}
impl ReachCounts {
#[must_use]
#[cfg(test)]
pub(crate) fn reachable(&self) -> BTreeSet<u32> {
self.counts.keys().copied().collect()
}
#[must_use]
pub(crate) fn is_reachable(&self, num: u32) -> bool {
self.counts.contains_key(&num)
}
#[must_use]
#[cfg(test)]
pub(crate) fn multiply_referenced(&self) -> BTreeSet<u32> {
self.counts
.iter()
.filter(|(_, count)| **count > 1)
.map(|(num, _)| *num)
.collect()
}
}
pub(crate) fn walk(trailer: &Dict, trailer_number: u32, r: &impl Resolve) -> ReachCounts {
let mut counts: BTreeMap<u32, u32> = BTreeMap::new();
if trailer_number != 0 {
counts.insert(trailer_number, 1);
}
let mut seen_sources: BTreeSet<u32> = BTreeSet::new();
let mut visited: BTreeSet<u32> = BTreeSet::new();
let mut queue: VecDeque<(Object, u32, u32)> = VecDeque::new();
for (_, value) in trailer.iter() {
queue.push_back((value.clone(), trailer_number, 0));
}
while let Some((obj, owner, depth)) = queue.pop_front() {
if depth > MAX_INLINE_DEPTH {
continue;
}
match obj {
Object::Ref(target) => {
let Ok(resolved) = r.fetch(target) else {
continue;
};
if resolved.is_null() {
continue;
}
count_reference(&mut counts, &mut seen_sources, owner, target.num);
if visited.insert(target.num) {
queue.push_back((
Object::clone(&resolved),
target.num,
depth.saturating_add(1),
));
}
}
Object::Array(a) => {
for value in a.iter() {
queue.push_back((value.clone(), owner, depth.saturating_add(1)));
}
}
Object::Dict(d) => {
for (_, value) in d.iter() {
queue.push_back((value.clone(), owner, depth.saturating_add(1)));
}
}
Object::Stream(s) => {
for (_, value) in s.dict.iter() {
queue.push_back((value.clone(), owner, depth.saturating_add(1)));
}
}
_ => {}
}
}
ReachCounts { counts }
}
fn count_reference(
counts: &mut BTreeMap<u32, u32>,
seen_sources: &mut BTreeSet<u32>,
source: u32,
target: u32,
) {
if source == target {
return;
}
if seen_sources.contains(&source) && seen_sources.contains(&target) {
return;
}
*counts.entry(target).or_insert(0) += 1;
seen_sources.insert(source);
}
pub(crate) const SUPPRESSED_TRAILER_KEYS: [&pdfrum_object::Name; 11] = [
names::ENCRYPT,
names::SIZE,
names::FILTER,
names::INDEX,
names::LENGTH,
names::PREV,
names::W,
names::XREF_STM,
names::ID,
names::DECODE_PARMS,
names::TYPE,
];
#[cfg(test)]
mod tests {
use super::{SUPPRESSED_TRAILER_KEYS, walk};
use pdfrum_object::{Array, Dict, Name, ObjRef, Object, Resolve, names};
use std::collections::BTreeMap;
use std::sync::Arc;
struct Store(BTreeMap<u32, Object>);
impl Resolve for Store {
fn fetch(&self, r: ObjRef) -> Result<Arc<Object>, pdfrum_object::Error> {
Ok(Arc::new(
self.0.get(&r.num).cloned().unwrap_or(Object::Null),
))
}
}
fn r(num: u32) -> Object {
Object::Ref(ObjRef::new(num, 0))
}
fn dict(pairs: impl IntoIterator<Item = (&'static str, Object)>) -> Object {
Object::Dict(Dict::from_pairs(
pairs.into_iter().map(|(k, v)| (Name::from(k), v)),
))
}
fn hello_world() -> (Dict, Store) {
let store = Store(BTreeMap::from([
(
1,
dict([
("Type", Object::Name(Name::from("Catalog"))),
("Pages", r(2)),
]),
),
(
2,
dict([
("Type", Object::Name(Name::from("Pages"))),
("Kids", Object::Array(Array::of([r(3)]))),
]),
),
(
3,
dict([
("Type", Object::Name(Name::from("Page"))),
("Contents", r(4)),
("Resources", dict([("Font", dict([("F1", r(5))]))])),
]),
),
(4, Object::Int(0)),
(
5,
dict([
("Type", Object::Name(Name::from("Font"))),
("FontDescriptor", r(6)),
]),
),
(
6,
dict([("Type", Object::Name(Name::from("FontDescriptor")))]),
),
]));
let trailer = Dict::from_pairs([(names::ROOT.clone(), r(1))]);
(trailer, store)
}
#[test]
fn hello_world_reaches_all_six() {
let (trailer, store) = hello_world();
let counts = walk(&trailer, 0, &store);
assert_eq!(counts.reachable(), [1, 2, 3, 4, 5, 6].into());
assert!(counts.multiply_referenced().is_empty());
}
#[test]
fn a_new_empty_document_reaches_its_catalog_and_pages() {
let store = Store(BTreeMap::from([
(
1,
dict([
("Type", Object::Name(Name::from("Catalog"))),
("Pages", r(2)),
]),
),
(
2,
dict([
("Type", Object::Name(Name::from("Pages"))),
("Kids", Object::Array(Array::new())),
]),
),
]));
let trailer = Dict::from_pairs([(names::ROOT.clone(), r(1))]);
assert_eq!(walk(&trailer, 0, &store).reachable(), [1, 2].into());
}
#[test]
fn a_circular_viewer_reference_reaches_only_the_catalog() {
let store = Store(BTreeMap::from([(
1,
dict([
("Type", Object::Name(Name::from("Catalog"))),
("ViewerPreferences", r(1)),
]),
)]));
let trailer = Dict::from_pairs([(names::ROOT.clone(), r(1))]);
let counts = walk(&trailer, 0, &store);
assert_eq!(counts.reachable(), [1].into());
assert!(counts.multiply_referenced().is_empty());
}
#[test]
fn an_unreferenced_object_is_not_reached() {
let (trailer, mut store) = hello_world();
store.0.insert(99, Object::Int(1));
assert!(!walk(&trailer, 0, &store).is_reachable(99));
}
#[test]
fn an_xref_stream_trailer_seeds_its_own_number() {
let (trailer, store) = hello_world();
let counts = walk(&trailer, 16, &store);
assert!(counts.is_reachable(16), "the trailer object survives");
assert_eq!(counts.reachable(), [1, 2, 3, 4, 5, 6, 16].into());
}
#[test]
fn two_pages_naming_one_stream_report_it_as_shared() {
let page = |contents: u32| {
dict([
("Type", Object::Name(Name::from("Page"))),
("Contents", r(contents)),
])
};
let store = Store(BTreeMap::from([
(1, dict([("Pages", r(2))])),
(2, dict([("Kids", Object::Array(Array::of([r(3), r(4)])))])),
(3, page(5)),
(4, page(5)),
(5, Object::Int(0)),
]));
let trailer = Dict::from_pairs([(names::ROOT.clone(), r(1))]);
let counts = walk(&trailer, 0, &store);
let shared = counts.multiply_referenced();
assert!(shared.contains(&5), "both pages name the same content");
assert!(!shared.contains(&3));
assert!(!shared.contains(&4));
}
#[test]
fn two_pages_sharing_a_font_report_the_whole_chain_as_shared() {
let page = |contents: u32| {
dict([
("Type", Object::Name(Name::from("Page"))),
("Contents", r(contents)),
("Resources", dict([("Font", dict([("F1", r(6))]))])),
])
};
let store = Store(BTreeMap::from([
(1, dict([("Pages", r(2))])),
(2, dict([("Kids", Object::Array(Array::of([r(3), r(4)])))])),
(3, page(5)),
(4, page(5)),
(5, Object::Int(0)),
(6, dict([("FontDescriptor", r(7))])),
(
7,
dict([("Type", Object::Name(Name::from("FontDescriptor")))]),
),
]));
let trailer = Dict::from_pairs([
(names::ROOT.clone(), r(1)),
(Name::from("Extra"), r(7)),
]);
let counts = walk(&trailer, 0, &store);
assert_eq!(counts.reachable(), [1, 2, 3, 4, 5, 6, 7].into());
assert_eq!(counts.multiply_referenced(), [5, 6, 7].into());
}
#[test]
fn a_dangling_reference_reaches_nothing() {
let store = Store(BTreeMap::from([(1, dict([("Missing", r(42))]))]));
let trailer = Dict::from_pairs([(names::ROOT.clone(), r(1))]);
let counts = walk(&trailer, 0, &store);
assert_eq!(counts.reachable(), [1].into());
}
#[test]
fn a_stream_dictionary_is_walked() {
let stream = Object::Stream(Box::new(pdfrum_object::Stream::new(
Dict::from_pairs([(Name::from("Ref"), r(9))]),
pdfrum_object::ByteSpan::empty(),
)));
let store = Store(BTreeMap::from([
(1, dict([("S", r(2))])),
(2, stream),
(9, Object::Int(1)),
]));
let trailer = Dict::from_pairs([(names::ROOT.clone(), r(1))]);
assert_eq!(walk(&trailer, 0, &store).reachable(), [1, 2, 9].into());
}
#[test]
fn a_cycle_terminates() {
let store = Store(BTreeMap::from([
(1, dict([("Next", r(2))])),
(2, dict([("Next", r(3))])),
(3, dict([("Back", r(1))])),
]));
let trailer = Dict::from_pairs([(names::ROOT.clone(), r(1))]);
assert_eq!(walk(&trailer, 0, &store).reachable(), [1, 2, 3].into());
}
#[test]
fn the_suppression_list_is_exactly_eleven_keys() {
let spelled: Vec<&str> = SUPPRESSED_TRAILER_KEYS
.iter()
.filter_map(|n| n.as_str())
.collect();
assert_eq!(
spelled,
vec![
"Encrypt",
"Size",
"Filter",
"Index",
"Length",
"Prev",
"W",
"XRefStm",
"ID",
"DecodeParms",
"Type",
]
);
}
}