Skip to main content

pdfrum_font/glyphs/
face.rs

1//! The `skrifa` / `read-fonts` adapter.
2//!
3//! PDFium drives FreeType, which carries a *selected charmap* as face state
4//! and mutates it as the glyph ladders walk. Selecting a charmap on a shared
5//! face is exactly the kind of hidden mutation this crate avoids, so the
6//! selection becomes a value — [`Charmap`] — that the ladders pass to every
7//! lookup. The ladders' sequence of "select this, try that" reads the same;
8//! nothing is hidden in the face.
9
10use crate::Gid;
11use pdfrum_common::kurbo::{BezPath, Rect};
12use read_fonts::TableProvider;
13use read_fonts::tables::cmap::PlatformId;
14use skrifa::MetadataProvider;
15use skrifa::instance::{LocationRef, Size};
16use skrifa::outline::{
17    DrawSettings, Engine as HintingEngine, HintingInstance, HintingOptions, OutlinePen,
18    Target as HintingTarget,
19};
20use std::collections::HashMap;
21use std::fmt;
22use std::sync::{Arc, OnceLock, RwLock};
23
24/// A charmap's `(platform, encoding)` identity, as the `cmap` table declares
25/// it.
26///
27/// PDFium compares these pairs literally — `(3,1)` for Windows Unicode,
28/// `(3,0)` for Windows Symbol, `(1,0)` for Mac Roman — and the *order* it
29/// prefers them in flips with the symbolic flag, so the pairs have to survive
30/// as data rather than being collapsed into a "best charmap".
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub struct CharmapId {
33    /// The `cmap` platform ID.
34    pub platform: u16,
35    /// The `cmap` encoding ID, whose meaning depends on the platform.
36    pub encoding: u16,
37}
38
39impl CharmapId {
40    /// Windows Unicode BMP — the charmap `UseTTCharmapUnicode` accepts outright.
41    pub const WINDOWS_UNICODE: Self = Self {
42        platform: 3,
43        encoding: 1,
44    };
45    /// Windows Symbol, the `0xF0xx` private-use charmap.
46    pub const WINDOWS_SYMBOL: Self = Self {
47        platform: 3,
48        encoding: 0,
49    };
50    /// Mac Roman.
51    pub const MAC_ROMAN: Self = Self {
52        platform: 1,
53        encoding: 0,
54    };
55    /// The synthesized Unicode charmap a Type 1 face exposes first.
56    pub const UNICODE_SYNTHETIC: Self = Self {
57        platform: 0,
58        encoding: 3,
59    };
60    /// A Type 1 face's own encoding vector, which FreeType reports as
61    /// `ADOBE_CUSTOM`.
62    pub const ADOBE_CUSTOM: Self = Self {
63        platform: 4,
64        encoding: 0,
65    };
66
67    /// Does this charmap map Unicode?
68    ///
69    /// Platform 0 is Unicode by definition and `(3,1)`/`(3,10)` are Windows'
70    /// Unicode encodings. This is FreeType's `FT_ENCODING_UNICODE` test, which
71    /// `UseTTCharmapUnicode` reads for any charmap that is not `(3,0)`.
72    #[must_use]
73    pub fn is_unicode(self) -> bool {
74        self.platform == 0 || (self.platform == 3 && (self.encoding == 1 || self.encoding == 10))
75    }
76
77    /// The `fxge`-level encoding this charmap reports, for the reverse lookups
78    /// of the former working note.
79    #[must_use]
80    pub(crate) fn face_encoding(self) -> crate::encoding::FaceEncoding {
81        use crate::encoding::FaceEncoding as E;
82        match (self.platform, self.encoding) {
83            (0, _) | (3, 1 | 10) => E::Unicode,
84            (3, 0) => E::Symbol,
85            (1, 0) => E::AppleRoman,
86            (4, _) => E::AdobeCustom,
87            _ => E::Other,
88        }
89    }
90}
91
92/// Which charmap a lookup reads.
93///
94/// A value rather than face state: PDFium's `FT_Set_Charmap` mutates the face,
95/// which would make every ladder order-dependent on a shared value.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
97pub enum Charmap {
98    /// The face's best Unicode charmap, chosen by `skrifa`.
99    #[default]
100    Unicode,
101    /// A specific subtable, by index into the `cmap` encoding records.
102    Index(usize),
103    /// No charmap: every lookup yields 0.
104    None,
105}
106
107/// Which reader answers for a face's bytes.
108///
109/// **A bare CFF has no table directory**, so `skrifa::FontRef` cannot open one
110/// — and all fourteen Foxit base-14 blobs are bare CFF, which makes this a
111/// requirement rather than a nicety. PDFium's own Rust bridge splits the same
112/// way (`Sfnt::new ?? CffFontRef::new ?? Type1Font::new`), so this is the
113/// shape upstream arrived at too.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115enum Backend {
116    /// A table-directory font: TrueType, OpenType/CFF, a collection member.
117    Sfnt,
118    /// A bare CFF font program, read through `read_fonts::ps::cff`.
119    BareCff,
120}
121
122/// A font face, owning its bytes.
123///
124/// The bytes are `Arc`'d and the reader is rebuilt per use rather than stored.
125/// Opening only validates a header, so this is a handful of bounds checks —
126/// cheap next to drawing a glyph, and it keeps the type free of the
127/// self-reference a borrowed `FontRef<'static>` would need.
128#[derive(Clone)]
129pub struct Face {
130    bytes: Arc<[u8]>,
131    index: u32,
132    backend: Backend,
133    upem: u16,
134    num_glyphs: u32,
135    is_truetype: bool,
136    charmaps: Vec<CharmapId>,
137    /// The 64-ppem hinting instance, built on first use.
138    ///
139    /// The one piece of state this type keeps, and it earns the exception by
140    /// measurement rather than by principle: building the instance runs the
141    /// face's `fpgm` and `prep`
142    /// programs and costs about **50 µs**, against 4 µs to rasterize a glyph
143    /// bitmap and 0.4 µs to blit one. Rebuilding it per glyph made a
144    /// text-heavy page 30% slower than filling outlines; keeping it makes the
145    /// same page faster.
146    ///
147    /// It cannot be a borrowed `HintingInstance<'_>` because there is no such
148    /// type — `skrifa`'s is owned, which is precisely what lets this sit beside
149    /// the bytes without the self-reference the doc above rules out.
150    ///
151    /// `None` inside the lock is a face that cannot be hinted at all, cached so
152    /// that a bare CFF does not re-attempt it once per glyph. The `Arc` shares
153    /// the lock across clones, so two fonts substituted onto one face pay for
154    /// the interpreter once between them.
155    hinting: Arc<OnceLock<Option<HintingInstance>>>,
156    /// Glyph name → the first glyph id carrying it, built on the first name
157    /// lookup. A simple font with `/Differences` looks up hundreds of names
158    /// against one face; scanning the `post` table per name was quadratic.
159    names: Arc<OnceLock<HashMap<Box<[u8]>, u16>>>,
160    /// Glyph id → the advance [`advance`](Self::advance) reported for it.
161    ///
162    /// The second measured exception, and for the same reason as
163    /// [`hinting`](Self::hinting): a **bare CFF** carries no `hmtx`, so the
164    /// only place an advance exists is inside the charstring, and reading it
165    /// means running the Type 2 interpreter over the glyph's whole outline
166    /// and discarding the path. Text extraction asks for a width once per
167    /// shown character — `Font::char_width` for every glyph the page draws,
168    /// then again through the extractor's own fallback ladder — so the same
169    /// handful of glyphs are drawn hundreds of times each. On
170    /// `text_tcpdf_063` that interpreter was **84% of the whole text run**.
171    ///
172    /// A map rather than a `num_glyphs`-long table because a CID font has
173    /// tens of thousands of glyphs and a page shows tens of them; a
174    /// `RwLock` rather than a `Mutex` because after the first few characters
175    /// every access is a read, and `TextPage` is `Send + Sync` precisely so
176    /// that pages extract in parallel. The `Arc` shares the cache across
177    /// clones, so two fonts substituted onto one face pay once between them.
178    advances: Arc<RwLock<HashMap<Gid, Option<f32>>>>,
179    /// Glyph id → the box [`glyph_bbox`](Self::glyph_bbox) reported for it,
180    /// cached for the same reason as [`advances`](Self::advances) and asked
181    /// for just as often -- once per shown character through
182    /// `TextRun::glyph_bbox`, and again by the width ladder's last rung.
183    ///
184    /// Each miss rebuilds a `skrifa::FontRef` and a whole `GlyphMetrics`
185    /// (`hmtx`, `loca`, `glyf`, the variation tables) to read one box, or,
186    /// on a CFF-flavoured face, draws the outline and measures it. On
187    /// `text_foxit_products` that was **28% of the whole text run**, over
188    /// half of it inside `GlyphMetrics::new`.
189    boxes: Arc<RwLock<HashMap<Gid, Option<Rect>>>>,
190}
191
192impl fmt::Debug for Face {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.debug_struct("Face")
195            .field("bytes", &format_args!("{} bytes", self.bytes.len()))
196            .field("index", &self.index)
197            .field("backend", &self.backend)
198            .field("upem", &self.upem)
199            .field("num_glyphs", &self.num_glyphs)
200            .field("is_truetype", &self.is_truetype)
201            .field("charmaps", &self.charmaps)
202            .field("hinting", &self.hinting.get().map(Option::is_some))
203            .field("names", &self.names.get().map(HashMap::len))
204            .field(
205                "advances",
206                &self.advances.read().map(|cache| cache.len()).ok(),
207            )
208            .field("boxes", &self.boxes.read().map(|cache| cache.len()).ok())
209            .finish()
210    }
211}
212
213impl Face {
214    /// Read a face from a font program.
215    ///
216    /// Accepts anything with a table directory — TrueType, bare CFF,
217    /// OpenType/CFF, and a TrueType Collection member by `index`. Returns
218    /// `None` for a blob no backend recognises, which is the signal to fall
219    /// back to [`pdfrum_type1`] and then to substitution.
220    #[must_use]
221    pub fn new(bytes: Arc<[u8]>, index: u32) -> Option<Self> {
222        // A table directory first, then a bare CFF. The order matters only in
223        // that an SFNT is unambiguous while a bare CFF is identified by a very
224        // short header, so trying the specific format first avoids a false
225        // positive on a truncated SFNT.
226        Self::from_sfnt(&bytes, index).or_else(|| Self::from_bare_cff(bytes, index))
227    }
228
229    /// Read a table-directory font.
230    fn from_sfnt(bytes: &Arc<[u8]>, index: u32) -> Option<Self> {
231        let font = skrifa::FontRef::from_index(bytes.as_ref(), index).ok()?;
232        let upem = font.head().map_or(0, |h| h.units_per_em());
233        let num_glyphs = u32::from(font.maxp().ok()?.num_glyphs());
234        // `glyf` present means outlines are quadratic TrueType splines; a
235        // bare or wrapped CFF has none. PDFium asks FreeType the same question
236        // through `FT_IS_SFNT` plus the driver name.
237        let is_truetype = font.glyf().is_ok();
238        let charmaps: Vec<CharmapId> = font
239            .cmap()
240            .map(|cmap| {
241                cmap.encoding_records()
242                    .iter()
243                    .map(|rec| CharmapId {
244                        platform: platform_ordinal(rec.platform_id()),
245                        encoding: rec.encoding_id(),
246                    })
247                    .collect()
248            })
249            .unwrap_or_default();
250        Some(Self {
251            bytes: Arc::clone(bytes),
252            index,
253            backend: Backend::Sfnt,
254            upem,
255            num_glyphs,
256            is_truetype,
257            charmaps,
258            hinting: Arc::default(),
259            names: Arc::default(),
260            advances: Arc::default(),
261            boxes: Arc::default(),
262        })
263    }
264
265    /// Read a bare CFF font program.
266    ///
267    /// It reports exactly one charmap — its built-in encoding, which FreeType
268    /// surfaces as `ADOBE_CUSTOM` — plus a synthesized Unicode one from its
269    /// glyph names, matching the shape a Type 1 face presents. That is what
270    /// makes the Type 1 ladder's `UseType1Charmap` step behave the same for a
271    /// bare CFF as for a PFB, which is what PDFium's FreeType backend does.
272    fn from_bare_cff(bytes: Arc<[u8]>, index: u32) -> Option<Self> {
273        let cff = read_fonts::ps::cff::CffFontRef::new(bytes.as_ref(), 0, None).ok()?;
274        let num_glyphs = cff.num_glyphs();
275        let upem = u16::try_from(cff.upem()).unwrap_or(1000);
276        Some(Self {
277            bytes,
278            index,
279            backend: Backend::BareCff,
280            upem,
281            num_glyphs,
282            // CFF outlines are cubic charstrings, never `glyf` splines.
283            is_truetype: false,
284            charmaps: vec![CharmapId::UNICODE_SYNTHETIC, CharmapId::ADOBE_CUSTOM],
285            hinting: Arc::default(),
286            names: Arc::default(),
287            advances: Arc::default(),
288            boxes: Arc::default(),
289        })
290    }
291
292    /// Open the bare-CFF reader, when that is this face's backend.
293    fn cff(&self) -> Option<read_fonts::ps::cff::CffFontRef<'_>> {
294        if self.backend != Backend::BareCff {
295            return None;
296        }
297        read_fonts::ps::cff::CffFontRef::new(&self.bytes, 0, None).ok()
298    }
299
300    /// The index a bare CFF actually stores a glyph under.
301    ///
302    /// For an ordinary CFF this is the number it was handed. For a **CID-keyed**
303    /// one it is not: the composite-font layer above hands down a CID, because
304    /// that is what PDFium hands FreeType, and FreeType silently maps it
305    /// through the font's charset. A subsetted CID-keyed program holds a
306    /// handful of glyphs numbered from zero while its CIDs are wherever the
307    /// original collection put them, so skipping the mapping asks for a glyph
308    /// number that does not exist and the font draws nothing at all.
309    fn cff_glyph_id(
310        cff: &read_fonts::ps::cff::CffFontRef<'_>,
311        gid: Gid,
312    ) -> read_fonts::types::GlyphId {
313        let raw = read_fonts::types::GlyphId::new(u32::from(gid.0));
314        if !cff.is_cid() {
315            return raw;
316        }
317        // In a CID-keyed font the charset's string identifiers *are* CIDs.
318        cff.charset()
319            .and_then(|charset| {
320                charset
321                    .glyph_id(read_fonts::ps::string::Sid::new(gid.0))
322                    .ok()
323            })
324            .unwrap_or(raw)
325    }
326
327    /// Design units per em.
328    #[must_use]
329    pub fn units_per_em(&self) -> u16 {
330        self.upem
331    }
332
333    /// How many glyphs the face declares.
334    #[must_use]
335    pub fn num_glyphs(&self) -> u32 {
336        self.num_glyphs
337    }
338
339    /// Whether outlines come from a `glyf` table.
340    #[must_use]
341    pub fn is_truetype(&self) -> bool {
342        self.is_truetype
343    }
344
345    /// The `(platform, encoding)` pairs the `cmap` table declares, in table
346    /// order — which is the order every ladder scans them in.
347    #[must_use]
348    pub fn charmaps(&self) -> Vec<CharmapId> {
349        self.charmaps.clone()
350    }
351
352    /// The glyph a code selects through `charmap`. Zero on any miss.
353    #[must_use]
354    pub fn char_index(&self, charmap: Charmap, code: u32) -> u16 {
355        if let Some(cff) = self.cff() {
356            // A bare CFF has two routes: its built-in encoding for a byte
357            // code, and its glyph names through the Adobe Glyph List for a
358            // Unicode. Which one applies is exactly the distinction
359            // `UseType1Charmap` draws.
360            let gid = match charmap {
361                Charmap::None => None,
362                Charmap::Unicode => self.cff_unicode_to_gid(code),
363                Charmap::Index(_) => u8::try_from(code).ok().and_then(|b| cff.encoding()?.map(b)),
364            };
365            return gid
366                .and_then(|g| u16::try_from(g.to_u32()).ok())
367                .unwrap_or(0);
368        }
369
370        let Ok(font) = skrifa::FontRef::from_index(&self.bytes, self.index) else {
371            return 0;
372        };
373        let gid = match charmap {
374            Charmap::None => None,
375            Charmap::Unicode => font.charmap().map(code),
376            Charmap::Index(i) => font
377                .cmap()
378                .ok()
379                .and_then(|cmap| {
380                    let rec = cmap.encoding_records().get(i)?;
381                    rec.subtable(cmap.offset_data()).ok()
382                })
383                .and_then(|sub| sub.map_codepoint(code)),
384        };
385        gid.and_then(|g| u16::try_from(g.to_u32()).ok())
386            .unwrap_or(0)
387    }
388
389    /// A bare CFF's synthesized Unicode charmap: glyph names through the AGL.
390    fn cff_unicode_to_gid(&self, code: u32) -> Option<read_fonts::types::GlyphId> {
391        let ch = char::from_u32(code)?;
392        let mut buf = [0u8; read_fonts::ps::agl::MAX_NAME_LEN];
393        let name = read_fonts::ps::agl::char_to_name(u32::from(ch), &mut buf)?;
394        let gid = self.name_index(name);
395        (gid != 0).then(|| read_fonts::types::GlyphId::new(u32::from(gid)))
396    }
397
398    /// Scan a bare CFF's charset for a glyph name.
399    /// One-pass twin of the per-name scan this replaced: every name the
400    /// face carries, mapped to the **first** glyph id that has it.
401    fn build_name_map(&self) -> HashMap<Box<[u8]>, u16> {
402        if let Some(cff) = self.cff() {
403            let Some(charset) = cff.charset() else {
404                return HashMap::new();
405            };
406            let mut map = HashMap::new();
407            for gid in 0..self.num_glyphs {
408                let Ok(g) = u16::try_from(gid) else { break };
409                let Ok(sid) = charset.string_id(read_fonts::types::GlyphId::new(gid)) else {
410                    continue;
411                };
412                if let Some(bytes) = cff.string(sid) {
413                    map.entry(bytes.into()).or_insert(g);
414                }
415            }
416            return map;
417        }
418        let Ok(font) = skrifa::FontRef::from_index(&self.bytes, self.index) else {
419            return HashMap::new();
420        };
421        let Ok(post) = font.post() else {
422            return HashMap::new();
423        };
424        let default_names = &read_fonts::tables::post::DEFAULT_GLYPH_NAMES;
425        let mut map = HashMap::new();
426        if post.version() == read_fonts::types::Version16Dot16::VERSION_1_0 {
427            for (gid, name) in default_names
428                .iter()
429                .enumerate()
430                .take(self.num_glyphs as usize)
431            {
432                let Ok(g) = u16::try_from(gid) else { break };
433                map.entry(name.as_bytes().into()).or_insert(g);
434            }
435            return map;
436        }
437        if post.version() != read_fonts::types::Version16Dot16::VERSION_2_0 {
438            return map;
439        }
440        let Some(index) = post.glyph_name_index() else {
441            return map;
442        };
443        // The custom strings are a Pascal-string array: read it once,
444        // sequentially, instead of walking it from the start per glyph.
445        let strings: Vec<&str> = post
446            .string_data()
447            .map(|d| d.iter().map_while(Result::ok).map(|s| s.as_str()).collect())
448            .unwrap_or_default();
449        for gid in 0..self.num_glyphs {
450            let Ok(g) = u16::try_from(gid) else { break };
451            let Some(idx) = index.get(gid as usize) else {
452                break;
453            };
454            let idx = usize::from(idx.get());
455            let name = if idx < default_names.len() {
456                default_names.get(idx).copied()
457            } else {
458                strings.get(idx - default_names.len()).copied()
459            };
460            if let Some(name) = name {
461                map.entry(name.as_bytes().into()).or_insert(g);
462            }
463        }
464        map
465    }
466
467    /// The glyph a name selects. Zero on a miss.
468    #[must_use]
469    pub fn name_index(&self, name: &str) -> u16 {
470        self.names
471            .get_or_init(|| self.build_name_map())
472            .get(name.as_bytes())
473            .copied()
474            .unwrap_or(0)
475    }
476
477    /// The scan [`Face::build_name_map`] replaced, kept as the test oracle
478    /// for it: the first glyph whose name matches, zero on a miss.
479    #[cfg(test)]
480    pub(crate) fn name_index_by_scan(&self, name: &str) -> u16 {
481        if let Some(cff) = self.cff() {
482            let Some(charset) = cff.charset() else {
483                return 0;
484            };
485            for gid in 0..self.num_glyphs {
486                let Ok(g) = u16::try_from(gid) else { break };
487                let id = read_fonts::types::GlyphId::new(gid);
488                let Ok(sid) = charset.string_id(id) else {
489                    continue;
490                };
491                if cff.string(sid) == Some(name.as_bytes()) {
492                    return g;
493                }
494            }
495            return 0;
496        }
497        let Ok(font) = skrifa::FontRef::from_index(&self.bytes, self.index) else {
498            return 0;
499        };
500        let Ok(post) = font.post() else { return 0 };
501        for gid in 0..self.num_glyphs {
502            let Ok(g) = u16::try_from(gid) else { break };
503            if post.glyph_name(read_fonts::types::GlyphId16::new(g)) == Some(name) {
504                return g;
505            }
506        }
507        0
508    }
509
510    /// A glyph's own name.
511    #[must_use]
512    pub fn glyph_name(&self, gid: Gid) -> Option<String> {
513        if let Some(cff) = self.cff() {
514            let sid = cff
515                .charset()?
516                .string_id(read_fonts::types::GlyphId::new(u32::from(gid.0)))
517                .ok()?;
518            return cff
519                .string(sid)
520                .and_then(|b| std::str::from_utf8(b).ok())
521                .map(ToOwned::to_owned);
522        }
523        let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
524        font.post()
525            .ok()?
526            .glyph_name(read_fonts::types::GlyphId16::new(gid.0))
527            .map(ToOwned::to_owned)
528    }
529
530    /// Whether the face carries glyph names at all.
531    #[must_use]
532    pub fn has_glyph_names(&self) -> bool {
533        if self.backend == Backend::BareCff {
534            // A CFF charset always names its glyphs.
535            return self.cff().and_then(|c| c.charset()).is_some();
536        }
537        skrifa::FontRef::from_index(&self.bytes, self.index)
538            .ok()
539            .and_then(|f| f.post().ok())
540            .is_some_and(|p| p.glyph_name(read_fonts::types::GlyphId16::new(0)).is_some())
541    }
542
543    /// The pixels-per-em every hinted glyph is grid-fitted at.
544    ///
545    /// A pinned constant rather than the glyph's real size: hinting always
546    /// fits to a 64-pixel grid, and the size the glyph is actually drawn at
547    /// is applied afterwards as a plain scale. Grid-fitting at a pinned ppem
548    /// is therefore *not* the same thing as grid-fitting at the drawn size.
549    ///
550    /// The measured consequence: this moves outline points by about 1/25 of a
551    /// device pixel at 9 pt, which is up to 10 counts per pixel on a 6 pt stem
552    /// once the glyph is rasterized.
553    // Where the 64 comes from: `CFX_Face::New` calls
554    // `FT_Set_Pixel_Sizes(rec, 64, 64)` once (`cfx_face.cpp:376`) and nothing
555    // ever changes it; the real size reaches FreeType through
556    // `FT_Set_Transform` with the matrix pre-divided by 64
557    // (`cfx_face.cpp:822-825`). FreeType applies a transform *after* hinting,
558    // so the interpreter fits to a 64-pixel grid whose alignment is then
559    // scaled away.
560    pub(crate) const HINT_PPEM: f32 = 64.0;
561
562    /// A glyph's outline grid-fitted at [`Self::HINT_PPEM`], in **64ths of an
563    /// em** — the units a 64-ppem instance draws in.
564    ///
565    /// `None` for every face that is not hinted, and that is exactly the
566    /// faces with **no table directory**: a bare CFF is never hinted, which
567    /// matters because all fourteen base-14 blobs are bare CFF.
568    ///
569    /// It is also `None` when the interpreter refuses the face's own
570    /// programs, in which case the caller falls back to the unhinted
571    /// [`Self::outline`] rather than drawing nothing.
572    ///
573    /// Building the instance costs about 50 µs — the face's `fpgm` and `prep`
574    /// programs run — so it is memoized per face rather than per glyph. See
575    /// [`Self::hinting`].
576    // The two `None` arms restate one upstream rule each.
577    // `CFX_Face::RenderGlyph` adds `FT_LOAD_NO_HINTING` exactly when
578    // `!IsTtOt()` — no `FT_FACE_FLAG_SFNT`, i.e. no table directory
579    // (`cfx_face.cpp:841-843`). And a glyph is loaded `FT_LOAD_PEDANTIC`; on
580    // an error `cfx_face.cpp:849-857` reloads it *unhinted* rather than
581    // failing, which is the same place our second `None` sends the caller.
582    #[must_use]
583    pub(crate) fn hinted_outline(&self, gid: Gid) -> Option<BezPath> {
584        let instance = self.hinting_instance()?;
585        let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
586        let glyph = font
587            .outline_glyphs()
588            .get(skrifa::GlyphId::new(u32::from(gid.0)))?;
589        let mut pen = PathPen::default();
590        glyph
591            .draw(DrawSettings::hinted(instance, false), &mut pen)
592            .ok()?;
593        Some(pen.path)
594    }
595
596    /// The memoized 64-ppem hinting instance, built on first use.
597    fn hinting_instance(&self) -> Option<&HintingInstance> {
598        self.hinting
599            .get_or_init(|| {
600                if self.backend != Backend::Sfnt {
601                    return None;
602                }
603                let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
604                // `Engine::Interpreter` rather than the default
605                // `AutoFallback`: the autofitter is compiled out of the
606                // oracle's FreeType (`ftmodule.h`), so a face with no
607                // `fpgm`/`prep` gets no hinting there at all, and falling back
608                // to an autohinter here would invent grid-fitting the oracle
609                // never applies. `Target::Smooth`'s default `Normal` mode is
610                // `FT_RENDER_MODE_NORMAL`, which is what `RenderGlyph` selects
611                // by passing no `FT_LOAD_TARGET_*` at all.
612                HintingInstance::new(
613                    &font.outline_glyphs(),
614                    Size::new(Self::HINT_PPEM),
615                    LocationRef::default(),
616                    HintingOptions {
617                        engine: HintingEngine::Interpreter,
618                        target: HintingTarget::default(),
619                    },
620                )
621                .ok()
622            })
623            .as_ref()
624    }
625
626    /// Whether `gid` is a composite glyph that carries its own instructions.
627    ///
628    /// Such a glyph is only partly described by its component offsets: the
629    /// bytecode moves the components into their final places, so the
630    /// translations alone put them somewhere the design never intended. A
631    /// face that builds its glyphs this way — stroke-assembled CJK faces are
632    /// the usual example — needs the interpreter run before its outlines mean
633    /// anything, which is what [`GlyphSource::outline`] uses this to decide.
634    ///
635    /// `false` for a simple glyph, for a composite with no instructions, and
636    /// for every face with no `glyf` table at all.
637    ///
638    /// [`GlyphSource::outline`]: super::GlyphSource::outline
639    #[must_use]
640    pub(crate) fn composite_is_instructed(&self, gid: Gid) -> bool {
641        let Ok(font) = skrifa::FontRef::from_index(&self.bytes, self.index) else {
642            return false;
643        };
644        let (Ok(glyf), Ok(loca)) = (font.glyf(), font.loca(None)) else {
645            return false;
646        };
647        let raw = read_fonts::types::GlyphId::new(u32::from(gid.0));
648        match loca.get_glyf(raw, &glyf) {
649            Ok(Some(read_fonts::tables::glyf::Glyph::Composite(c))) => {
650                c.count_and_instructions().1.is_some_and(|i| !i.is_empty())
651            }
652            _ => false,
653        }
654    }
655
656    /// A glyph's outline in **font units**, unhinted.
657    ///
658    /// Unhinted at every size, for every face — this is the *path* side of
659    /// text, which is never grid-fitted. The glyph-*bitmap* side is a
660    /// different rule and a different function: see [`Self::hinted_outline`].
661    // Unconditionally unhinted is not a simplification. `CFX_Face::LoadGlyphPath`
662    // hints only a face that is both SFNT and on FreeType's ~20-font "tricky"
663    // list (`cfx_face.cpp:948-951`); `skrifa` does not model that list and no
664    // corpus font is on it, so the hinted arm is unreachable either way.
665    #[must_use]
666    pub(crate) fn outline(&self, gid: Gid) -> Option<BezPath> {
667        let mut pen = PathPen::default();
668        if let Some(cff) = self.cff() {
669            let id = Self::cff_glyph_id(&cff, gid);
670            let subfont_index = cff.subfont_index(id)?;
671            let subfont = cff.subfont(subfont_index, &[]).ok()?;
672            // `ppem: None` means unscaled font units, which is the same
673            // request the SFNT path makes through `Size::unscaled`.
674            cff.draw(&subfont, id, &[], None, &mut pen).ok()?;
675            return Some(pen.path);
676        }
677        let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
678        let glyph = font
679            .outline_glyphs()
680            .get(skrifa::GlyphId::new(u32::from(gid.0)))?;
681        glyph
682            .draw(
683                DrawSettings::unhinted(Size::unscaled(), LocationRef::default()),
684                &mut pen,
685            )
686            .ok()?;
687        Some(pen.path)
688    }
689
690    /// A glyph's advance in font units.
691    ///
692    /// A bare CFF carries no `hmtx`: the advance comes out of the charstring
693    /// itself, which is why drawing is how it is read.
694    #[must_use]
695    pub(crate) fn advance(&self, gid: Gid) -> Option<f32> {
696        if let Ok(cache) = self.advances.read()
697            && let Some(hit) = cache.get(&gid)
698        {
699            return *hit;
700        }
701        let computed = self.advance_uncached(gid);
702        if let Ok(mut cache) = self.advances.write() {
703            cache.insert(gid, computed);
704        }
705        computed
706    }
707
708    /// [`advance`](Self::advance) with the cache bypassed.
709    #[must_use]
710    fn advance_uncached(&self, gid: Gid) -> Option<f32> {
711        if let Some(cff) = self.cff() {
712            let id = Self::cff_glyph_id(&cff, gid);
713            let subfont_index = cff.subfont_index(id)?;
714            let subfont = cff.subfont(subfont_index, &[]).ok()?;
715            let mut pen = PathPen::default();
716            return cff.draw(&subfont, id, &[], None, &mut pen).ok().flatten();
717        }
718        let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
719        font.glyph_metrics(Size::unscaled(), LocationRef::default())
720            .advance_width(skrifa::GlyphId::new(u32::from(gid.0)))
721    }
722
723    /// A glyph's bounding box in font units, y-up.
724    ///
725    /// The fast path is the `glyf` table's per-glyph bounds, which only a
726    /// TrueType-outlined face has. A **CFF-flavoured** OpenType face has no
727    /// such table — its bounds live inside each charstring — so it falls
728    /// through to measuring the outline, exactly as the C++'s FreeType
729    /// backend does by loading the glyph and reading its control box. Getting
730    /// this wrong makes every glyph of a CFF font report a zero box, which
731    /// text extraction reads as a degenerate text object and drops whole.
732    #[must_use]
733    pub(crate) fn glyph_bbox(&self, gid: Gid) -> Option<Rect> {
734        if let Ok(cache) = self.boxes.read()
735            && let Some(hit) = cache.get(&gid)
736        {
737            return *hit;
738        }
739        let computed = self.glyph_bbox_uncached(gid);
740        if let Ok(mut cache) = self.boxes.write() {
741            cache.insert(gid, computed);
742        }
743        computed
744    }
745
746    /// [`glyph_bbox`](Self::glyph_bbox) with the cache bypassed.
747    #[must_use]
748    fn glyph_bbox_uncached(&self, gid: Gid) -> Option<Rect> {
749        if self.backend != Backend::BareCff {
750            let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
751            if let Some(b) = font
752                .glyph_metrics(Size::unscaled(), LocationRef::default())
753                .bounds(skrifa::GlyphId::new(u32::from(gid.0)))
754            {
755                return Some(Rect::new(
756                    f64::from(b.x_min),
757                    f64::from(b.y_min),
758                    f64::from(b.x_max),
759                    f64::from(b.y_max),
760                ));
761            }
762        }
763        let path = self.outline(gid)?;
764        let b = pdfrum_common::kurbo::Shape::bounding_box(&path);
765        (b.width() > 0.0 || b.height() > 0.0).then_some(b)
766    }
767
768    /// The raw metrics `CheckFontMetrics` derives a bounding box from.
769    #[must_use]
770    pub(crate) fn metrics(&self) -> Option<crate::descriptor::FaceMetrics> {
771        if self.backend == Backend::BareCff {
772            // A bare CFF declares no `head` or `hhea`; PDFium's FreeType
773            // backend synthesizes the same nothing, and the caller's
774            // per-code union then supplies the box.
775            return None;
776        }
777        let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
778        let head = font.head().ok()?;
779        let hhea = font.hhea().ok()?;
780        Some(crate::descriptor::FaceMetrics {
781            upem: head.units_per_em(),
782            bbox_left: i64::from(head.x_min()),
783            bbox_top: i64::from(head.y_max()),
784            bbox_right: i64::from(head.x_max()),
785            bbox_bottom: i64::from(head.y_min()),
786            ascender: i64::from(hhea.ascender().to_i16()),
787            descender: i64::from(hhea.descender().to_i16()),
788        })
789    }
790
791    /// The face's own bytes, for the `GSUB` reader.
792    #[must_use]
793    pub(crate) fn bytes(&self) -> &Arc<[u8]> {
794        &self.bytes
795    }
796
797    /// The face index within a collection.
798    #[must_use]
799    pub(crate) fn index(&self) -> u32 {
800        self.index
801    }
802
803    /// The family and style names, joined as PDFium's `GetFontNameFromFace`
804    /// joins them: family, then a space and the style unless the style is
805    /// empty or `Regular`.
806    #[must_use]
807    pub(crate) fn display_name(&self) -> Option<String> {
808        if let Some(cff) = self.cff() {
809            let meta = cff.metadata()?;
810            return meta
811                .family_name()
812                .or_else(|| meta.name())
813                .map(ToOwned::to_owned);
814        }
815        let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
816        let strings = font.localized_strings(skrifa::string::StringId::FAMILY_NAME);
817        let family: String = strings.english_or_first()?.chars().collect();
818        if family.is_empty() {
819            return None;
820        }
821        let style: String = font
822            .localized_strings(skrifa::string::StringId::SUBFAMILY_NAME)
823            .english_or_first()
824            .map(|s| s.chars().collect())
825            .unwrap_or_default();
826        if style.is_empty() || style == "Regular" {
827            Some(family)
828        } else {
829            Some(format!("{family} {style}"))
830        }
831    }
832
833    /// The PostScript name (name ID 6), falling back to the family name.
834    #[must_use]
835    pub fn postscript_name(&self) -> Option<String> {
836        if let Some(cff) = self.cff() {
837            let meta = cff.metadata()?;
838            return meta
839                .name()
840                .or_else(|| meta.family_name())
841                .map(ToOwned::to_owned);
842        }
843        let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
844        let ps: String = font
845            .localized_strings(skrifa::string::StringId::POSTSCRIPT_NAME)
846            .english_or_first()
847            .map(|s| s.chars().collect())
848            .unwrap_or_default();
849        if !ps.is_empty() {
850            return Some(ps);
851        }
852        self.display_name()
853    }
854
855    /// `post.isFixedPitch`, or false when the table is missing.
856    #[must_use]
857    pub fn is_fixed_pitch(&self) -> bool {
858        let Ok(font) = skrifa::FontRef::from_index(&self.bytes, self.index) else {
859            return false;
860        };
861        font.post().is_ok_and(|p| p.is_fixed_pitch() != 0)
862    }
863
864    /// Italic from OS/2 `fsSelection`, `head.macStyle`, or a non-zero `post.italicAngle`.
865    #[must_use]
866    pub fn is_italic(&self) -> bool {
867        let Ok(font) = skrifa::FontRef::from_index(&self.bytes, self.index) else {
868            return false;
869        };
870        if let Ok(os2) = font.os2() {
871            let sel = os2.fs_selection();
872            if sel.contains(read_fonts::tables::os2::SelectionFlags::ITALIC)
873                || sel.contains(read_fonts::tables::os2::SelectionFlags::OBLIQUE)
874            {
875                return true;
876            }
877        }
878        if font.head().is_ok_and(|h| {
879            h.mac_style()
880                .contains(read_fonts::tables::head::MacStyle::ITALIC)
881        }) {
882            return true;
883        }
884        font.post().is_ok_and(|p| p.italic_angle().to_f64() != 0.0)
885    }
886
887    /// Bold from OS/2 `fsSelection` / `usWeightClass >= 700`, or `head.macStyle`.
888    #[must_use]
889    pub fn is_bold(&self) -> bool {
890        let Ok(font) = skrifa::FontRef::from_index(&self.bytes, self.index) else {
891            return false;
892        };
893        if let Ok(os2) = font.os2() {
894            if os2
895                .fs_selection()
896                .contains(read_fonts::tables::os2::SelectionFlags::BOLD)
897            {
898                return true;
899            }
900            if os2.us_weight_class() >= 700 {
901                return true;
902            }
903        }
904        font.head().is_ok_and(|h| {
905            h.mac_style()
906                .contains(read_fonts::tables::head::MacStyle::BOLD)
907        })
908    }
909
910    /// OS/2 `sCapHeight` in font units, when the table is version 2 or later.
911    #[must_use]
912    pub fn cap_height(&self) -> Option<f32> {
913        let font = skrifa::FontRef::from_index(&self.bytes, self.index).ok()?;
914        font.os2().ok()?.s_cap_height().map(f32::from)
915    }
916
917    /// Unicode codepoint → glyph mappings of the Unicode cmap, with `code <= max`.
918    ///
919    /// Sorted by codepoint. Glyph 0 (`.notdef`) is omitted, matching
920    /// `FT_Get_Next_Char`'s `glyph_index == 0` stop.
921    #[must_use]
922    pub fn unicode_mappings(&self, max: u32) -> Vec<(u32, u16)> {
923        if self.backend == Backend::BareCff {
924            return (0..=max)
925                .filter_map(|cp| {
926                    let gid = self.char_index(Charmap::Unicode, cp);
927                    (gid != 0).then_some((cp, gid))
928                })
929                .collect();
930        }
931        let Ok(font) = skrifa::FontRef::from_index(&self.bytes, self.index) else {
932            return Vec::new();
933        };
934        let mut out: Vec<(u32, u16)> = font
935            .charmap()
936            .mappings()
937            .filter_map(|(cp, gid)| {
938                if cp > max {
939                    return None;
940                }
941                let g = u16::try_from(gid.to_u32()).ok()?;
942                (g != 0).then_some((cp, g))
943            })
944            .collect();
945        out.sort_unstable_by_key(|(cp, _)| *cp);
946        out.dedup_by_key(|(cp, _)| *cp);
947        out
948    }
949}
950
951fn platform_ordinal(p: PlatformId) -> u16 {
952    match p {
953        PlatformId::Unicode => 0,
954        PlatformId::Macintosh => 1,
955        PlatformId::ISO => 2,
956        PlatformId::Windows => 3,
957        PlatformId::Custom => 4,
958        // A malformed platform id must not collide with a real one.
959        PlatformId::Unknown => u16::MAX,
960    }
961}
962
963/// Collects `skrifa`'s outline verbs into a `kurbo` path.
964///
965/// Quadratics are elevated to cubics rather than kept, matching the
966/// `ConvertOutline` step PDFium's own Fontations bridge performs so FreeType's
967/// decomposition and this one agree.
968#[derive(Default)]
969struct PathPen {
970    path: BezPath,
971    current: (f32, f32),
972    open: bool,
973}
974
975impl OutlinePen for PathPen {
976    fn move_to(&mut self, x: f32, y: f32) {
977        if self.open {
978            self.path.close_path();
979        }
980        self.path.move_to((f64::from(x), f64::from(y)));
981        self.current = (x, y);
982        self.open = true;
983    }
984
985    fn line_to(&mut self, x: f32, y: f32) {
986        if self.open {
987            self.path.line_to((f64::from(x), f64::from(y)));
988            self.current = (x, y);
989        }
990    }
991
992    fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
993        if !self.open {
994            return;
995        }
996        // The standard quadratic-to-cubic elevation: each cubic control point
997        // sits two thirds of the way from an endpoint to the quadratic's.
998        let (px, py) = self.current;
999        let c1 = (
1000            f64::from(px) + 2.0 / 3.0 * f64::from(cx0 - px),
1001            f64::from(py) + 2.0 / 3.0 * f64::from(cy0 - py),
1002        );
1003        let c2 = (
1004            f64::from(cx0) + f64::from(x - cx0) / 3.0,
1005            f64::from(cy0) + f64::from(y - cy0) / 3.0,
1006        );
1007        self.path.curve_to(c1, c2, (f64::from(x), f64::from(y)));
1008        self.current = (x, y);
1009    }
1010
1011    fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
1012        if !self.open {
1013            return;
1014        }
1015        self.path.curve_to(
1016            (f64::from(cx0), f64::from(cy0)),
1017            (f64::from(cx1), f64::from(cy1)),
1018            (f64::from(x), f64::from(y)),
1019        );
1020        self.current = (x, y);
1021    }
1022
1023    fn close(&mut self) {
1024        if self.open {
1025            self.path.close_path();
1026            self.open = false;
1027        }
1028    }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    /// The one-pass map answers exactly what the per-name scan answered, for
1034    /// every name every fixture face carries, and zero for a name none has.
1035    #[test]
1036    fn the_name_map_answers_what_the_scan_answered() {
1037        let fixtures = [
1038            "tt_custom_40.ttf",
1039            "tt_macroman_10.ttf",
1040            "tt_macroman_empty.ttf",
1041            "tt_named_no_cmap.ttf",
1042            "tt_sjis_and_unicode.ttf",
1043            "tt_symbol_30.ttf",
1044            "tt_symbol_and_macroman.ttf",
1045            "tt_symbol_empty.ttf",
1046            "tt_unicode_03_and_symbol.ttf",
1047            "tt_unicode_03.ttf",
1048            "tt_unicode_31_and_symbol.ttf",
1049            "tt_unicode_31.ttf",
1050        ];
1051        let mut named_faces = 0;
1052        for fixture in fixtures {
1053            let bytes: Arc<[u8]> = crate::testfonts::load(fixture).into();
1054            let face = Face::new(bytes, 0).unwrap();
1055            let map = face.build_name_map();
1056            named_faces += usize::from(!map.is_empty());
1057            for (name, gid) in &map {
1058                let name = std::str::from_utf8(name).unwrap();
1059                let scanned = face.name_index_by_scan(name);
1060                assert_eq!(*gid, scanned, "{fixture}: {name}");
1061                assert_eq!(face.name_index(name), scanned, "{fixture}: {name}");
1062            }
1063            assert_eq!(face.name_index("nonesuch"), 0, "{fixture}");
1064            assert_eq!(face.name_index_by_scan("nonesuch"), 0, "{fixture}");
1065        }
1066        assert!(
1067            named_faces > 0,
1068            "no fixture carries glyph names; the pin proves nothing"
1069        );
1070    }
1071
1072    use super::*;
1073
1074    #[test]
1075    fn charmap_ids_classify_unicode_correctly() {
1076        assert!(CharmapId::WINDOWS_UNICODE.is_unicode());
1077        assert!(CharmapId::UNICODE_SYNTHETIC.is_unicode());
1078        assert!(
1079            CharmapId {
1080                platform: 3,
1081                encoding: 10
1082            }
1083            .is_unicode()
1084        );
1085        // `(3,0)` is Windows *Symbol*, deliberately not Unicode — the whole
1086        // `UseTTCharmapUnicode` rule turns on that distinction.
1087        assert!(!CharmapId::WINDOWS_SYMBOL.is_unicode());
1088        assert!(!CharmapId::MAC_ROMAN.is_unicode());
1089    }
1090
1091    #[test]
1092    fn charmap_ids_map_to_face_encodings() {
1093        use crate::encoding::FaceEncoding as E;
1094        assert_eq!(CharmapId::WINDOWS_UNICODE.face_encoding(), E::Unicode);
1095        assert_eq!(CharmapId::WINDOWS_SYMBOL.face_encoding(), E::Symbol);
1096        assert_eq!(CharmapId::MAC_ROMAN.face_encoding(), E::AppleRoman);
1097        assert_eq!(CharmapId::ADOBE_CUSTOM.face_encoding(), E::AdobeCustom);
1098        assert_eq!(
1099            CharmapId {
1100                platform: 2,
1101                encoding: 7
1102            }
1103            .face_encoding(),
1104            E::Other
1105        );
1106    }
1107
1108    #[test]
1109    fn garbage_bytes_yield_no_face() {
1110        assert!(Face::new(Arc::from(&b""[..]), 0).is_none());
1111        assert!(Face::new(Arc::from(&b"not a font at all"[..]), 0).is_none());
1112        assert!(Face::new(Arc::from(vec![0u8; 4096].as_slice()), 0).is_none());
1113    }
1114
1115    #[test]
1116    fn a_foxit_base14_blob_reads_as_a_non_truetype_face() {
1117        let bytes: Arc<[u8]> = Arc::from(crate::subst::standard_font_data(
1118            crate::StandardFont::Helvetica,
1119        ));
1120        let face = Face::new(bytes, 0).expect("bare CFF is readable");
1121        assert!(!face.is_truetype(), "a bare CFF has no glyf table");
1122        assert!(face.num_glyphs() > 100);
1123        assert_eq!(face.units_per_em(), 1000);
1124    }
1125
1126    #[test]
1127    fn the_pen_elevates_quadratics_to_cubics() {
1128        let mut pen = PathPen::default();
1129        pen.move_to(0.0, 0.0);
1130        pen.quad_to(30.0, 60.0, 60.0, 0.0);
1131        pen.close();
1132        let els: Vec<_> = pen.path.into_iter().collect();
1133        assert_eq!(els.len(), 3);
1134        assert!(matches!(
1135            els.get(1),
1136            Some(pdfrum_common::kurbo::PathEl::CurveTo(..))
1137        ));
1138    }
1139
1140    #[test]
1141    fn the_pen_ignores_segments_before_any_move() {
1142        let mut pen = PathPen::default();
1143        pen.line_to(10.0, 10.0);
1144        pen.curve_to(1.0, 1.0, 2.0, 2.0, 3.0, 3.0);
1145        pen.close();
1146        assert!(pen.path.elements().is_empty());
1147    }
1148}