Skip to main content

pdfrum_font/glyphs/
cache.rs

1//! The glyph outline cache.
2//!
3//! Owned by a render session, never global. The key is the interesting part:
4//! the obvious `(font_id, gid,
5//! hint_flags)` is **insufficient**, because `dest_width` alone changes the
6//! outline of a Multiple-Master face — and the Multiple-Master faces are the
7//! terminal rung of the substitution ladder, so they are what draws every font
8//! the system cannot supply. The corrected key is the one PDFium's own path
9//! cache uses.
10
11use super::GlyphParams;
12use crate::{Font, FontId, Gid};
13use pdfrum_common::kurbo::BezPath;
14use std::collections::HashMap;
15use std::sync::Arc;
16
17/// What identifies one cached outline.
18///
19/// `hint_flags` is deliberately absent, and stayed absent when wave 7b brought
20/// hinting in. This cache holds the outlines the *path* side of text fills,
21/// and that side is unhinted at every size, for every face.
22///
23/// The hinted outline belongs to the glyph-*bitmap* side, which is a different
24/// cache in a different crate (`pdfrum_render::glyph::BitmapCache`) because it
25/// holds a different thing: a rasterization, which depends on the device
26/// matrix, where an outline does not. That separation is why this key did not
27/// need to grow a size — the key that does have one is over there.
28///
29/// The five fields below all genuinely vary an outline for at least one face
30/// kind.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub struct GlyphKey {
33    /// Which font — cache entries from two fonts must never be confused even
34    /// when they share a face.
35    pub font: FontId,
36    /// Which glyph.
37    pub gid: Gid,
38    /// The PDF's declared width for this character code, which solves a
39    /// Multiple-Master face's width axis.
40    pub dest_width: i32,
41    /// The substitution weight, which drives a Multiple-Master face's weight
42    /// axis. Zero means the face's own.
43    pub weight: i32,
44    /// The synthetic italic angle, which shears the outline.
45    pub italic_angle: i32,
46    /// Whether this is a vertical-writing form.
47    pub vertical: bool,
48}
49
50impl GlyphKey {
51    /// A key for a glyph drawn with no substitution adjustments at all.
52    #[must_use]
53    pub fn plain(font: FontId, gid: Gid) -> Self {
54        Self {
55            font,
56            gid,
57            dest_width: 0,
58            weight: 0,
59            italic_angle: 0,
60            vertical: false,
61        }
62    }
63
64    fn params(self) -> GlyphParams {
65        GlyphParams {
66            dest_width: self.dest_width,
67            weight: self.weight,
68        }
69    }
70}
71
72/// Memoized glyph outlines in 1000/em text space.
73///
74/// A miss is cached too: a glyph that produced no outline is stored as `None`
75/// so it is not recomputed, which is what PDFium's own null-result memoization
76/// does.
77///
78/// The outlines are held behind an `Arc` so a caller that wants one *for longer
79/// than the borrow* — the renderer's per-glyph placement record, which cannot
80/// hold a borrow because placing the next glyph needs the cache mutably again —
81/// takes a refcount rather than a copy of the path. Copying instead was 2891
82/// `BezPath` clones and 3.3 MiB per render of one corpus page, on a document
83/// where the copies were then never read.
84#[derive(Debug, Default)]
85pub struct GlyphCache {
86    entries: HashMap<GlyphKey, Option<Arc<BezPath>>>,
87}
88
89impl GlyphCache {
90    /// An empty cache.
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// The outline for `key`, drawing it if this is the first request.
97    ///
98    /// `font` must be the font `key.font` identifies; passing a different one
99    /// returns that font's glyph under the wrong key, which is why the key
100    /// carries the id at all.
101    #[cfg(test)]
102    pub(crate) fn path(&mut self, font: &Font, key: GlyphKey) -> Option<&BezPath> {
103        self.entry(font, key).map(AsRef::as_ref)
104    }
105
106    /// The same outline, as a handle that outlives the borrow.
107    ///
108    /// For a caller that needs the outline *after* asking the cache for the next
109    /// glyph. Cloning the returned `Arc` is a refcount bump; cloning a borrowed
110    /// path copies every element.
111    pub fn shared(&mut self, font: &Font, key: GlyphKey) -> Option<Arc<BezPath>> {
112        self.entry(font, key).map(Arc::clone)
113    }
114
115    /// The stored entry, drawn on first request.
116    fn entry(&mut self, font: &Font, key: GlyphKey) -> Option<&Arc<BezPath>> {
117        self.entries
118            .entry(key)
119            .or_insert_with(|| font.glyphs().outline(key.gid, key.params()).map(Arc::new))
120            .as_ref()
121    }
122
123    /// How many outlines — hits and misses alike — are memoized.
124    #[cfg(test)]
125    #[must_use]
126    pub(crate) fn len(&self) -> usize {
127        self.entries.len()
128    }
129
130    /// Whether anything has been drawn yet.
131    #[cfg(test)]
132    #[must_use]
133    pub(crate) fn is_empty(&self) -> bool {
134        self.entries.is_empty()
135    }
136
137    /// Forget everything.
138    #[cfg(test)]
139    pub(crate) fn clear(&mut self) {
140        self.entries.clear();
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::{FontCache, subst};
148    use pdfrum_common::{Diagnostics, Limits};
149    use pdfrum_object::{Dict, Name, NoResolve, Object};
150
151    fn helvetica() -> Font {
152        named("Helvetica")
153    }
154
155    /// A non-embedded Type 1 by name. An unrecognised name falls all the way
156    /// to the built-in Multiple-Master generic, which is the face the width
157    /// solve applies to; a base-14 name resolves to a Foxit blob instead.
158    fn named(base_font: &str) -> Font {
159        let dict = Dict::from_pairs([
160            (
161                crate::names::SUBTYPE.clone(),
162                Object::Name(Name::from("Type1")),
163            ),
164            (
165                crate::names::BASE_FONT.clone(),
166                Object::Name(Name::from(base_font)),
167            ),
168        ]);
169        crate::load(
170            &dict,
171            &NoResolve,
172            &FontCache::new(),
173            &Limits::default(),
174            &mut Diagnostics::default(),
175        )
176        .expect("a simple font always constructs")
177    }
178
179    #[test]
180    fn the_key_separates_every_field_it_declares() {
181        let base = GlyphKey::plain(FontId(1), Gid(5));
182        let variants = [
183            GlyphKey {
184                font: FontId(2),
185                ..base
186            },
187            GlyphKey {
188                gid: Gid(6),
189                ..base
190            },
191            GlyphKey {
192                dest_width: 700,
193                ..base
194            },
195            GlyphKey {
196                weight: 700,
197                ..base
198            },
199            GlyphKey {
200                italic_angle: -12,
201                ..base
202            },
203            GlyphKey {
204                vertical: true,
205                ..base
206            },
207        ];
208        for v in variants {
209            assert_ne!(base, v, "these must be distinct cache entries");
210        }
211    }
212
213    #[test]
214    fn a_repeated_request_is_served_from_the_cache() {
215        let font = helvetica();
216        let mut cache = GlyphCache::new();
217        let gid = font.glyphs().name_index(b"A");
218        let key = GlyphKey::plain(font.id(), Gid(gid));
219
220        assert!(cache.is_empty());
221        let first = cache.path(&font, key).cloned();
222        assert_eq!(cache.len(), 1);
223        let second = cache.path(&font, key).cloned();
224        assert_eq!(cache.len(), 1, "no second entry was created");
225        assert_eq!(first, second);
226    }
227
228    #[test]
229    fn a_glyph_with_no_outline_is_memoized_as_a_miss() {
230        let font = helvetica();
231        let mut cache = GlyphCache::new();
232        // A glyph index far past the face's own count.
233        let key = GlyphKey::plain(font.id(), Gid(60_000));
234        assert!(cache.path(&font, key).is_none());
235        assert_eq!(cache.len(), 1, "the miss itself is cached");
236        assert!(cache.path(&font, key).is_none());
237        assert_eq!(cache.len(), 1);
238    }
239
240    #[test]
241    fn a_dest_width_solves_the_multiple_master_width_axis() {
242        // Burn-down wave 5's font defect, in the form that outlives the file
243        // that exposed it. `AGaramond` is not embedded and is not a base-14
244        // name, so it substitutes onto the built-in generic — a
245        // Multiple-Master Type 1 face — and PDFium then solves that face's
246        // width axis until the glyph's own advance equals the PDF's declared
247        // `/Widths` value
248        // (`AdjustVariationParams`, `cfx_face.cpp:1561-1605`, reached from
249        // `cpdf_font.cpp:440-444`).
250        //
251        // Leaving `dest_width` at zero draws the axis default instead. On
252        // `5.5_simple_font.pdf`, whose `/Widths` say `a = 800` against a face
253        // whose own is 452, the glyphs overran their advances and piled into
254        // each other — which read as dropped characters on the `/Type_1_F`
255        // band and as a "too narrow" `/Type_1_MM_F` band. Both were this.
256        let font = named("AGaramond");
257        assert!(
258            font.subst().is_some_and(|s| s.is_builtin_generic),
259            "the fixture must actually reach the Multiple-Master generic"
260        );
261        let gid = Gid(font.glyphs().name_index(b"a"));
262        let params = |w: i32| GlyphParams {
263            dest_width: w,
264            weight: 0,
265        };
266        let at = |w: i32| font.glyphs().advance(gid, params(w));
267
268        let default = at(0);
269        let narrow = at(300);
270        let wide = at(900);
271        assert!(default > 0, "the substitute face has a real glyph");
272        assert!(
273            narrow < default && default < wide,
274            "the axis solve tracks dest_width: {narrow} < {default} < {wide}"
275        );
276        // The solve is an interpolation onto the requested advance, so it
277        // lands on it rather than merely moving toward it.
278        // Inside the axis the solve is an *interpolation onto the requested
279        // advance*, so it lands on it exactly rather than merely moving
280        // toward it. This is the assertion the defect would have failed:
281        // before the fix every one of these returned the default, 556.
282        for want in [300, 400, 500, 600, 700] {
283            assert_eq!(at(want), want, "dest_width {want} must be solved for");
284        }
285        // Outside it the advance saturates, because the interpolated design
286        // *coordinate* is unclamped (`AdjustVariationParams` deliberately
287        // extrapolates) but the blend then clamps it to the axis range, which
288        // is what `FT_Set_MM_Design_Coordinates` does. So an extreme
289        // `/Widths` gets the widest or narrowest the face can draw, not a
290        // degenerate outline.
291        assert_eq!(at(900), at(1500), "the wide end saturates");
292        assert_eq!(at(50), at(1), "and so does the narrow end");
293        assert!(at(50) < at(300) && at(700) < at(900));
294    }
295
296    #[test]
297    fn a_dest_width_and_the_default_are_separate_cache_entries() {
298        // The whole reason `dest_width` is in the key: the two draw different
299        // outlines from the same face and glyph.
300        let font = named("AGaramond");
301        let mut cache = GlyphCache::new();
302        let gid = Gid(font.glyphs().name_index(b"a"));
303        let plain = GlyphKey::plain(font.id(), gid);
304        let sized = GlyphKey {
305            dest_width: 300,
306            ..plain
307        };
308        let a = cache.path(&font, plain).cloned();
309        let b = cache.path(&font, sized).cloned();
310        assert_eq!(cache.len(), 2, "two entries, not one");
311        assert_ne!(a, b, "a solved width draws a different outline");
312    }
313
314    #[test]
315    fn clearing_empties_the_cache() {
316        let font = helvetica();
317        let mut cache = GlyphCache::new();
318        cache.path(&font, GlyphKey::plain(font.id(), Gid(1)));
319        assert!(!cache.is_empty());
320        cache.clear();
321        assert!(cache.is_empty());
322    }
323
324    #[test]
325    fn dest_width_changes_a_multiple_master_outline() {
326        // The reason the SPEC key had to grow. The two generic fallback faces
327        // are Multiple Master, and the width axis is solved from `dest_width`
328        // — so two requests differing only in that field must not share an
329        // entry, and must not produce the same outline either.
330        let (source, _) = subst::builtin_generic(false);
331        let gid = Gid(source.name_index(b"A"));
332        assert_ne!(gid.0, 0, "the fallback face has an `A`");
333
334        let narrow = source.outline(
335            gid,
336            GlyphParams {
337                dest_width: 200,
338                weight: 400,
339            },
340        );
341        let wide = source.outline(
342            gid,
343            GlyphParams {
344                dest_width: 900,
345                weight: 400,
346            },
347        );
348        let (Some(narrow), Some(wide)) = (narrow, wide) else {
349            panic!("both instantiations must draw");
350        };
351        assert_ne!(
352            format!("{narrow:?}"),
353            format!("{wide:?}"),
354            "the width axis must actually move the outline"
355        );
356    }
357
358    #[test]
359    fn weight_changes_a_multiple_master_outline() {
360        let (source, _) = subst::builtin_generic(false);
361        let gid = Gid(source.name_index(b"A"));
362        let light = source.outline(
363            gid,
364            GlyphParams {
365                dest_width: 0,
366                weight: 100,
367            },
368        );
369        let heavy = source.outline(
370            gid,
371            GlyphParams {
372                dest_width: 0,
373                weight: 900,
374            },
375        );
376        let (Some(light), Some(heavy)) = (light, heavy) else {
377            panic!("both instantiations must draw");
378        };
379        assert_ne!(format!("{light:?}"), format!("{heavy:?}"));
380    }
381
382    #[test]
383    fn a_base14_face_ignores_the_variation_fields() {
384        // A bare CFF has no design space, so the extra key fields are inert
385        // for it — which is exactly why they were easy to leave out and wrong
386        // to leave out.
387        let font = helvetica();
388        let gid = Gid(font.glyphs().name_index(b"A"));
389        let a = font.glyphs().outline(
390            gid,
391            GlyphParams {
392                dest_width: 100,
393                weight: 100,
394            },
395        );
396        let b = font.glyphs().outline(
397            gid,
398            GlyphParams {
399                dest_width: 900,
400                weight: 900,
401            },
402        );
403        assert_eq!(format!("{a:?}"), format!("{b:?}"));
404    }
405}