use std::collections::{BTreeMap, BTreeSet};
use pdfrum_object::{Dict, Name, ObjRef, Object};
pub(crate) const CATEGORIES: [&str; 3] = ["ExtGState", "Font", "XObject"];
#[derive(Debug, Clone, Default)]
pub struct ResourceTable {
entries: BTreeMap<String, Dict>,
parked: BTreeMap<String, BTreeMap<Name, Object>>,
}
impl ResourceTable {
#[must_use]
pub fn load(resources: &Dict, r: &impl pdfrum_object::Resolve) -> Self {
let mut entries = BTreeMap::new();
for category in CATEGORIES {
let key = Name::from(category);
if let Some(dict) = resources.dict(&key, r) {
entries.insert(category.to_owned(), dict);
}
}
Self {
entries,
parked: BTreeMap::new(),
}
}
pub fn realize(&mut self, category: &str, object: ObjRef) -> Name {
let dict = self.entries.entry(category.to_owned()).or_default();
for (name, held) in dict.iter() {
if held.as_ref_id() == Some(object) {
return name.clone();
}
}
let name = self.free_name(category);
let dict = self.entries.entry(category.to_owned()).or_default();
dict.push(name.clone(), Object::Ref(object));
name
}
#[must_use]
pub fn name_of(&self, category: &str, object: ObjRef) -> Option<Name> {
self.entries
.get(category)?
.iter()
.find(|(_, held)| held.as_ref_id() == Some(object))
.map(|(name, _)| name.clone())
}
#[must_use]
pub fn name_of_dict(&self, category: &str, value: &Dict) -> Option<Name> {
self.entries
.get(category)?
.iter()
.find(|(_, held)| matches!(held, Object::Dict(d) if d == value))
.map(|(name, _)| name.clone())
}
pub fn realize_dict(&mut self, category: &str, value: &Dict) -> Name {
let existing = self
.entries
.get(category)
.and_then(|dict| {
dict.iter().find(|(_, held)| match held {
Object::Dict(d) => d == value,
_ => false,
})
})
.map(|(name, _)| name.clone());
existing.unwrap_or_else(|| {
let name = self.free_name(category);
let dict = self.entries.entry(category.to_owned()).or_default();
dict.push(name.clone(), Object::Dict(value.clone()));
name
})
}
fn free_name(&self, category: &str) -> Name {
let letter = category.chars().next().unwrap_or('X');
let live = self.entries.get(category);
let parked = self.parked.get(category);
for id in 1u32.. {
let candidate = Name::from(format!("FX{letter}{id}").as_str());
let taken = live.is_some_and(|d| d.contains_key(&candidate))
|| parked.is_some_and(|p| p.contains_key(&candidate));
if !taken {
return candidate;
}
}
Name::from("FXX1")
}
pub fn sweep(&mut self, used: &BTreeMap<String, BTreeSet<Name>>) {
for category in CATEGORIES {
let wanted: BTreeSet<Name> =
used.get(category).into_iter().flatten().cloned().collect();
if let Some(dict) = self.entries.get_mut(category) {
let mut kept = Dict::new();
let parked = self.parked.entry(category.to_owned()).or_default();
for (name, value) in dict.iter() {
if wanted.contains(name) {
kept.push(name.clone(), value.clone());
} else {
parked.insert(name.clone(), value.clone());
}
}
*dict = kept;
}
let restorable: Vec<(Name, Object)> = self
.parked
.get(category)
.into_iter()
.flatten()
.filter(|(name, _)| wanted.contains(*name))
.map(|(name, value)| (name.clone(), value.clone()))
.collect();
if restorable.is_empty() {
continue;
}
let dict = self.entries.entry(category.to_owned()).or_default();
let parked = self.parked.entry(category.to_owned()).or_default();
for (name, value) in restorable {
if !dict.contains_key(&name) {
dict.push(name.clone(), value);
}
parked.remove(&name);
}
}
}
#[must_use]
pub fn to_dict(&self, base: &Dict) -> Dict {
let mut out = Dict::new();
for (key, value) in base.iter() {
if CATEGORIES.iter().any(|c| key.as_bytes() == c.as_bytes()) {
continue;
}
out.push(key.clone(), value.clone());
}
for category in CATEGORIES {
let Some(dict) = self.entries.get(category) else {
continue;
};
if dict.is_empty() {
continue;
}
out.push(Name::from(category), Object::Dict(dict.clone()));
}
out
}
}
#[cfg(test)]
mod tests {
use super::{CATEGORIES, ResourceTable};
use pdfrum_object::{Dict, Name, NoResolve, ObjRef, Object};
use std::collections::{BTreeMap, BTreeSet};
fn used(pairs: &[(&str, &[&str])]) -> BTreeMap<String, BTreeSet<Name>> {
pairs
.iter()
.map(|(category, names)| {
(
(*category).to_owned(),
names.iter().map(|n| Name::from(*n)).collect(),
)
})
.collect()
}
fn names_of(table: &ResourceTable, category: &str) -> Vec<String> {
let dict = table.to_dict(&Dict::new());
let Some(Object::Dict(sub)) = dict.raw(&Name::from(category)) else {
return Vec::new();
};
sub.keys()
.map(|k| String::from_utf8_lossy(k.as_bytes()).into_owned())
.collect()
}
#[test]
fn exactly_three_categories_are_maintained() {
assert_eq!(CATEGORIES, ["ExtGState", "Font", "XObject"]);
}
#[test]
fn a_fresh_name_is_fx_plus_the_categorys_letter_plus_one() {
let mut table = ResourceTable::default();
assert_eq!(table.realize("Font", ObjRef::new(4, 0)), Name::from("FXF1"));
assert_eq!(
table.realize("XObject", ObjRef::new(5, 0)),
Name::from("FXX1")
);
assert_eq!(
table.realize("ExtGState", ObjRef::new(6, 0)),
Name::from("FXE1")
);
}
#[test]
fn successive_objects_take_successive_numbers() {
let mut table = ResourceTable::default();
let first = table.realize("XObject", ObjRef::new(1, 0));
let second = table.realize("XObject", ObjRef::new(2, 0));
assert_eq!(first, Name::from("FXX1"));
assert_eq!(second, Name::from("FXX2"));
}
#[test]
fn an_object_already_in_the_dictionary_keeps_its_name() {
let existing = Dict::from_pairs([(Name::from("F1"), Object::Ref(ObjRef::new(9, 0)))]);
let resources = Dict::from_pairs([(Name::from("Font"), Object::Dict(existing))]);
let mut table = ResourceTable::load(&resources, &NoResolve);
assert_eq!(table.realize("Font", ObjRef::new(9, 0)), Name::from("F1"));
assert_eq!(names_of(&table, "Font"), vec!["F1".to_owned()]);
}
#[test]
fn an_occupied_name_is_skipped() {
let existing = Dict::from_pairs([(Name::from("FXF1"), Object::Int(0))]);
let resources = Dict::from_pairs([(Name::from("Font"), Object::Dict(existing))]);
let mut table = ResourceTable::load(&resources, &NoResolve);
assert_eq!(table.realize("Font", ObjRef::new(3, 0)), Name::from("FXF2"));
}
#[test]
fn a_swept_name_is_parked_and_still_reserved() {
let mut table = ResourceTable::default();
let first = table.realize("ExtGState", ObjRef::new(1, 0));
let second = table.realize("ExtGState", ObjRef::new(2, 0));
assert_eq!(
(first.clone(), second.clone()),
(Name::from("FXE1"), Name::from("FXE2"))
);
table.sweep(&used(&[("ExtGState", &["FXE1"])]));
assert_eq!(names_of(&table, "ExtGState"), vec!["FXE1".to_owned()]);
let third = table.realize("ExtGState", ObjRef::new(3, 0));
assert_eq!(third, Name::from("FXE3"));
}
#[test]
fn a_parked_entry_wanted_again_is_restored() {
let existing = Dict::from_pairs([
(Name::from("A"), Object::Int(1)),
(Name::from("B"), Object::Int(2)),
]);
let resources = Dict::from_pairs([(Name::from("Font"), Object::Dict(existing))]);
let mut table = ResourceTable::load(&resources, &NoResolve);
table.sweep(&used(&[("Font", &["A"])]));
assert_eq!(names_of(&table, "Font"), vec!["A".to_owned()]);
table.sweep(&used(&[("Font", &["A", "B"])]));
assert_eq!(
names_of(&table, "Font"),
vec!["A".to_owned(), "B".to_owned()]
);
}
#[test]
fn an_emptied_category_loses_its_key() {
let existing = Dict::from_pairs([(Name::from("F1"), Object::Int(1))]);
let resources = Dict::from_pairs([(Name::from("Font"), Object::Dict(existing))]);
let mut table = ResourceTable::load(&resources, &NoResolve);
table.sweep(&BTreeMap::new());
let out = table.to_dict(&Dict::new());
assert!(!out.contains_key(&Name::from("Font")));
}
#[test]
fn an_unmaintained_category_is_carried_through() {
let base = Dict::from_pairs([
(Name::from("ColorSpace"), Object::Int(7)),
(Name::from("Font"), Object::Int(0)),
]);
let table = ResourceTable::default();
let out = table.to_dict(&base);
assert_eq!(out.raw(&Name::from("ColorSpace")), Some(&Object::Int(7)));
assert!(!out.contains_key(&Name::from("Font")));
}
#[test]
fn an_equal_direct_dictionary_is_reused() {
let mut table = ResourceTable::default();
let gs = Dict::from_pairs([(Name::from("ca"), Object::Real(0.5))]);
let first = table.realize_dict("ExtGState", &gs);
let second = table.realize_dict("ExtGState", &gs);
assert_eq!(first, second);
assert_eq!(names_of(&table, "ExtGState"), vec!["FXE1".to_owned()]);
}
#[test]
fn a_name_the_caller_did_not_list_is_swept() {
let mut table = ResourceTable::default();
let _ = table.realize("XObject", ObjRef::new(1, 0));
table.sweep(&BTreeMap::new());
assert!(names_of(&table, "XObject").is_empty());
}
}