pdfrum_page/image/cache.rs
1//! The decoded-image session cache.
2//!
3//! Keyed on `(ObjRef, RequestedSize)` rather than the reference alone: a
4//! plain reference key cannot express
5//! resolution-dependent invalidation, and would hand a fifty-pixel thumbnail
6//! back to a full-resolution request.
7//!
8//! # The eviction order is not the obvious one
9//!
10//! PDFium evicts down to the **fifteen most recent entries unconditionally,
11//! before looking at the byte budget at all**, and only then evicts further
12//! to fit the budget. So a cache holding twenty small images loses five of
13//! them even when they total a few kilobytes.
14//!
15//! The byte budget, unlike the entry cap, never empties the cache: the last
16//! entry survives however large it is. An image bigger than the whole budget
17//! would otherwise be inserted and evicted in one call, leaving a caller that
18//! draws it repeatedly re-decoding it every time — the cache doing strictly
19//! worse than no cache. The rendered-pixmap cache downstream
20//! (`pdfrum_render`'s `RenderedImageCache`) states the same rule for the same
21//! reason.
22
23use super::ImageData;
24use pdfrum_object::ObjRef;
25use std::collections::HashMap;
26use std::sync::Arc;
27
28/// Entries kept regardless of size.
29pub const MAX_ENTRIES: usize = 15;
30
31/// Byte budget, 100 MiB.
32pub const MAX_BYTES: usize = 100 << 20;
33
34/// The resolution an image was decoded at.
35///
36/// `Full` and a `Reduced` size are distinct keys: an image cached reduced
37/// cannot serve a full-resolution request, while a full-resolution one can
38/// serve anything.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
40pub enum RequestedSize {
41 /// Every sample the image has.
42 #[default]
43 Full,
44 /// At most this many pixels in each direction.
45 Reduced {
46 /// Requested width.
47 width: u32,
48 /// Requested height.
49 height: u32,
50 },
51 /// No samples at all. A build for text extraction, which never reads an
52 /// image, asks for this, and the image object is not emitted — the same
53 /// outcome as a codec refusing it. Never a key the cache holds: on the
54 /// corpus's image documents the decode was 87% of a text run.
55 NoSamples,
56}
57
58impl RequestedSize {
59 /// The request a device box of `width` by `height` float pixels makes.
60 ///
61 /// The box is the render device's whole extent, not an image's destination
62 /// rectangle, so a 5000x5000 image on a 612x792 page is reduced by four
63 /// however small the `cm` that draws it is. Dimensions truncate, and a box
64 /// under one pixel on either axis names [`Self::Full`] rather than a zero
65 /// reduction.
66 // Reducing against the page bitmap rather than the destination rectangle is
67 // deliberately conservative, and it is what bounds the error: such a
68 // reduction can never drop a sample the device could have resolved.
69 // Truncation matches the allocated bitmap, whose extent is the float page
70 // size times the scale cast to an int. A zero would only be a division the
71 // level calculation has to guard against anyway.
72 #[must_use]
73 #[expect(
74 clippy::cast_possible_truncation,
75 clippy::cast_sign_loss,
76 reason = "the finite and >= 1.0 guard runs before the cast"
77 )]
78 pub fn for_device(width: f64, height: f64) -> Self {
79 let (w, h) = (width.trunc(), height.trunc());
80 if !w.is_finite() || !h.is_finite() || w < 1.0 || h < 1.0 {
81 return Self::Full;
82 }
83 Self::Reduced {
84 width: w as u32,
85 height: h as u32,
86 }
87 }
88
89 /// How many resolution levels to skip to satisfy this request for an
90 /// image of `width` by `height`.
91 ///
92 /// Integer division, then the smaller of the two, then a floored base-two
93 /// logarithm — so an image four times too large skips two levels and one
94 /// three times too large skips one.
95 #[must_use]
96 pub fn levels(self, width: u32, height: u32) -> u8 {
97 let Self::Reduced {
98 width: max_w,
99 height: max_h,
100 } = self
101 else {
102 return 0;
103 };
104 if max_w == 0 || max_h == 0 {
105 return 0;
106 }
107 let ratio = (width / max_w).min(height / max_h).max(1);
108 #[expect(
109 clippy::cast_possible_truncation,
110 reason = "a u32's base-two logarithm never exceeds 31"
111 )]
112 let levels = ratio.ilog2() as u8;
113 levels
114 }
115
116 /// Whether an image cached at `self` can serve a request for `wanted`.
117 #[must_use]
118 pub fn satisfies(self, wanted: Self, cached_width: u32, cached_height: u32) -> bool {
119 match self {
120 // A full-resolution cache always serves.
121 Self::Full => true,
122 Self::Reduced { .. } => match wanted {
123 // A reduced cache cannot serve a full-resolution request, and
124 // nothing is ever requested without samples: the build
125 // returns before it reaches the cache.
126 Self::Full | Self::NoSamples => false,
127 Self::Reduced { width, height } => cached_width >= width && cached_height >= height,
128 },
129 // Nothing is ever cached without samples.
130 Self::NoSamples => false,
131 }
132 }
133}
134
135/// A session-scoped cache of decoded images.
136///
137/// Owned by whatever is rendering or extracting, passed down by `&mut` — no
138/// global state, no interior mutability.
139#[derive(Debug, Default)]
140pub struct ImageCache {
141 entries: HashMap<(ObjRef, RequestedSize), Entry>,
142 /// A monotonically increasing counter standing in for a clock.
143 tick: u64,
144 bytes: usize,
145}
146
147#[derive(Debug)]
148struct Entry {
149 image: Arc<ImageData>,
150 last_used: u64,
151 bytes: usize,
152}
153
154impl ImageCache {
155 /// An empty cache.
156 #[must_use]
157 pub fn new() -> Self {
158 Self::default()
159 }
160
161 /// How many images are cached.
162 #[must_use]
163 pub fn len(&self) -> usize {
164 self.entries.len()
165 }
166
167 /// Whether nothing is cached.
168 #[must_use]
169 pub fn is_empty(&self) -> bool {
170 self.entries.is_empty()
171 }
172
173 /// Total bytes held.
174 #[must_use]
175 pub fn bytes(&self) -> usize {
176 self.bytes
177 }
178
179 /// Look an image up, refreshing its recency.
180 ///
181 /// Three rungs: the exact key, then a full-resolution entry — which
182 /// serves anything — then any entry whose *decoded* dimensions already
183 /// cover the request in both axes. The third matters because a decoder is
184 /// free to
185 /// ignore the hint: an image asked for at 600x600 that came back at 5000x5000
186 /// is stored under the 600x600 request, and a later 300x300 request must
187 /// find it rather than decode the same codestream again.
188 pub fn get(&mut self, key: ObjRef, size: RequestedSize) -> Option<Arc<ImageData>> {
189 self.tick = self.tick.saturating_add(1);
190 let tick = self.tick;
191 // The exact key first.
192 if let Some(entry) = self.entries.get_mut(&(key, size)) {
193 entry.last_used = tick;
194 return Some(Arc::clone(&entry.image));
195 }
196 // Then a full-resolution entry, which serves any request.
197 if size != RequestedSize::Full
198 && let Some(entry) = self.entries.get_mut(&(key, RequestedSize::Full))
199 {
200 entry.last_used = tick;
201 return Some(Arc::clone(&entry.image));
202 }
203 // Then any entry large enough. A linear scan, because the cache holds
204 // fifteen entries and the alternative is a second index.
205 let found = self.entries.iter().find_map(|((object, stored), entry)| {
206 (*object == key && stored.satisfies(size, entry.image.width, entry.image.height))
207 .then_some(*stored)
208 })?;
209 let entry = self.entries.get_mut(&(key, found))?;
210 entry.last_used = tick;
211 Some(Arc::clone(&entry.image))
212 }
213
214 /// Store an image, then evict.
215 pub fn insert(&mut self, key: ObjRef, size: RequestedSize, image: Arc<ImageData>) {
216 self.tick = self.tick.saturating_add(1);
217 let bytes = image.byte_size();
218 if let Some(old) = self.entries.insert(
219 (key, size),
220 Entry {
221 image,
222 last_used: self.tick,
223 bytes,
224 },
225 ) {
226 self.bytes = self.bytes.saturating_sub(old.bytes);
227 }
228 self.bytes = self.bytes.saturating_add(bytes);
229 self.evict();
230 }
231
232 /// Drop everything.
233 pub fn clear(&mut self) {
234 self.entries.clear();
235 self.bytes = 0;
236 }
237
238 /// The entry cap first, then the byte budget — in that order, which is
239 /// what makes a cache of twenty small images shed five of them.
240 fn evict(&mut self) {
241 if self.entries.len() <= MAX_ENTRIES && self.bytes <= MAX_BYTES {
242 return;
243 }
244 let mut order: Vec<_> = self
245 .entries
246 .iter()
247 .map(|(k, e)| (*k, e.last_used, e.bytes))
248 .collect();
249 // Oldest first.
250 order.sort_by_key(|(_, used, _)| *used);
251
252 // The unconditional entry cap.
253 let over = self.entries.len().saturating_sub(MAX_ENTRIES);
254 let mut drop_count = over;
255 // Then whatever more the budget demands.
256 let mut projected = self.bytes;
257 for (_, _, bytes) in order.iter().take(over) {
258 projected = projected.saturating_sub(*bytes);
259 }
260 // `order.len() - 1` and not `order.len()`: one entry survives the
261 // byte budget however large it is. A single image bigger than the
262 // whole budget would otherwise be inserted and evicted in the same
263 // call, so a session that draws it repeatedly re-decodes it every
264 // time and the cache does nothing at all — which is what the corpus's
265 // 4473x4473 sixteen-bit `image_bug_583804` did once its samples
266 // stopped being widened on decode (120 MiB packed against a 100 MiB
267 // budget). `crate::image` names the same rule for the rendered
268 // pixmaps downstream. The entry cap above stays unconditional,
269 // because that is upstream's order and it is a count, not a size.
270 let keep_last = order.len().saturating_sub(1);
271 while projected > MAX_BYTES && drop_count < keep_last {
272 if let Some((_, _, bytes)) = order.get(drop_count) {
273 projected = projected.saturating_sub(*bytes);
274 }
275 drop_count += 1;
276 }
277 for (key, _, bytes) in order.into_iter().take(drop_count) {
278 self.entries.remove(&key);
279 self.bytes = self.bytes.saturating_sub(bytes);
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 // Test fixtures quote the oracle's own vectors, compare floats exactly
287 // where the behaviour being pinned is exact, and index arrays whose
288 // length the fixture itself fixes.
289 #![allow(
290 clippy::unreadable_literal,
291 clippy::float_cmp,
292 clippy::indexing_slicing,
293 clippy::cast_precision_loss,
294 clippy::cast_possible_truncation,
295 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
296 )]
297
298 use super::{ImageCache, MAX_ENTRIES, RequestedSize};
299 use crate::image::{ImageData, Pixels, Samples};
300 use pdfrum_object::ObjRef;
301 use std::sync::Arc;
302
303 fn tiny() -> Arc<ImageData> {
304 Arc::new(ImageData {
305 width: 1,
306 height: 1,
307 samples: Samples::Whole(Pixels::Gray8(Box::from(&[0u8][..]))),
308 mask: None,
309 matte: None,
310 interpolate: false,
311 })
312 }
313
314 #[test]
315 fn resolution_levels_halve_per_step() {
316 let full = RequestedSize::Full;
317 assert_eq!(full.levels(400, 400), 0);
318 let half = RequestedSize::Reduced {
319 width: 200,
320 height: 200,
321 };
322 assert_eq!(half.levels(400, 400), 1);
323 let eighth = RequestedSize::Reduced {
324 width: 50,
325 height: 50,
326 };
327 assert_eq!(eighth.levels(400, 400), 3);
328 // A request larger than the image skips nothing.
329 assert_eq!(
330 RequestedSize::Reduced {
331 width: 800,
332 height: 800
333 }
334 .levels(400, 400),
335 0
336 );
337 // A zero request is not a division by zero.
338 assert_eq!(
339 RequestedSize::Reduced {
340 width: 0,
341 height: 0
342 }
343 .levels(400, 400),
344 0
345 );
346 }
347
348 #[test]
349 fn a_reduced_entry_cannot_serve_a_full_resolution_request() {
350 let reduced = RequestedSize::Reduced {
351 width: 50,
352 height: 50,
353 };
354 assert!(!reduced.satisfies(RequestedSize::Full, 50, 50));
355 // But a full-resolution one serves anything.
356 assert!(RequestedSize::Full.satisfies(reduced, 400, 400));
357 // A reduced one serves a smaller reduced request.
358 assert!(reduced.satisfies(
359 RequestedSize::Reduced {
360 width: 25,
361 height: 25
362 },
363 50,
364 50
365 ));
366 assert!(!reduced.satisfies(
367 RequestedSize::Reduced {
368 width: 100,
369 height: 100
370 },
371 50,
372 50
373 ));
374 }
375
376 #[test]
377 fn the_entry_cap_fires_before_the_byte_budget() {
378 let mut cache = ImageCache::new();
379 for i in 0..MAX_ENTRIES + 5 {
380 cache.insert(
381 ObjRef::new(u32::try_from(i).unwrap_or(0), 0),
382 RequestedSize::Full,
383 tiny(),
384 );
385 }
386 // Twenty one-byte images are nowhere near 100 MiB, and five are still
387 // evicted.
388 assert_eq!(cache.len(), MAX_ENTRIES);
389 assert!(cache.bytes() < 1024);
390 }
391
392 #[test]
393 fn lookup_refreshes_recency_so_the_oldest_untouched_entry_goes_first() {
394 let mut cache = ImageCache::new();
395 for i in 0..MAX_ENTRIES {
396 cache.insert(
397 ObjRef::new(u32::try_from(i).unwrap_or(0), 0),
398 RequestedSize::Full,
399 tiny(),
400 );
401 }
402 // Touch the first entry, then push the cache over the cap.
403 assert!(cache.get(ObjRef::new(0, 0), RequestedSize::Full).is_some());
404 cache.insert(ObjRef::new(99, 0), RequestedSize::Full, tiny());
405 assert!(
406 cache.get(ObjRef::new(0, 0), RequestedSize::Full).is_some(),
407 "the touched entry should have survived"
408 );
409 assert!(cache.get(ObjRef::new(1, 0), RequestedSize::Full).is_none());
410 }
411
412 #[test]
413 fn a_full_resolution_entry_answers_a_reduced_request() {
414 let mut cache = ImageCache::new();
415 cache.insert(ObjRef::new(1, 0), RequestedSize::Full, tiny());
416 assert!(
417 cache
418 .get(
419 ObjRef::new(1, 0),
420 RequestedSize::Reduced {
421 width: 10,
422 height: 10
423 }
424 )
425 .is_some()
426 );
427 // But not the other way round.
428 let mut cache = ImageCache::new();
429 cache.insert(
430 ObjRef::new(1, 0),
431 RequestedSize::Reduced {
432 width: 10,
433 height: 10,
434 },
435 tiny(),
436 );
437 assert!(cache.get(ObjRef::new(1, 0), RequestedSize::Full).is_none());
438 }
439
440 /// A grey image of a given size, so a lookup can be asked about the
441 /// dimensions actually decoded rather than about the key alone.
442 fn gray(width: u32, height: u32) -> Arc<ImageData> {
443 let count = (width as usize) * (height as usize);
444 Arc::new(ImageData {
445 width,
446 height,
447 samples: Samples::Whole(Pixels::Gray8(vec![0u8; count].into())),
448 mask: None,
449 matte: None,
450 interpolate: false,
451 })
452 }
453
454 #[test]
455 fn an_entry_that_ignored_the_hint_serves_every_request_it_covers() {
456 // The case that only exists once a decoder is allowed to refuse: an
457 // image asked for at 600x600 that came back at 5000x5000 — because it
458 // is a JPEG whose MCUs are not aligned, or a palettized JPEG 2000 —
459 // is stored under the 600x600 *request*. A later 300x300 request must
460 // find it. Keying on the request alone would miss and decode the same
461 // codestream again; keying on the decoded size alone would lose the
462 // fact that the request was ever made.
463 let mut cache = ImageCache::new();
464 let asked = RequestedSize::Reduced {
465 width: 600,
466 height: 600,
467 };
468 cache.insert(ObjRef::new(1, 0), asked, gray(5000, 5000));
469 let smaller = RequestedSize::Reduced {
470 width: 300,
471 height: 300,
472 };
473 let hit = cache
474 .get(ObjRef::new(1, 0), smaller)
475 .expect("a 5000x5000 entry covers a 300x300 request");
476 assert_eq!((hit.width, hit.height), (5000, 5000));
477 // And it covers a request larger than its key but no larger than
478 // itself.
479 assert!(
480 cache
481 .get(
482 ObjRef::new(1, 0),
483 RequestedSize::Reduced {
484 width: 4000,
485 height: 4000
486 }
487 )
488 .is_some()
489 );
490 }
491
492 #[test]
493 fn a_thumbnail_is_never_handed_to_a_request_it_cannot_cover() {
494 // The correctness bug names: a page that draws one image
495 // small and then large must not get the small decode back for the
496 // large draw. Two axes, tested separately, because a mask that checked
497 // only one would pass the symmetric case and fail every real one.
498 let mut cache = ImageCache::new();
499 let thumb = RequestedSize::Reduced {
500 width: 64,
501 height: 64,
502 };
503 cache.insert(ObjRef::new(1, 0), thumb, gray(64, 64));
504 assert!(
505 cache.get(ObjRef::new(1, 0), RequestedSize::Full).is_none(),
506 "a 64x64 decode cannot answer a full-resolution draw"
507 );
508 for wanted in [
509 RequestedSize::Reduced {
510 width: 65,
511 height: 64,
512 },
513 RequestedSize::Reduced {
514 width: 64,
515 height: 65,
516 },
517 RequestedSize::Reduced {
518 width: 2000,
519 height: 2000,
520 },
521 ] {
522 assert!(
523 cache.get(ObjRef::new(1, 0), wanted).is_none(),
524 "{wanted:?} is larger than the entry on at least one axis"
525 );
526 }
527 }
528
529 /// One image larger than the whole byte budget is still cached.
530 ///
531 /// The regression this pins: `image_bug_583804` is a 4473x4473 sixteen-bit
532 /// RGB image whose packed samples are 120 MiB against a 100 MiB budget, so
533 /// the eviction pass dropped it in the same call that inserted it and every
534 /// rebuild of the page re-ran the decode. A warm render that rebuilds its
535 /// page — which is what `render-warm-*` measures — paid 95 ms per iteration
536 /// for a cache that held nothing.
537 #[test]
538 fn one_image_larger_than_the_whole_budget_survives_eviction() {
539 let big = Arc::new(ImageData {
540 width: 1,
541 height: 1,
542 samples: Samples::Whole(Pixels::Gray8(
543 vec![0u8; super::MAX_BYTES + 1].into_boxed_slice(),
544 )),
545 mask: None,
546 matte: None,
547 interpolate: false,
548 });
549 let mut cache = ImageCache::default();
550 let key = ObjRef::new(1, 0);
551 cache.insert(key, RequestedSize::Full, Arc::clone(&big));
552 assert!(
553 cache.get(key, RequestedSize::Full).is_some(),
554 "the only entry is kept however large it is"
555 );
556
557 // A second image does not get the same protection: with two entries
558 // the budget sheds the older one and the newest survives.
559 let key2 = ObjRef::new(2, 0);
560 cache.insert(key2, RequestedSize::Full, big);
561 assert!(cache.get(key, RequestedSize::Full).is_none());
562 assert!(cache.get(key2, RequestedSize::Full).is_some());
563 }
564
565 #[test]
566 fn a_device_box_becomes_the_request_the_oracle_would_make() {
567 // The truncation is the bitmap allocation's, and a sub-pixel box asks
568 // for everything rather than for nothing.
569 assert_eq!(
570 RequestedSize::for_device(612.0, 792.0),
571 RequestedSize::Reduced {
572 width: 612,
573 height: 792
574 }
575 );
576 assert_eq!(
577 RequestedSize::for_device(595.32, 841.92),
578 RequestedSize::Reduced {
579 width: 595,
580 height: 841
581 }
582 );
583 assert_eq!(RequestedSize::for_device(0.5, 100.0), RequestedSize::Full);
584 assert_eq!(RequestedSize::for_device(100.0, 0.0), RequestedSize::Full);
585 assert_eq!(
586 RequestedSize::for_device(f64::NAN, f64::INFINITY),
587 RequestedSize::Full
588 );
589 // And the level count it then implies is the oracle's own formula: a
590 // 5000x5000 image against a 612x792 page skips two levels, not six.
591 assert_eq!(
592 RequestedSize::for_device(612.0, 792.0).levels(5000, 5000),
593 2
594 );
595 }
596
597 #[test]
598 fn clearing_empties_the_cache() {
599 let mut cache = ImageCache::new();
600 cache.insert(ObjRef::new(1, 0), RequestedSize::Full, tiny());
601 assert!(!cache.is_empty());
602 cache.clear();
603 assert!(cache.is_empty());
604 assert_eq!(cache.bytes(), 0);
605 }
606}