Skip to main content

pdf_interpret/
cache.rs

1use crate::util::hash128;
2use kurbo::Affine;
3use pdf_syntax::object::{Array, Dict, MaybeRef, Name, Null, ObjRef, Object, Stream};
4use std::any::Any;
5use std::collections::hash_map::Entry;
6use std::collections::{HashMap, VecDeque};
7use std::sync::{Arc, Mutex};
8
9type CacheMap = HashMap<u128, Option<Box<dyn Any + Send + Sync>>>;
10
11/// Maximum number of decoded images retained by the document-level image cache.
12///
13/// The cache is bounded by BOTH entry count (this constant) and total decoded
14/// bytes ([`IMAGE_CACHE_MAX_BYTES`]); an insertion evicts least-recently-used
15/// entries until both bounds hold. The count cap catches "many small images"
16/// (logos, headers/footers repeated across pages); the byte cap catches "few
17/// but huge images" that would otherwise pass the count cap while consuming
18/// hundreds of MB. The cache is per-`PdfDocument` and lives only as long as
19/// that document.
20const IMAGE_CACHE_CAPACITY: usize = 32;
21
22/// Maximum total decoded-image bytes retained by the document-level image cache.
23///
24/// 256 MiB is a conservative working-set ceiling for image-heavy documents: it
25/// comfortably holds typical repeated assets while bounding peak transient
26/// memory for pathological inputs (e.g. a handful of full-page scans). An entry
27/// larger than the whole budget is returned to the caller but not retained.
28const IMAGE_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024;
29
30struct ImageCache {
31    /// Maps a cache key to the decoded value and its accounted byte weight.
32    map: HashMap<u128, (Box<dyn Any + Send + Sync>, usize)>,
33    /// Least-recently-used order; front = oldest, back = most recently used.
34    order: VecDeque<u128>,
35    /// Sum of the byte weights of all entries currently in `map`.
36    total_bytes: usize,
37}
38
39/// A cache to store decoded images and other objects to avoid parsing/decoding them repeatedly.
40#[derive(Clone)]
41pub struct Cache {
42    generic: Arc<Mutex<CacheMap>>,
43    images: Arc<Mutex<ImageCache>>,
44}
45
46impl Default for Cache {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl Cache {
53    /// Create a new, empty cache.
54    pub fn new() -> Self {
55        Self {
56            generic: Arc::new(Mutex::new(HashMap::new())),
57            images: Arc::new(Mutex::new(ImageCache {
58                map: HashMap::new(),
59                order: VecDeque::new(),
60                total_bytes: 0,
61            })),
62        }
63    }
64
65    pub(crate) fn get_or_insert_with<T: Clone + Send + Sync + 'static>(
66        &self,
67        id: u128,
68        f: impl FnOnce() -> Option<T>,
69    ) -> Option<T> {
70        let mut locked = self.generic.lock().unwrap();
71
72        // We can't use `get_or_insert_with` here, because if the closure makes another access to the
73        // cache, we end up with a deadlock.
74        match locked.entry(id) {
75            Entry::Occupied(o) => o
76                .get()
77                .as_ref()
78                .and_then(|val| val.downcast_ref::<T>().cloned()),
79            Entry::Vacant(_) => {
80                drop(locked);
81                let val = f();
82                self.generic.lock().unwrap().insert(
83                    id,
84                    val.clone()
85                        .map(|val| Box::new(val) as Box<dyn Any + Send + Sync>),
86                );
87
88                val
89            }
90        }
91    }
92
93    /// Look up a parsed font program by `id`, or build it with `f` and keep it
94    /// for the rest of the document.
95    ///
96    /// Unlike [`Self::get_or_insert_with`], a `None` is not remembered: a
97    /// program that fails to parse is tried again on the next page, so every
98    /// warning the failure raises reaches the sink of the page that hit it,
99    /// exactly as without the cache.
100    pub(crate) fn get_or_insert_some<T: Clone + Send + Sync + 'static>(
101        &self,
102        id: u128,
103        f: impl FnOnce() -> Option<T>,
104    ) -> Option<T> {
105        if let Some(Some(val)) = self.generic.lock().unwrap().get(&id)
106            && let Some(val) = val.downcast_ref::<T>()
107        {
108            return Some(val.clone());
109        }
110        let val = f()?;
111        self.generic.lock().unwrap().insert(
112            id,
113            Some(Box::new(val.clone()) as Box<dyn Any + Send + Sync>),
114        );
115        Some(val)
116    }
117
118    /// The key a font program stream is kept under; `tag` names the parser
119    /// that reads it, since one stream can be tried by several.
120    ///
121    /// THE BYTES, NOT THE ADDRESS. `InterpreterSettings::shared_cache` is
122    /// public and a `Cache` is `Clone`, so one cache can serve two
123    /// documents, and object 5 with the same dictionary is a different font
124    /// in each. The key is therefore a hash of the stream's dictionary and
125    /// its raw bytes, which no other font program shares. Hashing a font of a
126    /// few hundred kilobytes costs tens of microseconds per page it is set
127    /// on, against the milliseconds the parse it replaces took.
128    pub(crate) fn font_stream_key(tag: &[u8], stream: &Stream<'_>) -> u128 {
129        hash128(&(tag, stream.cache_key(), stream.raw_data().as_ref()))
130    }
131
132    /// The key a font program handed over as bytes (by the font resolver) is
133    /// kept under: the bytes themselves, so two resolvers that answer the
134    /// same query differently never share an entry.
135    pub(crate) fn font_data_key(tag: &[u8], index: u32, data: &[u8]) -> u128 {
136        hash128(&(tag, index, data))
137    }
138
139    /// Look up a decoded image by `id`, or compute it with `f` and cache it.
140    ///
141    /// `weight` returns the byte size of a freshly-computed value; it is used to
142    /// keep the cache within [`IMAGE_CACHE_MAX_BYTES`]. The cache is also bounded
143    /// to [`IMAGE_CACHE_CAPACITY`] entries. After an insertion, least-recently-
144    /// used entries are evicted until both bounds hold (a single value larger
145    /// than the whole budget is returned but not retained).
146    pub(crate) fn get_or_insert_image<T: Clone + Send + Sync + 'static>(
147        &self,
148        id: u128,
149        f: impl FnOnce() -> Option<T>,
150        weight: impl FnOnce(&T) -> usize,
151    ) -> Option<T> {
152        let mut locked = self.images.lock().unwrap();
153        if locked.map.contains_key(&id) {
154            if let Some(pos) = locked.order.iter().position(|&x| x == id) {
155                locked.order.remove(pos);
156            }
157            locked.order.push_back(id);
158            let (val, _) = locked.map.get(&id).unwrap();
159            return val.downcast_ref::<T>().cloned();
160        }
161
162        drop(locked);
163        let val = f();
164        if let Some(ref v) = val {
165            let w = weight(v);
166            let mut locked = self.images.lock().unwrap();
167            // A repeated key would otherwise leak its old weight; subtract it.
168            if let Some((_, old_w)) = locked
169                .map
170                .insert(id, (Box::new(v.clone()) as Box<dyn Any + Send + Sync>, w))
171            {
172                locked.total_bytes = locked.total_bytes.saturating_sub(old_w);
173                if let Some(pos) = locked.order.iter().position(|&x| x == id) {
174                    locked.order.remove(pos);
175                }
176            }
177            locked.total_bytes += w;
178            locked.order.push_back(id);
179
180            // Evict least-recently-used entries until both the count and the
181            // byte budget are satisfied.
182            while !locked.order.is_empty()
183                && (locked.order.len() > IMAGE_CACHE_CAPACITY
184                    || locked.total_bytes > IMAGE_CACHE_MAX_BYTES)
185            {
186                if let Some(oldest_id) = locked.order.pop_front()
187                    && let Some((_, evicted_w)) = locked.map.remove(&oldest_id)
188                {
189                    locked.total_bytes = locked.total_bytes.saturating_sub(evicted_w);
190                }
191            }
192        }
193        val
194    }
195
196    /// Current image-cache occupancy as `(entry_count, total_bytes)`.
197    ///
198    /// Exposed for measurement and tests; both values are always within
199    /// [`IMAGE_CACHE_CAPACITY`] and [`IMAGE_CACHE_MAX_BYTES`] respectively after
200    /// any completed `get_or_insert_image` call.
201    #[cfg(test)]
202    pub(crate) fn image_cache_stats(&self) -> (usize, usize) {
203        let locked = self.images.lock().unwrap();
204        (locked.map.len(), locked.total_bytes)
205    }
206}
207
208/// A trait for objects that can generate a unique cache key.
209pub trait CacheKey {
210    /// Returns the cache key for this object.
211    fn cache_key(&self) -> u128;
212}
213
214impl<T: CacheKey, U: CacheKey> CacheKey for (T, U) {
215    fn cache_key(&self) -> u128 {
216        hash128(&(self.0.cache_key(), self.1.cache_key()))
217    }
218}
219
220impl CacheKey for Dict<'_> {
221    fn cache_key(&self) -> u128 {
222        hash128(self.data())
223    }
224}
225
226impl CacheKey for Stream<'_> {
227    fn cache_key(&self) -> u128 {
228        self.dict().cache_key()
229    }
230}
231
232impl CacheKey for Null {
233    fn cache_key(&self) -> u128 {
234        hash128(self)
235    }
236}
237
238impl CacheKey for bool {
239    fn cache_key(&self) -> u128 {
240        hash128(self)
241    }
242}
243
244impl CacheKey for pdf_syntax::object::Number {
245    fn cache_key(&self) -> u128 {
246        hash128(&self.as_f64().to_bits())
247    }
248}
249
250impl CacheKey for pdf_syntax::object::String {
251    fn cache_key(&self) -> u128 {
252        hash128(self.as_ref())
253    }
254}
255
256impl CacheKey for Name {
257    fn cache_key(&self) -> u128 {
258        hash128(self)
259    }
260}
261
262impl CacheKey for Array<'_> {
263    fn cache_key(&self) -> u128 {
264        hash128(self.data())
265    }
266}
267
268impl CacheKey for Object<'_> {
269    fn cache_key(&self) -> u128 {
270        match self {
271            Object::Null(n) => n.cache_key(),
272            Object::Boolean(b) => b.cache_key(),
273            Object::Number(n) => n.cache_key(),
274            Object::String(s) => s.cache_key(),
275            Object::Name(n) => n.cache_key(),
276            Object::Dict(d) => d.cache_key(),
277            Object::Array(a) => a.cache_key(),
278            Object::Stream(s) => s.cache_key(),
279        }
280    }
281}
282
283impl CacheKey for ObjRef {
284    fn cache_key(&self) -> u128 {
285        hash128(self)
286    }
287}
288
289impl<T: CacheKey> CacheKey for MaybeRef<T> {
290    fn cache_key(&self) -> u128 {
291        match self {
292            Self::Ref(r) => r.cache_key(),
293            Self::NotRef(o) => o.cache_key(),
294        }
295    }
296}
297
298impl CacheKey for Affine {
299    fn cache_key(&self) -> u128 {
300        let c = self.as_coeffs();
301        hash128(&[
302            c[0].to_bits(),
303            c[1].to_bits(),
304            c[2].to_bits(),
305            c[3].to_bits(),
306            c[4].to_bits(),
307            c[5].to_bits(),
308        ])
309    }
310}
311
312impl CacheKey for u128 {
313    fn cache_key(&self) -> u128 {
314        hash128(self)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    /// Two documents, one `Cache`: object 5 is a stream with the same
321    /// dictionary in each, but a different program. The keys must differ,
322    /// or the second document would be handed the first one's font.
323    #[test]
324    fn a_font_stream_is_keyed_by_its_bytes_and_not_by_its_object_number() {
325        use pdf_syntax::object::{ObjectIdentifier, Stream};
326
327        fn pdf_with_object_5(program: &[u8]) -> Vec<u8> {
328            let objects: Vec<Vec<u8>> = vec![
329                b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
330                b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
331                b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 4 0 R >>".to_vec(),
332                b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
333                [
334                    format!("<< /Length {} >>\nstream\n", program.len()).into_bytes(),
335                    program.to_vec(),
336                    b"\nendstream".to_vec(),
337                ]
338                .concat(),
339            ];
340            let mut out = b"%PDF-1.7\n".to_vec();
341            let mut offsets = Vec::new();
342            for (i, body) in objects.iter().enumerate() {
343                offsets.push(out.len());
344                out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
345                out.extend_from_slice(body);
346                out.extend_from_slice(b"\nendobj\n");
347            }
348            let xref_pos = out.len();
349            out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
350            out.extend_from_slice(b"0000000000 65535 f \n");
351            for offset in offsets {
352                out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
353            }
354            out.extend_from_slice(
355                format!(
356                    "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n",
357                    objects.len() + 1
358                )
359                .as_bytes(),
360            );
361            out
362        }
363
364        // Same length, so the dictionaries are byte-identical too.
365        let first = pdf_syntax::Pdf::new(pdf_with_object_5(b"FONT-PROGRAM-A")).expect("first");
366        let second = pdf_syntax::Pdf::new(pdf_with_object_5(b"FONT-PROGRAM-B")).expect("second");
367        let stream_of = |pdf: &pdf_syntax::Pdf| -> u128 {
368            let stream = pdf
369                .xref()
370                .get::<Stream<'_>>(ObjectIdentifier::new(5, 0))
371                .expect("object 5 is a stream");
372            super::Cache::font_stream_key(b"type1", &stream)
373        };
374        let (a, b) = (stream_of(&first), stream_of(&second));
375        assert_ne!(
376            a, b,
377            "two different programs under one object number share a key"
378        );
379        assert_eq!(a, stream_of(&first), "the key of one stream is stable");
380
381        // And through the cache itself: the second document must not be
382        // handed the first one's value.
383        let cache = super::Cache::new();
384        let got_a = cache.get_or_insert_some(a, || Some(1u8));
385        let got_b = cache.get_or_insert_some(b, || Some(2u8));
386        assert_eq!((got_a, got_b), (Some(1), Some(2)));
387    }
388
389    use super::*;
390
391    #[test]
392    fn test_generic_cache() {
393        let cache = Cache::new();
394        let val = cache.get_or_insert_with(12345, || Some("hello".to_string()));
395        assert_eq!(val, Some("hello".to_string()));
396
397        let cached = cache.get_or_insert_with(12345, || Some("world".to_string()));
398        assert_eq!(cached, Some("hello".to_string()));
399    }
400
401    // A small weight; keeps these count-focused cases far below the byte budget.
402    fn tiny(_: &i32) -> usize {
403        4
404    }
405
406    #[test]
407    fn test_image_cache_lru_eviction() {
408        let cache = Cache::new();
409
410        // 1. Insert 32 unique items
411        for i in 0..32 {
412            let val = cache.get_or_insert_image(i as u128, || Some(i), tiny);
413            assert_eq!(val, Some(i));
414        }
415
416        // Verify all 32 items are still there
417        for i in 0..32 {
418            let cached = cache.get_or_insert_image::<i32>(i as u128, || None, tiny);
419            assert_eq!(cached, Some(i));
420        }
421
422        // 2. Access key 0 to make it recently used (brings it to the back)
423        let first_accessed = cache.get_or_insert_image::<i32>(0, || None, tiny);
424        assert_eq!(first_accessed, Some(0));
425
426        // 3. Insert a new item (key 32). This evicts the oldest entry, which is
427        //    key 1 (key 0 was just touched).
428        let val32 = cache.get_or_insert_image(32, || Some(32), tiny);
429        assert_eq!(val32, Some(32));
430
431        // Key 1 should be None (evicted)
432        let evicted1 = cache.get_or_insert_image::<i32>(1, || None, tiny);
433        assert_eq!(evicted1, None);
434
435        // Key 0 should still be Some(0) (since we touched it)
436        let kept0 = cache.get_or_insert_image::<i32>(0, || None, tiny);
437        assert_eq!(kept0, Some(0));
438    }
439
440    #[test]
441    fn test_image_cache_evicts_by_byte_budget() {
442        let cache = Cache::new();
443        let big = 100 * 1024 * 1024; // 100 MiB each; three => 300 MiB > 256 MiB.
444
445        cache.get_or_insert_image(1u128, || Some(1i32), |_| big);
446        cache.get_or_insert_image(2u128, || Some(2i32), |_| big);
447        cache.get_or_insert_image(3u128, || Some(3i32), |_| big);
448
449        // Only three entries (far under the 32 count cap), but their bytes exceed
450        // the budget, so the least-recently-used (key 1) is evicted.
451        let (count, bytes) = cache.image_cache_stats();
452        assert_eq!(count, 2, "byte budget should drop the cache to two entries");
453        assert!(
454            bytes <= IMAGE_CACHE_MAX_BYTES,
455            "total bytes must stay within budget"
456        );
457        assert_eq!(
458            cache.get_or_insert_image::<i32>(1, || None, |_| 0),
459            None,
460            "oldest entry evicted by byte budget"
461        );
462        assert_eq!(cache.get_or_insert_image::<i32>(2, || None, |_| 0), Some(2));
463        assert_eq!(cache.get_or_insert_image::<i32>(3, || None, |_| 0), Some(3));
464    }
465
466    #[test]
467    fn test_image_cache_byte_budget_respects_lru_touch() {
468        let cache = Cache::new();
469        let big = 100 * 1024 * 1024;
470
471        cache.get_or_insert_image(1u128, || Some(1i32), |_| big);
472        cache.get_or_insert_image(2u128, || Some(2i32), |_| big);
473        // Touch key 1 so it becomes most-recently-used.
474        assert_eq!(cache.get_or_insert_image::<i32>(1, || None, |_| 0), Some(1));
475        // Inserting key 3 pushes bytes over budget; the LRU victim is now key 2.
476        cache.get_or_insert_image(3u128, || Some(3i32), |_| big);
477
478        assert_eq!(
479            cache.get_or_insert_image::<i32>(2, || None, |_| 0),
480            None,
481            "least-recently-used key 2 evicted, not the touched key 1"
482        );
483        assert_eq!(cache.get_or_insert_image::<i32>(1, || None, |_| 0), Some(1));
484        assert_eq!(cache.get_or_insert_image::<i32>(3, || None, |_| 0), Some(3));
485    }
486
487    #[test]
488    fn test_image_cache_oversized_item_not_retained() {
489        let cache = Cache::new();
490        let huge = IMAGE_CACHE_MAX_BYTES + 1; // larger than the whole budget
491
492        // The value is still returned to the caller for this call...
493        assert_eq!(
494            cache.get_or_insert_image(1u128, || Some(1i32), |_| huge),
495            Some(1)
496        );
497        // ...but it is evicted immediately, so the cache retains nothing.
498        let (count, bytes) = cache.image_cache_stats();
499        assert_eq!(count, 0, "an item larger than the budget is not retained");
500        assert_eq!(bytes, 0);
501        assert_eq!(cache.get_or_insert_image::<i32>(1, || None, |_| 0), None);
502    }
503
504    #[test]
505    fn test_image_cache_stats_stay_within_both_bounds() {
506        let cache = Cache::new();
507        // 100 small (1 KiB) items: the count cap bounds entries to 32; bytes
508        // stay tiny. This is the measurement asserting both bounds hold.
509        for i in 0..100u128 {
510            cache.get_or_insert_image(i, || Some(i as i32), |_| 1024);
511        }
512        let (count, bytes) = cache.image_cache_stats();
513        assert_eq!(count, IMAGE_CACHE_CAPACITY, "count cap holds at 32 entries");
514        assert_eq!(
515            bytes,
516            IMAGE_CACHE_CAPACITY * 1024,
517            "32 retained * 1 KiB each"
518        );
519        assert!(bytes <= IMAGE_CACHE_MAX_BYTES);
520    }
521}