use super::ImageData;
use pdfrum_object::ObjRef;
use std::collections::HashMap;
use std::sync::Arc;
pub const MAX_ENTRIES: usize = 15;
pub const MAX_BYTES: usize = 100 << 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum RequestedSize {
#[default]
Full,
Reduced {
width: u32,
height: u32,
},
NoSamples,
}
impl RequestedSize {
#[must_use]
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the finite and >= 1.0 guard runs before the cast"
)]
pub fn for_device(width: f64, height: f64) -> Self {
let (w, h) = (width.trunc(), height.trunc());
if !w.is_finite() || !h.is_finite() || w < 1.0 || h < 1.0 {
return Self::Full;
}
Self::Reduced {
width: w as u32,
height: h as u32,
}
}
#[must_use]
pub fn levels(self, width: u32, height: u32) -> u8 {
let Self::Reduced {
width: max_w,
height: max_h,
} = self
else {
return 0;
};
if max_w == 0 || max_h == 0 {
return 0;
}
let ratio = (width / max_w).min(height / max_h).max(1);
#[expect(
clippy::cast_possible_truncation,
reason = "a u32's base-two logarithm never exceeds 31"
)]
let levels = ratio.ilog2() as u8;
levels
}
#[must_use]
pub fn satisfies(self, wanted: Self, cached_width: u32, cached_height: u32) -> bool {
match self {
Self::Full => true,
Self::Reduced { .. } => match wanted {
Self::Full | Self::NoSamples => false,
Self::Reduced { width, height } => cached_width >= width && cached_height >= height,
},
Self::NoSamples => false,
}
}
}
#[derive(Debug, Default)]
pub struct ImageCache {
entries: HashMap<(ObjRef, RequestedSize), Entry>,
tick: u64,
bytes: usize,
}
#[derive(Debug)]
struct Entry {
image: Arc<ImageData>,
last_used: u64,
bytes: usize,
}
impl ImageCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn bytes(&self) -> usize {
self.bytes
}
pub fn get(&mut self, key: ObjRef, size: RequestedSize) -> Option<Arc<ImageData>> {
self.tick = self.tick.saturating_add(1);
let tick = self.tick;
if let Some(entry) = self.entries.get_mut(&(key, size)) {
entry.last_used = tick;
return Some(Arc::clone(&entry.image));
}
if size != RequestedSize::Full
&& let Some(entry) = self.entries.get_mut(&(key, RequestedSize::Full))
{
entry.last_used = tick;
return Some(Arc::clone(&entry.image));
}
let found = self.entries.iter().find_map(|((object, stored), entry)| {
(*object == key && stored.satisfies(size, entry.image.width, entry.image.height))
.then_some(*stored)
})?;
let entry = self.entries.get_mut(&(key, found))?;
entry.last_used = tick;
Some(Arc::clone(&entry.image))
}
pub fn insert(&mut self, key: ObjRef, size: RequestedSize, image: Arc<ImageData>) {
self.tick = self.tick.saturating_add(1);
let bytes = image.byte_size();
if let Some(old) = self.entries.insert(
(key, size),
Entry {
image,
last_used: self.tick,
bytes,
},
) {
self.bytes = self.bytes.saturating_sub(old.bytes);
}
self.bytes = self.bytes.saturating_add(bytes);
self.evict();
}
pub fn clear(&mut self) {
self.entries.clear();
self.bytes = 0;
}
fn evict(&mut self) {
if self.entries.len() <= MAX_ENTRIES && self.bytes <= MAX_BYTES {
return;
}
let mut order: Vec<_> = self
.entries
.iter()
.map(|(k, e)| (*k, e.last_used, e.bytes))
.collect();
order.sort_by_key(|(_, used, _)| *used);
let over = self.entries.len().saturating_sub(MAX_ENTRIES);
let mut drop_count = over;
let mut projected = self.bytes;
for (_, _, bytes) in order.iter().take(over) {
projected = projected.saturating_sub(*bytes);
}
let keep_last = order.len().saturating_sub(1);
while projected > MAX_BYTES && drop_count < keep_last {
if let Some((_, _, bytes)) = order.get(drop_count) {
projected = projected.saturating_sub(*bytes);
}
drop_count += 1;
}
for (key, _, bytes) in order.into_iter().take(drop_count) {
self.entries.remove(&key);
self.bytes = self.bytes.saturating_sub(bytes);
}
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{ImageCache, MAX_ENTRIES, RequestedSize};
use crate::image::{ImageData, Pixels, Samples};
use pdfrum_object::ObjRef;
use std::sync::Arc;
fn tiny() -> Arc<ImageData> {
Arc::new(ImageData {
width: 1,
height: 1,
samples: Samples::Whole(Pixels::Gray8(Box::from(&[0u8][..]))),
mask: None,
matte: None,
interpolate: false,
})
}
#[test]
fn resolution_levels_halve_per_step() {
let full = RequestedSize::Full;
assert_eq!(full.levels(400, 400), 0);
let half = RequestedSize::Reduced {
width: 200,
height: 200,
};
assert_eq!(half.levels(400, 400), 1);
let eighth = RequestedSize::Reduced {
width: 50,
height: 50,
};
assert_eq!(eighth.levels(400, 400), 3);
assert_eq!(
RequestedSize::Reduced {
width: 800,
height: 800
}
.levels(400, 400),
0
);
assert_eq!(
RequestedSize::Reduced {
width: 0,
height: 0
}
.levels(400, 400),
0
);
}
#[test]
fn a_reduced_entry_cannot_serve_a_full_resolution_request() {
let reduced = RequestedSize::Reduced {
width: 50,
height: 50,
};
assert!(!reduced.satisfies(RequestedSize::Full, 50, 50));
assert!(RequestedSize::Full.satisfies(reduced, 400, 400));
assert!(reduced.satisfies(
RequestedSize::Reduced {
width: 25,
height: 25
},
50,
50
));
assert!(!reduced.satisfies(
RequestedSize::Reduced {
width: 100,
height: 100
},
50,
50
));
}
#[test]
fn the_entry_cap_fires_before_the_byte_budget() {
let mut cache = ImageCache::new();
for i in 0..MAX_ENTRIES + 5 {
cache.insert(
ObjRef::new(u32::try_from(i).unwrap_or(0), 0),
RequestedSize::Full,
tiny(),
);
}
assert_eq!(cache.len(), MAX_ENTRIES);
assert!(cache.bytes() < 1024);
}
#[test]
fn lookup_refreshes_recency_so_the_oldest_untouched_entry_goes_first() {
let mut cache = ImageCache::new();
for i in 0..MAX_ENTRIES {
cache.insert(
ObjRef::new(u32::try_from(i).unwrap_or(0), 0),
RequestedSize::Full,
tiny(),
);
}
assert!(cache.get(ObjRef::new(0, 0), RequestedSize::Full).is_some());
cache.insert(ObjRef::new(99, 0), RequestedSize::Full, tiny());
assert!(
cache.get(ObjRef::new(0, 0), RequestedSize::Full).is_some(),
"the touched entry should have survived"
);
assert!(cache.get(ObjRef::new(1, 0), RequestedSize::Full).is_none());
}
#[test]
fn a_full_resolution_entry_answers_a_reduced_request() {
let mut cache = ImageCache::new();
cache.insert(ObjRef::new(1, 0), RequestedSize::Full, tiny());
assert!(
cache
.get(
ObjRef::new(1, 0),
RequestedSize::Reduced {
width: 10,
height: 10
}
)
.is_some()
);
let mut cache = ImageCache::new();
cache.insert(
ObjRef::new(1, 0),
RequestedSize::Reduced {
width: 10,
height: 10,
},
tiny(),
);
assert!(cache.get(ObjRef::new(1, 0), RequestedSize::Full).is_none());
}
fn gray(width: u32, height: u32) -> Arc<ImageData> {
let count = (width as usize) * (height as usize);
Arc::new(ImageData {
width,
height,
samples: Samples::Whole(Pixels::Gray8(vec![0u8; count].into())),
mask: None,
matte: None,
interpolate: false,
})
}
#[test]
fn an_entry_that_ignored_the_hint_serves_every_request_it_covers() {
let mut cache = ImageCache::new();
let asked = RequestedSize::Reduced {
width: 600,
height: 600,
};
cache.insert(ObjRef::new(1, 0), asked, gray(5000, 5000));
let smaller = RequestedSize::Reduced {
width: 300,
height: 300,
};
let hit = cache
.get(ObjRef::new(1, 0), smaller)
.expect("a 5000x5000 entry covers a 300x300 request");
assert_eq!((hit.width, hit.height), (5000, 5000));
assert!(
cache
.get(
ObjRef::new(1, 0),
RequestedSize::Reduced {
width: 4000,
height: 4000
}
)
.is_some()
);
}
#[test]
fn a_thumbnail_is_never_handed_to_a_request_it_cannot_cover() {
let mut cache = ImageCache::new();
let thumb = RequestedSize::Reduced {
width: 64,
height: 64,
};
cache.insert(ObjRef::new(1, 0), thumb, gray(64, 64));
assert!(
cache.get(ObjRef::new(1, 0), RequestedSize::Full).is_none(),
"a 64x64 decode cannot answer a full-resolution draw"
);
for wanted in [
RequestedSize::Reduced {
width: 65,
height: 64,
},
RequestedSize::Reduced {
width: 64,
height: 65,
},
RequestedSize::Reduced {
width: 2000,
height: 2000,
},
] {
assert!(
cache.get(ObjRef::new(1, 0), wanted).is_none(),
"{wanted:?} is larger than the entry on at least one axis"
);
}
}
#[test]
fn one_image_larger_than_the_whole_budget_survives_eviction() {
let big = Arc::new(ImageData {
width: 1,
height: 1,
samples: Samples::Whole(Pixels::Gray8(
vec![0u8; super::MAX_BYTES + 1].into_boxed_slice(),
)),
mask: None,
matte: None,
interpolate: false,
});
let mut cache = ImageCache::default();
let key = ObjRef::new(1, 0);
cache.insert(key, RequestedSize::Full, Arc::clone(&big));
assert!(
cache.get(key, RequestedSize::Full).is_some(),
"the only entry is kept however large it is"
);
let key2 = ObjRef::new(2, 0);
cache.insert(key2, RequestedSize::Full, big);
assert!(cache.get(key, RequestedSize::Full).is_none());
assert!(cache.get(key2, RequestedSize::Full).is_some());
}
#[test]
fn a_device_box_becomes_the_request_the_oracle_would_make() {
assert_eq!(
RequestedSize::for_device(612.0, 792.0),
RequestedSize::Reduced {
width: 612,
height: 792
}
);
assert_eq!(
RequestedSize::for_device(595.32, 841.92),
RequestedSize::Reduced {
width: 595,
height: 841
}
);
assert_eq!(RequestedSize::for_device(0.5, 100.0), RequestedSize::Full);
assert_eq!(RequestedSize::for_device(100.0, 0.0), RequestedSize::Full);
assert_eq!(
RequestedSize::for_device(f64::NAN, f64::INFINITY),
RequestedSize::Full
);
assert_eq!(
RequestedSize::for_device(612.0, 792.0).levels(5000, 5000),
2
);
}
#[test]
fn clearing_empties_the_cache() {
let mut cache = ImageCache::new();
cache.insert(ObjRef::new(1, 0), RequestedSize::Full, tiny());
assert!(!cache.is_empty());
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.bytes(), 0);
}
}