Skip to main content

blockworx_text/
metrics.rs

1//! The vertical metrics a row is measured by, in font units.
2
3use blockworx_paint::{Font, FontChoice};
4
5/// A face's vertical metrics, in font units — the size-independent numbers a
6/// row's height and a glyph's baseline are derived from.
7///
8/// Which table they come from is the shaper's font reader's business: it
9/// follows `FreeType`'s rule (OS/2 typo metrics when the face asks for them,
10/// `hhea` otherwise), which is the rule the desktop's engine follows too, so
11/// a row is the same height on both.
12#[derive(Clone, Copy, Debug, PartialEq)]
13pub struct Metrics {
14    units_per_em: f32,
15    ascent: f32,
16    descent: f32,
17    line_gap: f32,
18}
19
20impl Metrics {
21    /// # Panics
22    ///
23    /// If the typeface's embedded bytes are not a font this can read.
24    #[expect(clippy::expect_used)]
25    #[must_use]
26    pub fn of(typeface: FontChoice) -> Self {
27        use skrifa::MetadataProvider as _;
28        let font =
29            skrifa::FontRef::from_index(typeface.bytes(), 0).expect("embedded font is valid");
30        let metrics = font.metrics(
31            skrifa::instance::Size::unscaled(),
32            skrifa::instance::LocationRef::default(),
33        );
34        Self {
35            units_per_em: f32::from(metrics.units_per_em),
36            ascent: metrics.ascent,
37            descent: metrics.descent,
38            line_gap: metrics.leading,
39        }
40    }
41
42    #[must_use]
43    pub fn units_per_em(self) -> f32 {
44        self.units_per_em
45    }
46
47    /// Font units to points at `font`'s size.
48    #[must_use]
49    pub fn scale(self, font: &Font) -> f32 {
50        font.size / self.units_per_em
51    }
52
53    /// How far the baseline sits below the line's top edge.
54    #[must_use]
55    pub fn ascent(self, font: &Font) -> f32 {
56        self.ascent * self.scale(font)
57    }
58
59    /// How far the face descends below the baseline — negative, as the face
60    /// states it.
61    #[must_use]
62    pub fn descent(self, font: &Font) -> f32 {
63        self.descent * self.scale(font)
64    }
65
66    /// A line's height, whether or not a glyph sits on it.
67    #[must_use]
68    pub fn row_height(self, font: &Font) -> f32 {
69        (self.ascent - self.descent + self.line_gap) * self.scale(font)
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn every_bundled_face_has_a_positive_em_and_an_ascending_baseline() {
79        for choice in FontChoice::ALL {
80            let metrics = Metrics::of(choice);
81            let font = Font::canvas(12.0);
82            assert!(
83                metrics.units_per_em() > 0.0,
84                "{} has no em square",
85                choice.display_name()
86            );
87            assert!(
88                metrics.ascent(&font) > 0.0 && metrics.descent(&font) < 0.0,
89                "{} ascends above and descends below the baseline",
90                choice.display_name()
91            );
92            assert!(
93                metrics.row_height(&font) >= metrics.ascent(&font) - metrics.descent(&font),
94                "{} rows are at least the face's extent tall",
95                choice.display_name()
96            );
97        }
98    }
99
100    #[test]
101    fn metrics_scale_linearly_with_the_font_size() {
102        let metrics = Metrics::of(FontChoice::Basic);
103        let small = metrics.row_height(&Font::canvas(10.0));
104        let large = metrics.row_height(&Font::canvas(20.0));
105        assert!((large - 2.0 * small).abs() < 1e-3, "{small} {large}");
106    }
107}