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}