Skip to main content

fleuron/
linebox.rs

1//! The line box model: how tall a line is and where its baseline sits.
2//!
3//! Half-leading over font metrics: a face's box is its ascent and
4//! descent, each widened by half the leading, where leading is what
5//! `line_height` adds over the face's natural height (ascent +
6//! descent + line gap). The line gap splits across the two halves so
7//! a single-face line always measures exactly `line_height × size`.
8//!
9//! The strut is the paragraph's own box — the line's minimum
10//! geometry, independent of the runs on it. Runs larger than
11//! the strut grow the line around the shared baseline; runs smaller
12//! never shrink it below the strut.
13//!
14//! Units: points throughout — runs of different sizes share one
15//! baseline, and font units don't commute across sizes.
16
17use crate::fonts::FontMetricsTable;
18
19/// Vertical extents around a baseline, in points: a face's box when
20/// computed for one font and size, the paragraph's strut when
21/// computed for the paragraph's own style.
22#[derive(Debug, Clone, Copy, PartialEq, Default)]
23pub struct Strut {
24    /// Distance from baseline to the top of the box.
25    pub above: f32,
26    /// Distance from baseline to the bottom of the box.
27    pub below: f32,
28}
29
30impl Strut {
31    /// The box a face makes at `size` under a unitless `line_height`.
32    pub fn from_metrics(metrics: FontMetricsTable, size: f32, line_height: f32) -> Strut {
33        let upem = metrics.units_per_em as f32;
34        let ascent = metrics.ascender as f32 / upem * size;
35        let descent = -metrics.descender as f32 / upem * size;
36        let gap = metrics.line_gap as f32 / upem * size;
37        let half_leading = (line_height * size - (ascent + descent + gap)) / 2.0;
38        Strut {
39            above: ascent + half_leading + gap / 2.0,
40            below: descent + half_leading + gap / 2.0,
41        }
42    }
43
44    /// The content area a face makes at `size`: its ascent and its
45    /// descent alone. This is the box an inline element's padding and
46    /// border are measured from, so what a chip encloses is the
47    /// letters rather than the space the line height opened around
48    /// them.
49    pub fn content(metrics: FontMetricsTable, size: f32) -> Strut {
50        let upem = metrics.units_per_em as f32;
51        Strut {
52            above: metrics.ascender as f32 / upem * size,
53            below: -metrics.descender as f32 / upem * size,
54        }
55    }
56
57    /// Total height: `above + below`.
58    pub fn height(self) -> f32 {
59        self.above + self.below
60    }
61}
62
63/// One line's vertical geometry, in points.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct LineBox {
66    /// Total height of the line.
67    pub height: f32,
68    /// Baseline offset from the top of the line. One baseline serves
69    /// the whole line; every run paints here, whatever its size.
70    pub baseline: f32,
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::fonts::{FontRegistry, bundled_registry};
77    use crate::lines::{LineLayout, ParagraphStyle, ShapedRun};
78
79    fn layout() -> LineLayout<'static> {
80        static REGISTRY: std::sync::OnceLock<FontRegistry> = std::sync::OnceLock::new();
81        let registry = REGISTRY.get_or_init(|| bundled_registry().expect("bundled font parses"));
82        LineLayout::new(registry)
83    }
84
85    /// The body style the built-in sheet computes: 11pt over the
86    /// bundled serif, at a line height of 1.4.
87    fn body() -> ParagraphStyle {
88        static REGISTRY: std::sync::OnceLock<FontRegistry> = std::sync::OnceLock::new();
89        let registry = REGISTRY.get_or_init(|| bundled_registry().expect("bundled font parses"));
90        crate::style::defaults(&crate::content::Book::default(), registry)
91            .root()
92            .paragraph()
93    }
94
95    fn run(size: f32) -> ShapedRun {
96        ShapedRun {
97            font_id: 0,
98            size,
99            text: String::new(),
100            source: String::new(),
101            source_map: Vec::new(),
102            text_start: 0,
103            origin: None,
104            pseudo_element: None,
105            inline: None,
106            lead: 0.0,
107            trail: 0.0,
108            features: crate::fonts::Features::NONE,
109            color: crate::style::Color::BLACK,
110            decoration: crate::style::TextDecoration::NONE,
111            glyphs: Vec::new(),
112            advance: 0,
113        }
114    }
115
116    fn assert_close(got: f32, want: f32, what: &str) {
117        assert!((got - want).abs() < 1e-3, "{what}: got {got}, want {want}");
118    }
119
120    /// EB Garamond: upem 1000, ascent 1007, descent −298, gap 0.
121    /// Body (11pt, line-height 1.4): natural 14.355pt, leading 1.045,
122    /// half 0.5225 — ascent 11.077 + 0.5225 above, 3.278 + 0.5225
123    /// below.
124    #[test]
125    fn strut_splits_the_leading_in_half() {
126        let strut = layout().strut(&body());
127        assert_close(strut.above, 11.5995, "above");
128        assert_close(strut.below, 3.8005, "below");
129        assert_close(strut.height(), 15.4, "height");
130    }
131
132    /// Above + below totals `line_height × size` whatever the knob —
133    /// including below the natural height, where the distribution is
134    /// symmetric negative leading.
135    #[test]
136    fn box_totals_line_height_at_any_knob() {
137        let metrics = bundled_registry().unwrap().metrics(0).unwrap();
138        for size in [6.0, 11.0, 24.0] {
139            for line_height in [0.9, 1.305, 1.4, 2.0] {
140                let strut = Strut::from_metrics(metrics, size, line_height);
141                assert_close(
142                    strut.height(),
143                    line_height * size,
144                    &format!("{size}pt at {line_height}"),
145                );
146            }
147        }
148    }
149
150    /// A line of runs smaller than the strut stays strut-tall — the
151    /// minimum height is independent of content.
152    #[test]
153    fn strut_is_the_minimum_independent_of_content() {
154        let strut = layout().strut(&body());
155        let line_box = layout().line_box(&[run(6.0)], &body());
156        assert_close(line_box.baseline, strut.above, "baseline");
157        assert_close(line_box.height, strut.height(), "height");
158    }
159
160    /// A line mixing 12pt and 24pt: both sit on one baseline, set by
161    /// the larger run. 24pt at 1.4: ascent 25.308 above, 8.292 below,
162    /// height 33.6 = 24 × 1.4; the 12pt run changes nothing.
163    #[test]
164    fn mixed_sizes_share_one_baseline() {
165        let line_box = layout().line_box(&[run(12.0), run(24.0)], &body());
166        assert_close(line_box.baseline, 25.308, "baseline");
167        assert_close(line_box.height, 33.6, "height");
168        let alone = layout().line_box(&[run(24.0)], &body());
169        assert_eq!(alone, line_box, "the 12pt run moved the line");
170    }
171
172    /// No runs at all: the strut alone defines the line.
173    #[test]
174    fn empty_line_is_the_strut() {
175        let strut = layout().strut(&body());
176        let line_box = layout().line_box(&[], &body());
177        assert_close(line_box.baseline, strut.above, "baseline");
178        assert_close(line_box.height, strut.height(), "height");
179    }
180}