Skip to main content

pdfrum_font/glyphs/
mod.rs

1//! Where a glyph index becomes an outline, an advance, or a bounding box.
2//!
3//! Two backends sit behind one enum: `skrifa` for everything with an SFNT or
4//! CFF shape, and `pdfrum-type1` for Type 1 programs, which Fontations reads
5//! only at the weight vector the file ships with — not enough for the
6//! Multiple-Master fallback faces PDFium leans on.
7
8mod cache;
9mod face;
10mod synth;
11
12pub use cache::{GlyphCache, GlyphKey};
13pub use face::{Charmap, CharmapId, Face};
14pub use synth::SynthGlyph;
15
16pub use crate::descriptor::em_adjust;
17pub(crate) use crate::descriptor::normalize_font_metric;
18
19use crate::Gid;
20use crate::ids::GlyphName;
21use pdfrum_common::kurbo::{Affine, BezPath, Rect};
22use pdfrum_common::{Diagnostics, Limits};
23use std::sync::Arc;
24
25/// Where glyphs come from.
26///
27/// `Fontations` covers TrueType, bare CFF, OpenType and everything else with a
28/// table directory; `Type1` covers PFA/PFB programs and the two Multiple-Master
29/// fallback faces; `None` is a Type3 font or a program nothing could read.
30#[derive(Debug, Clone, Default)]
31pub enum GlyphSource {
32    /// A face read by `skrifa`, over bytes this value owns.
33    Fontations(Face),
34    /// A Type 1 program, optionally instantiated at design coordinates.
35    Type1(Arc<pdfrum_type1::Type1Font>),
36    /// No glyphs at all.
37    #[default]
38    None,
39}
40
41/// A Type 1 face exposes a synthesized Unicode charmap first and its own
42/// encoding second — the shape `UseType1Charmap` expects.
43const TYPE1_CHARMAPS: [CharmapId; 2] = [CharmapId::UNICODE_SYNTHETIC, CharmapId::ADOBE_CUSTOM];
44
45/// The parameters that change what a glyph *looks like*, beyond its index.
46///
47/// The first two reach the *face*: for an ordinary face they are inert, but a
48/// Multiple-Master face solves its axes from them, so `dest_width` alone
49/// changes the outline. The last two are applied to whatever outline comes
50/// back, for every face alike.
51#[derive(Debug, Clone, Copy, PartialEq, Default)]
52pub(crate) struct GlyphParams {
53    /// The width the PDF declared for this character code, in 1000/em units.
54    /// Zero means "whatever the face does naturally".
55    pub dest_width: i32,
56    /// The substitution weight, or 0 for the face's own.
57    pub weight: i32,
58    /// The synthetic shear, in hundredths of a unit per unit, already resolved
59    /// through the CJK/CID arm (`GetEffectiveSkew` / `GetSkew`).
60    pub skew: i32,
61    /// Whether the shear runs down the page rather than across it, which is
62    /// the font's *writing mode* and not the vertical-glyph substitution.
63    pub vertical: bool,
64    /// The synthetic dilation, in 1000/em units. Zero for a face that is
65    /// already the requested weight.
66    pub embolden: f64,
67}
68
69impl GlyphSource {
70    /// Open a TrueType, OpenType, bare-CFF, or Type 1 program from its bytes.
71    ///
72    /// `None` when no backend recognises the blob.
73    #[must_use]
74    pub fn from_bytes(bytes: impl Into<Arc<[u8]>>) -> Option<Self> {
75        let bytes = bytes.into();
76        if let Some(face) = Face::new(Arc::clone(&bytes), 0) {
77            return Some(Self::Fontations(face));
78        }
79        let mut diags = Diagnostics::default();
80        pdfrum_type1::Type1Font::parse(&bytes, &Limits::default(), &mut diags)
81            .ok()
82            .map(|font| Self::Type1(Arc::new(font)))
83    }
84
85    /// Is there a face at all?
86    #[must_use]
87    pub(crate) fn is_some(&self) -> bool {
88        !matches!(self, Self::None)
89    }
90
91    /// Design units per em; 0 when there is no face.
92    #[must_use]
93    pub fn units_per_em(&self) -> u16 {
94        match self {
95            Self::Fontations(f) => f.units_per_em(),
96            Self::Type1(f) => f.units_per_em(),
97            Self::None => 0,
98        }
99    }
100
101    /// How many glyphs the face declares.
102    #[must_use]
103    pub fn num_glyphs(&self) -> u32 {
104        match self {
105            Self::Fontations(f) => f.num_glyphs(),
106            Self::Type1(f) => f.num_glyphs(),
107            Self::None => 0,
108        }
109    }
110
111    /// Is this a TrueType-shaped face? PDFium's per-glyph fallback and its
112    /// `ShouldUseFont` test both branch on it.
113    #[must_use]
114    pub fn is_truetype(&self) -> bool {
115        match self {
116            Self::Fontations(f) => f.is_truetype(),
117            Self::Type1(_) | Self::None => false,
118        }
119    }
120
121    /// The glyph a character code selects through the face's *currently
122    /// selected* charmap.
123    ///
124    /// Returns 0 rather than `None` on a miss, because every ladder in the former working note
125    /// and the former working note tests `!= 0` and 0 is `.notdef` either way.
126    #[must_use]
127    pub fn char_index(&self, charmap: Charmap, code: u32) -> u16 {
128        match self {
129            Self::Fontations(f) => f.char_index(charmap, code),
130            // A Type 1 face's "charmap" is its built-in encoding vector for a
131            // byte code, and the synthesized Unicode map otherwise.
132            Self::Type1(f) => {
133                let gid = match charmap {
134                    Charmap::Unicode => char::from_u32(code).and_then(|c| f.unicode_to_gid(c)),
135                    _ => u8::try_from(code).ok().and_then(|b| f.code_to_gid(b)),
136                };
137                gid.map_or(0, |g| g.0)
138            }
139            Self::None => 0,
140        }
141    }
142
143    /// The glyph a *name* selects. Zero on a miss, as `FT_Get_Name_Index`
144    /// leaves it.
145    #[must_use]
146    pub(crate) fn name_index(&self, name: &[u8]) -> u16 {
147        let Ok(name) = std::str::from_utf8(name) else {
148            return 0;
149        };
150        match self {
151            Self::Fontations(f) => f.name_index(name),
152            Self::Type1(f) => f.name_to_gid(name).map_or(0, |g| g.0),
153            Self::None => 0,
154        }
155    }
156
157    /// A glyph's own name, when the face has a name table.
158    #[must_use]
159    pub(crate) fn glyph_name(&self, gid: Gid) -> Option<GlyphName> {
160        match self {
161            Self::Fontations(f) => f.glyph_name(gid).map(|n| GlyphName::new(n.into_bytes())),
162            Self::Type1(f) => f
163                .glyph_name(gid.into())
164                .map(|n| GlyphName::new(n.as_bytes().to_vec())),
165            Self::None => None,
166        }
167    }
168
169    /// Whether the face can name its glyphs at all.
170    #[must_use]
171    pub(crate) fn has_glyph_names(&self) -> bool {
172        match self {
173            Self::Fontations(f) => f.has_glyph_names(),
174            Self::Type1(_) => true,
175            Self::None => false,
176        }
177    }
178
179    /// The charmaps the face declares, as `(platform, encoding)` pairs in
180    /// table order.
181    #[must_use]
182    pub fn charmaps(&self) -> &[CharmapId] {
183        match self {
184            Self::Fontations(f) => f.charmaps(),
185            // A Type 1 face exposes a synthesized Unicode charmap first and
186            // its own encoding second — the shape `UseType1Charmap` expects.
187            Self::Type1(_) => &TYPE1_CHARMAPS,
188            Self::None => &[],
189        }
190    }
191
192    /// A glyph's outline in **1000/em text space**.
193    ///
194    /// Three things happen here that a plain `draw` would not do:
195    ///
196    /// - The outline is requested **unscaled**, in font units, and then scaled
197    ///   by `1000 / upem` — matching the Fontations path PDFium itself is
198    ///   moving to, rather than its FreeType path's 64-pixel dance.
199    /// - **Degenerate trailing contours are trimmed** (`Outline_CheckEmptyContour`),
200    ///   because `kurbo` will happily hold a zero-area contour that changes
201    ///   what a rasterizer produces.
202    /// - An outline that trims to nothing yields `None`, not an empty path.
203    #[must_use]
204    pub(crate) fn outline(&self, gid: Gid, params: GlyphParams) -> Option<BezPath> {
205        let upem = self.units_per_em();
206        let raw = match self {
207            Self::Fontations(f) => {
208                // A hint-reliant face does not describe its glyphs without its
209                // bytecode: the strokes are stored off-canvas and the program
210                // is what places them, so the unhinted outline is a pile, not a
211                // rougher glyph. Run the interpreter for the whole face — the
212                // oracle's `IsTtOt() && IsTricky()` gate — and take the
213                // unhinted outline back only if it declines the face.
214                //
215                // Every other face stays unhinted here. This is the *path* side
216                // of text, which the oracle also draws unhinted, and grid-fitting
217                // it would round coordinates the filler wants exact.
218                if f.is_hint_reliant()
219                    && let Some(hinted) = self.hinted_outline(gid)
220                {
221                    return Some(synthesize(hinted, params));
222                }
223                f.outline(gid)?
224            }
225            Self::Type1(f) => match Self::mm_instance(f, gid, params) {
226                Some(inst) => inst.outline(gid.into())?.0,
227                None => f.outline(gid.into())?.0,
228            },
229            Self::None => return None,
230        };
231        let trimmed = trim_empty_contours(raw)?;
232        let scaled = if upem == 0 || upem == 1000 {
233            trimmed
234        } else {
235            Affine::scale(1000.0 / f64::from(upem)) * trimmed
236        };
237        Some(synthesize(scaled, params))
238    }
239
240    /// A glyph's outline in 1000/em text space, **grid-fitted at 64 ppem**.
241    ///
242    /// The same space [`Self::outline`] returns, so the two are interchangeable
243    /// at every call site and the renderer's glyph matrix does not change. The
244    /// difference is what happened before the scaling: this one ran the face's
245    /// own hinting programs against a 64-pixel grid, which is what an SFNT
246    /// face drawn as a *bitmap* gets.
247    ///
248    /// The conversion is a pure scale — `1000 / 64` — because a 64-ppem
249    /// instance draws in 64ths of an em. Grid-fitting at a pinned ppem and
250    /// then scaling is not the same thing as grid-fitting at the size the
251    /// glyph is drawn at, and the pinned ppem is the one that is correct here.
252    ///
253    /// `None` for every face that is not hinted, which is the caller's signal
254    /// to fall back to [`Self::outline`] rather than to draw nothing: a face
255    /// with no table directory (every bare CFF, so every base-14
256    /// substitution, and every `Type1` program), and a face whose own
257    /// programs the interpreter refuses.
258    // The two `None` arms are `cfx_face.cpp:841-843`'s `!IsTtOt()` gate and
259    // `cfx_face.cpp:849-857`'s pedantic-load failure, which reloads the glyph
260    // unhinted; the 64-ppem grid is `CFX_Face::RenderGlyph`'s.
261    #[must_use]
262    pub(crate) fn hinted_outline(&self, gid: Gid) -> Option<BezPath> {
263        let Self::Fontations(f) = self else {
264            return None;
265        };
266        let raw = f.hinted_outline(gid)?;
267        let trimmed = trim_empty_contours(raw)?;
268        Some(Affine::scale(1000.0 / f64::from(Face::HINT_PPEM)) * trimmed)
269    }
270
271    /// Advance in 1000/em units at the face's default location.
272    #[must_use]
273    pub fn default_advance(&self, gid: Gid) -> i32 {
274        self.advance(gid, GlyphParams::default())
275    }
276
277    /// A glyph's advance width in 1000/em units.
278    ///
279    /// Uses the **truncating** normalizer [`em_adjust`], not the rounding
280    /// `normalize_font_metric` — the two disagree for half the inputs, and an
281    /// advance takes the truncating one.
282    #[must_use]
283    pub(crate) fn advance(&self, gid: Gid, params: GlyphParams) -> i32 {
284        let upem = self.units_per_em();
285        let raw = match self {
286            Self::Fontations(f) => f.advance(gid),
287            Self::Type1(f) => match Self::mm_instance(f, gid, params) {
288                Some(inst) => inst.advance(gid.into()),
289                None => f.outline(gid.into()).map(|(_, a)| a),
290            },
291            Self::None => None,
292        };
293        let Some(raw) = raw else { return 0 };
294        // The C++'s range guard: an advance that would overflow the ×1000
295        // scaling reports zero rather than a wrapped value.
296        let raw = raw as i64;
297        if raw < i64::from(i32::MIN) / 1000 || raw > i64::from(i32::MAX) / 1000 {
298            return 0;
299        }
300        em_adjust(raw as i32, upem)
301    }
302
303    /// A glyph's advance through the **rounding** normalizer, which is what
304    /// `LoadCharMetrics` uses when filling in a width the PDF omitted.
305    #[must_use]
306    pub(crate) fn advance_tt(&self, gid: Gid) -> i32 {
307        let upem = self.units_per_em();
308        let raw = match self {
309            Self::Fontations(f) => f.advance(gid),
310            Self::Type1(f) => f.outline(gid.into()).map(|(_, a)| a),
311            Self::None => None,
312        };
313        raw.map_or(0, |a| normalize_font_metric(a as i64, upem))
314    }
315
316    /// A glyph's bounding box in 1000/em units, y-up.
317    ///
318    /// PDFium's own differential check maps skrifa's `(x_min, y_min, x_max,
319    /// y_max)` to `(left, top, right, bottom)` in its y-down convention and
320    /// asserts agreement within 2 units; we take that mapping and keep the
321    /// result y-up, which is what `kurbo::Rect` means.
322    #[must_use]
323    pub(crate) fn glyph_bbox(&self, gid: Gid) -> Option<Rect> {
324        let upem = self.units_per_em();
325        let raw = match self {
326            Self::Fontations(f) => f.glyph_bbox(gid)?,
327            Self::Type1(f) => f.glyph_bounds(gid.into())?,
328            Self::None => return None,
329        };
330        let n = |v: f64| f64::from(normalize_font_metric(v as i64, upem));
331        Some(Rect::new(n(raw.x0), n(raw.y0), n(raw.x1), n(raw.y1)))
332    }
333
334    /// The design-space instance to draw a Multiple-Master glyph at, solving
335    /// the width axis for `dest_width` (`AdjustVariationParams`, the former working note).
336    ///
337    /// Axis 0 is weight, taken **directly** as a design coordinate. Axis 1 is
338    /// width, found by probing the advance at both ends of the axis and
339    /// interpolating — **without clamping**, so an extreme `dest_width`
340    /// deliberately extrapolates past the axis.
341    fn mm_instance(
342        font: &pdfrum_type1::Type1Font,
343        gid: Gid,
344        params: GlyphParams,
345    ) -> Option<pdfrum_type1::Type1Instance<'_>> {
346        let axes = font.mm_axes()?;
347        let weight_axis = axes.first()?;
348        let width_axis = axes.get(1)?;
349
350        let weight = if params.weight == 0 {
351            weight_axis.default
352        } else {
353            params.weight as f32
354        };
355
356        if params.dest_width == 0 {
357            return font.instantiate(&[weight, width_axis.default]);
358        }
359
360        let upem = font.units_per_em();
361        let probe = |coord: f32| -> Option<i32> {
362            let inst = font.instantiate(&[weight, coord])?;
363            let adv = inst.advance(gid.into())?;
364            Some(em_adjust(adv as i32, upem))
365        };
366        let (lo, hi) = (width_axis.min, width_axis.max);
367        let min_w = probe(lo)?;
368        let max_w = probe(hi)?;
369        if max_w == min_w {
370            // Degenerate: the C++ leaves the coordinates at the max probe.
371            return font.instantiate(&[weight, hi]);
372        }
373        let t = (params.dest_width - min_w) as f32 / (max_w - min_w) as f32;
374        font.instantiate(&[weight, (hi - lo).mul_add(t, lo)])
375    }
376
377    /// PostScript name, or a family/style display name, when the face has one.
378    #[must_use]
379    pub fn postscript_name(&self) -> Option<String> {
380        match self {
381            Self::Fontations(f) => f.postscript_name(),
382            Self::Type1(f) => f
383                .postscript_name()
384                .map(ToOwned::to_owned)
385                .or_else(|| f.family_name().map(ToOwned::to_owned)),
386            Self::None => None,
387        }
388    }
389
390    /// Fixed pitch: `post.isFixedPitch`, or Type 1 `/isFixedPitch`.
391    #[must_use]
392    pub fn is_fixed_pitch(&self) -> bool {
393        match self {
394            Self::Fontations(f) => f.is_fixed_pitch(),
395            Self::Type1(f) => f.is_fixed_pitch(),
396            Self::None => false,
397        }
398    }
399
400    /// Italic: OS/2 / `macStyle` / `post.italicAngle`, or a Type 1 `/ItalicAngle`.
401    #[must_use]
402    pub fn is_italic(&self) -> bool {
403        match self {
404            Self::Fontations(f) => f.is_italic(),
405            Self::Type1(f) => f.italic_angle() != 0.0,
406            Self::None => false,
407        }
408    }
409
410    /// Bold: OS/2 / `macStyle`, or a Type 1 name containing `Bold` / `Black`.
411    #[must_use]
412    pub fn is_bold(&self) -> bool {
413        match self {
414            Self::Fontations(f) => f.is_bold(),
415            Self::Type1(f) => {
416                let name = f.postscript_name().or_else(|| f.full_name()).unwrap_or("");
417                name.contains("Bold") || name.contains("Black")
418            }
419            Self::None => false,
420        }
421    }
422
423    /// OS/2 `sCapHeight` in font units, when present.
424    #[must_use]
425    pub fn cap_height_unscaled(&self) -> Option<f32> {
426        match self {
427            Self::Fontations(f) => f.cap_height(),
428            Self::Type1(_) | Self::None => None,
429        }
430    }
431
432    /// Ascender in font units (`hhea`, or the Type 1 bbox top).
433    #[must_use]
434    pub fn unscaled_ascent(&self) -> Option<i32> {
435        match self {
436            Self::Fontations(f) => f.metrics().and_then(|m| i32::try_from(m.ascender).ok()),
437            Self::Type1(f) => Some(f.bbox().y1 as i32),
438            Self::None => None,
439        }
440    }
441
442    /// Descender in font units (`hhea`, or the Type 1 bbox bottom).
443    #[must_use]
444    pub fn unscaled_descent(&self) -> Option<i32> {
445        match self {
446            Self::Fontations(f) => f.metrics().and_then(|m| i32::try_from(m.descender).ok()),
447            Self::Type1(f) => Some(f.bbox().y0 as i32),
448            Self::None => None,
449        }
450    }
451
452    /// Font bounding box in font units, `(left, bottom, right, top)`.
453    #[must_use]
454    pub fn unscaled_bbox(&self) -> Option<(i32, i32, i32, i32)> {
455        match self {
456            Self::Fontations(f) => {
457                let m = f.metrics()?;
458                Some((
459                    i32::try_from(m.bbox_left).ok()?,
460                    i32::try_from(m.bbox_bottom).ok()?,
461                    i32::try_from(m.bbox_right).ok()?,
462                    i32::try_from(m.bbox_top).ok()?,
463                ))
464            }
465            Self::Type1(f) => {
466                let b = f.bbox();
467                Some((b.x0 as i32, b.y0 as i32, b.x1 as i32, b.y1 as i32))
468            }
469            Self::None => None,
470        }
471    }
472
473    /// Unicode → glyph mappings with `code <= max`, sorted by codepoint.
474    ///
475    /// A miss is omitted rather than recorded as glyph 0.
476    #[must_use]
477    pub fn unicode_mappings(&self, max: u32) -> Vec<(u32, u16)> {
478        match self {
479            Self::Fontations(f) => f.unicode_mappings(max),
480            Self::Type1(f) => {
481                let mut out: Vec<(u32, u16)> = f
482                    .unicode_pairs()
483                    .filter(|(ch, gid)| u32::from(*ch) <= max && gid.0 != 0)
484                    .map(|(ch, gid)| (u32::from(ch), gid.0))
485                    .collect();
486                out.sort_unstable_by_key(|(cp, _)| *cp);
487                out
488            }
489            Self::None => Vec::new(),
490        }
491    }
492}
493
494/// Drop degenerate trailing contours (`Outline_CheckEmptyContour`).
495///
496/// FreeType's decomposition leaves two shapes behind that draw nothing but do
497/// change a rasterizer's output: a `MoveTo` followed by a line back to the
498/// same point, and a `MoveTo` followed by three curves all landing on it. Both
499/// are trimmed, repeatedly, and an outline that trims away entirely yields
500/// `None` rather than an empty path.
501fn trim_empty_contours(path: BezPath) -> Option<BezPath> {
502    use pdfrum_common::kurbo::PathEl;
503
504    let mut els: Vec<PathEl> = path.into_iter().collect();
505    loop {
506        // A `ClosePath` is not itself degenerate; look past it.
507        let end = els
508            .iter()
509            .rposition(|e| !matches!(e, PathEl::ClosePath))
510            .map_or(0, |i| i + 1);
511
512        // `[MoveTo(p), LineTo(p)]`.
513        if end >= 2
514            && let (Some(PathEl::MoveTo(a)), Some(PathEl::LineTo(b))) =
515                (els.get(end - 2), els.get(end - 1))
516            && a == b
517        {
518            els.truncate(end - 2);
519            continue;
520        }
521        // `[MoveTo(p), CurveTo(_,_,p) × 3]`.
522        if end >= 4
523            && let (
524                Some(PathEl::MoveTo(a)),
525                Some(PathEl::CurveTo(_, _, b)),
526                Some(PathEl::CurveTo(_, _, c)),
527                Some(PathEl::CurveTo(_, _, d)),
528            ) = (
529                els.get(end - 4),
530                els.get(end - 3),
531                els.get(end - 2),
532                els.get(end - 1),
533            )
534            && a == b
535            && b == c
536            && c == d
537        {
538            els.truncate(end - 4);
539            continue;
540        }
541        break;
542    }
543    if els.iter().all(|e| matches!(e, PathEl::ClosePath)) {
544        return None;
545    }
546    let out = BezPath::from_vec(els);
547    if out.elements().is_empty() {
548        None
549    } else {
550        Some(out)
551    }
552}
553
554/// Apply a substitution's synthetic shear and dilation to a finished outline.
555///
556/// The order is the oracle's, and it is not commutative: the skew rides on the
557/// matrix the glyph is *loaded* through (`cfx_face.cpp:771-776`, `:871-875`)
558/// while the embolden runs on the outline that comes back (`:812-815`,
559/// `:889-891`), so the dilation happens in the already-sheared space. Dilating
560/// first and shearing after would slant the added weight along with the glyph,
561/// which is a different — and visibly wrong — stem shape.
562///
563/// Doing this here rather than at the call sites is what keeps the glyph cache
564/// correct for free: its key already separates two fonts that differ only in
565/// slant or weight, so each entry holds the outline that font actually draws.
566fn synthesize(path: BezPath, params: GlyphParams) -> BezPath {
567    SynthGlyph {
568        skew: params.skew,
569        vertical: params.vertical,
570        embolden: params.embolden,
571    }
572    .apply(path)
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use pdfrum_common::kurbo::{PathEl, Point};
579
580    #[test]
581    fn a_move_and_a_line_back_to_it_is_trimmed() {
582        let mut p = BezPath::new();
583        p.move_to((10.0, 10.0));
584        p.line_to((50.0, 10.0));
585        p.line_to((50.0, 50.0));
586        p.close_path();
587        p.move_to((7.0, 7.0));
588        p.line_to((7.0, 7.0));
589        let trimmed = trim_empty_contours(p).expect("the real contour survives");
590        assert_eq!(trimmed.elements().len(), 4);
591        assert!(matches!(
592            trimmed.elements().first(),
593            Some(PathEl::MoveTo(_))
594        ));
595    }
596
597    #[test]
598    fn a_move_and_three_curves_to_it_is_trimmed() {
599        let mut p = BezPath::new();
600        p.move_to((0.0, 0.0));
601        p.line_to((10.0, 0.0));
602        p.close_path();
603        let q = Point::new(3.0, 3.0);
604        p.move_to(q);
605        for _ in 0..3 {
606            p.curve_to(q, q, q);
607        }
608        let trimmed = trim_empty_contours(p).expect("the real contour survives");
609        assert_eq!(trimmed.elements().len(), 3);
610    }
611
612    #[test]
613    fn repeated_degenerate_contours_are_all_trimmed() {
614        let mut p = BezPath::new();
615        p.move_to((0.0, 0.0));
616        p.line_to((10.0, 0.0));
617        p.close_path();
618        for i in 0..3 {
619            let q = Point::new(f64::from(i), f64::from(i));
620            p.move_to(q);
621            p.line_to(q);
622        }
623        let trimmed = trim_empty_contours(p).expect("the real contour survives");
624        assert_eq!(trimmed.elements().len(), 3);
625    }
626
627    #[test]
628    fn an_entirely_degenerate_outline_is_none_not_an_empty_path() {
629        let mut p = BezPath::new();
630        p.move_to((5.0, 5.0));
631        p.line_to((5.0, 5.0));
632        assert!(trim_empty_contours(p).is_none());
633        assert!(trim_empty_contours(BezPath::new()).is_none());
634    }
635
636    #[test]
637    fn a_healthy_outline_is_untouched() {
638        let mut p = BezPath::new();
639        p.move_to((0.0, 0.0));
640        p.curve_to((10.0, 0.0), (10.0, 10.0), (0.0, 10.0));
641        p.close_path();
642        let n = p.elements().len();
643        assert_eq!(trim_empty_contours(p).map(|q| q.elements().len()), Some(n));
644    }
645
646    #[test]
647    fn only_a_face_on_the_hint_reliant_list_asks_for_the_interpreter() {
648        // The two fixtures hold the same glyphs and differ only in the family
649        // name, so the name is what the predicate is reading.
650        let plain = crate::testfonts::load("tt_composite_instructions.ttf");
651        let plain = Face::new(plain.into(), 0).expect("the fixture loads");
652        assert!(!plain.is_hint_reliant());
653
654        let tricky = crate::testfonts::load("tt_hint_reliant.ttf");
655        let tricky = Face::new(tricky.into(), 0).expect("the fixture loads");
656        assert!(tricky.is_hint_reliant());
657    }
658
659    #[test]
660    fn a_hint_reliant_face_draws_its_path_outlines_grid_fitted() {
661        // `outline` must route a hint-reliant face through the interpreter,
662        // because its components are placed by bytecode and not by their
663        // offsets. Grid-fitting at 64 ppem lands coordinates on a coarse
664        // lattice, which is what separates the two paths here.
665        let bytes = crate::testfonts::load("tt_hint_reliant.ttf");
666        let face = Face::new(bytes.into(), 0).expect("the fixture loads");
667        let source = GlyphSource::Fontations(face);
668        let gid = Gid(3);
669
670        let drawn = source
671            .outline(gid, GlyphParams::default())
672            .expect("the instructed composite draws");
673        let hinted = source
674            .hinted_outline(gid)
675            .expect("the fixture carries programs the interpreter accepts");
676        assert_eq!(drawn.to_svg(), hinted.to_svg());
677    }
678
679    #[test]
680    fn an_ordinary_face_keeps_its_unhinted_path_outlines() {
681        // The other side of the gate: everything not on the list stays
682        // unhinted on the path side, which is what the oracle draws.
683        let bytes = crate::testfonts::load("tt_composite_instructions.ttf");
684        let face = Face::new(bytes.into(), 0).expect("the fixture loads");
685        let source = GlyphSource::Fontations(face.clone());
686        let gid = Gid(3);
687
688        let drawn = source
689            .outline(gid, GlyphParams::default())
690            .expect("the instructed composite draws");
691        let unhinted = face.outline(gid).expect("the glyph has an outline");
692        let upem = f64::from(face.units_per_em());
693        let expected = Affine::scale(1000.0 / upem) * unhinted;
694        assert_eq!(drawn.to_svg(), expected.to_svg());
695    }
696
697    #[test]
698    fn the_empty_source_answers_everything_with_nothing() {
699        let s = GlyphSource::None;
700        assert!(!s.is_some());
701        assert_eq!(s.units_per_em(), 0);
702        assert_eq!(s.num_glyphs(), 0);
703        assert!(!s.is_truetype());
704        assert_eq!(s.char_index(Charmap::Unicode, 0x41), 0);
705        assert_eq!(s.name_index(b"A"), 0);
706        assert!(s.glyph_name(Gid(0)).is_none());
707        assert!(!s.has_glyph_names());
708        assert!(s.charmaps().is_empty());
709        assert!(s.outline(Gid(0), GlyphParams::default()).is_none());
710        assert_eq!(s.advance(Gid(0), GlyphParams::default()), 0);
711        assert_eq!(s.advance_tt(Gid(0)), 0);
712        assert!(s.glyph_bbox(Gid(0)).is_none());
713    }
714}