Skip to main content

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}