Skip to main content

cranpose_ui/text/
line_box.rs

1//! Where a line of text sits inside the height it was given.
2//!
3//! [`LineHeightStyle`] has been a declared-but-unread field on
4//! [`ParagraphStyle`](crate::text::ParagraphStyle) since it was added: nothing
5//! outside `merge` and the hash keys ever looked at it, and the rasterizer's
6//! line box was a fixed rule — the box is exactly the requested line height,
7//! and the leading is split evenly above and below. That rule is not what
8//! Android does, and the difference is visible.
9//!
10//! AOSP's `StaticLayout` differs in four ways that each move a glyph row:
11//!
12//! - the font's ascent and descent are **whole pixels**, rounded the way
13//!   `Paint.getFontMetricsInt()` rounds them, and the line is built from that
14//!   pair rather than from the float metrics;
15//! - the line advance is a **whole pixel**, `ceil`ed, not a float;
16//! - a requested line height **shorter than the font's own ascent + descent
17//!   does not shrink the line** — the font wins, which is why a 16sp/18sp
18//!   style lays out in 38px rather than 36px at density 2;
19//! - the leading is split with the **odd pixel below** the baseline, not above.
20//!
21//! [`line_box`] implements that, and it implements it **only when the caller
22//! asked for it**. A style whose `line_height_style` is `None` gets exactly the
23//! arithmetic it got before, bit for bit. That is deliberate: the rule changes
24//! where every glyph lands, and it is not a change to make silently on behalf
25//! of text that never asked. The Wear widgets ask for it through
26//! [`WearTextStyle`](crate::widgets::wear::WearTextStyle), and a
27//! [`DrawScope`](cranpose_ui_graphics::DrawScope) run asks for it through
28//! [`TextStyle::with_line_height_style`](cranpose_ui_graphics::TextStyle::with_line_height_style)
29//! — which is what lets a canvas and a `Text` on one screen agree.
30
31use crate::text::style::{
32    LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim, TextStyle,
33};
34
35/// A resolved line box: how tall the line is and where its baseline sits inside
36/// it, both measured down from the top of the box.
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct LineBox {
39    /// Baseline-to-baseline advance, and the height of a single-line block.
40    pub height: f32,
41    /// Distance from the top of the box down to the baseline.
42    pub baseline: f32,
43}
44
45/// The font's own vertical extent, in the same unit as the line height.
46///
47/// `ascent` and `descent` are both **positive distances** from the baseline,
48/// which is the sign convention AOSP states its rule in and the opposite of the
49/// one `ab_glyph` reports `descent` in.
50#[derive(Clone, Copy, Debug, PartialEq)]
51pub struct FontExtent {
52    pub ascent: f32,
53    pub descent: f32,
54    /// `hhea.lineGap`. Only read when a style asks for font padding.
55    pub line_gap: f32,
56}
57
58impl FontExtent {
59    pub fn new(ascent: f32, descent: f32, line_gap: f32) -> Self {
60        Self {
61            ascent,
62            descent,
63            line_gap,
64        }
65    }
66
67    /// Ascent plus descent — the height the font needs with no leading at all.
68    pub fn natural(self) -> f32 {
69        self.ascent + self.descent
70    }
71}
72
73/// The line box a style asks for, given the font's extent and the line height
74/// already resolved from the style's own units.
75///
76/// `asked` is the line height in the same unit as the extent. `grid` is how
77/// many device pixels there are to one of those units, and it is what every
78/// rounding in the AOSP rule is done against — pass `1.0` when the values are
79/// already device pixels, or the density when they are layout points. Getting
80/// it wrong does not shift a baseline by a fraction; it quantises the whole
81/// line box to the wrong step.
82pub fn line_box(style: &TextStyle, extent: FontExtent, asked: f32, grid: f32) -> LineBox {
83    let grid = if grid.is_finite() && grid > 0.0 {
84        grid
85    } else {
86        1.0
87    };
88    match style.paragraph_style.line_height_style {
89        None => unstyled_line_box(extent, asked, grid),
90        Some(line_height_style) => {
91            let padding = font_padding(style, extent);
92            aosp_line_box(line_height_style, extent, asked, padding, grid)
93        }
94    }
95}
96
97fn unstyled_line_box(extent: FontExtent, asked: f32, grid: f32) -> LineBox {
98    let natural = (extent.natural() * grid).ceil() / grid;
99    LineBox {
100        height: asked,
101        baseline: extent.ascent + (asked - natural) * 0.5,
102    }
103}
104
105fn font_padding(style: &TextStyle, extent: FontExtent) -> f32 {
106    let asked = style
107        .paragraph_style
108        .platform_style
109        .and_then(|platform| platform.include_font_padding)
110        .unwrap_or(false);
111    if asked && extent.line_gap.is_finite() && extent.line_gap > 0.0 {
112        extent.line_gap
113    } else {
114        0.0
115    }
116}
117
118fn aosp_line_box(
119    style: LineHeightStyle,
120    extent: FontExtent,
121    asked: f32,
122    padding: f32,
123    grid: f32,
124) -> LineBox {
125    let up = |value: f32| (value * grid).ceil() / grid;
126    let down = |value: f32| (value * grid).floor() / grid;
127    let round = |value: f32| ((value * grid) + 0.5).floor() / grid;
128    let ascent = -round(-extent.ascent.max(0.0));
129    let descent = round(extent.descent.max(0.0));
130    let above_padding = down(padding * 0.5);
131    let below_padding = padding - above_padding;
132    let natural = up(ascent + descent + padding);
133    let asked = if asked.is_finite() {
134        up(asked)
135    } else {
136        natural
137    };
138
139    let height = match style.mode {
140        LineHeightMode::Fixed => asked.max(1.0),
141        LineHeightMode::Minimum => asked.max(natural).max(1.0),
142        LineHeightMode::Tight => natural.max(1.0),
143    };
144
145    let leading = height - (ascent + descent + padding);
146    let (mut above, mut below) = match style.alignment {
147        LineHeightAlignment::Top => (0.0, leading),
148        LineHeightAlignment::Bottom => (leading, 0.0),
149        LineHeightAlignment::Center => {
150            let below = up(leading * 0.5);
151            (leading - below, below)
152        }
153        LineHeightAlignment::Proportional => {
154            let total = ascent + descent;
155            if total > 0.0 {
156                let above = leading * (ascent / total);
157                (above, leading - above)
158            } else {
159                (leading * 0.5, leading * 0.5)
160            }
161        }
162    };
163    above += above_padding;
164    below += below_padding;
165
166    let (trim_above, trim_below) = match style.trim {
167        LineHeightTrim::None => (false, false),
168        LineHeightTrim::FirstLineTop => (true, false),
169        LineHeightTrim::LastLineBottom => (false, true),
170        LineHeightTrim::Both => (true, true),
171    };
172    let mut height = height;
173    if trim_above {
174        height -= above;
175        above = 0.0;
176    }
177    if trim_below {
178        height -= below;
179    }
180
181    LineBox {
182        height: height.max(1.0),
183        baseline: above + ascent,
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::text::{
191        TextUnit,
192        style::{ParagraphStyle, PlatformParagraphStyle},
193    };
194
195    fn roboto_16sp() -> FontExtent {
196        FontExtent::new(32.0 * 1900.0 / 2048.0, 32.0 * 500.0 / 2048.0, 0.0)
197    }
198
199    fn styled(line_height_px: f32, line_height_style: Option<LineHeightStyle>) -> TextStyle {
200        TextStyle {
201            paragraph_style: ParagraphStyle {
202                line_height: TextUnit::Sp(line_height_px),
203                line_height_style,
204                ..ParagraphStyle::default()
205            },
206            ..TextStyle::default()
207        }
208    }
209
210    fn wear() -> LineHeightStyle {
211        LineHeightStyle {
212            alignment: LineHeightAlignment::Center,
213            trim: LineHeightTrim::None,
214            mode: LineHeightMode::Minimum,
215        }
216    }
217
218    #[test]
219    fn a_style_that_asks_for_nothing_gets_exactly_what_it_got_before() {
220        let extent = roboto_16sp();
221        let plain = line_box(&styled(36.0, None), extent, 36.0, 1.0);
222        assert_eq!(plain.height, 36.0);
223        let natural = extent.natural().ceil();
224        assert_eq!(plain.baseline, extent.ascent + (36.0 - natural) * 0.5);
225    }
226
227    #[test]
228    fn an_unstyled_box_is_the_same_box_measured_in_points_or_in_pixels() {
229        let density = 2.0;
230        for glyph_px in [24.0f32, 26.0, 28.125, 30.0, 32.0, 37.2, 38.72] {
231            let ascent_px = glyph_px * 1900.0 / 2048.0;
232            let descent_px = glyph_px * 500.0 / 2048.0;
233            let asked_px = (glyph_px * 4.0 / 3.0).round();
234
235            let in_pixels = line_box(
236                &styled(asked_px, None),
237                FontExtent::new(ascent_px, descent_px, 0.0),
238                asked_px,
239                1.0,
240            );
241            let in_points = line_box(
242                &styled(asked_px / density, None),
243                FontExtent::new(ascent_px / density, descent_px / density, 0.0),
244                asked_px / density,
245                density,
246            );
247
248            assert!(
249                (in_points.height * density - in_pixels.height).abs() < 1e-4,
250                "{glyph_px}px: height {} in points against {} in pixels",
251                in_points.height * density,
252                in_pixels.height
253            );
254            assert!(
255                (in_points.baseline * density - in_pixels.baseline).abs() < 1e-4,
256                "{glyph_px}px: baseline {} in points against {} in pixels",
257                in_points.baseline * density,
258                in_pixels.baseline
259            );
260        }
261    }
262
263    #[test]
264    fn title_medium_overflows_its_own_line_height_and_the_font_wins() {
265        let box_ = line_box(&styled(36.0, Some(wear())), roboto_16sp(), 36.0, 1.0);
266        assert_eq!(box_.height, 38.0);
267    }
268
269    #[test]
270    fn a_line_height_the_font_fits_inside_is_honoured_as_asked() {
271        let extent = FontExtent::new(30.0 * 1900.0 / 2048.0, 30.0 * 500.0 / 2048.0, 0.0);
272        let box_ = line_box(&styled(36.0, Some(wear())), extent, 36.0, 1.0);
273        assert_eq!(box_.height, 36.0);
274        assert_eq!(box_.baseline, 28.0);
275    }
276
277    #[test]
278    fn the_font_metrics_are_rounded_the_way_the_platform_rounds_them() {
279        for (size_px, ascent_px, descent_px) in [
280            (24.0_f32, 22.0_f32, 6.0_f32),
281            (26.0, 24.0, 6.0),
282            (30.0, 28.0, 7.0),
283            (32.0, 30.0, 8.0),
284            (37.2, 35.0, 9.0),
285            (38.72, 36.0, 9.0),
286            (43.76, 41.0, 11.0),
287            (38.0, 35.0, 9.0),
288        ] {
289            let extent = FontExtent::new(size_px * 1900.0 / 2048.0, size_px * 500.0 / 2048.0, 0.0);
290            let tight = line_box(
291                &styled(
292                    0.0,
293                    Some(LineHeightStyle {
294                        mode: LineHeightMode::Tight,
295                        ..wear()
296                    }),
297                ),
298                extent,
299                0.0,
300                1.0,
301            );
302            assert_eq!(
303                (tight.baseline, tight.height - tight.baseline),
304                (ascent_px, descent_px),
305                "{size_px}px",
306            );
307        }
308    }
309
310    #[test]
311    fn the_wear_type_scale_lays_out_in_the_boxes_the_platform_gives_it() {
312        for (name, size_px, line_height_px, height, baseline) in [
313            ("titleMedium 1.0", 32.0_f32, 36.0_f32, 38.0_f32, 30.0_f32),
314            ("labelMedium 1.0", 30.0, 36.0, 36.0, 28.0),
315            ("labelSmall 1.0", 26.0, 32.0, 32.0, 25.0),
316            ("titleMedium 1.24", 38.72, 41.76, 45.0, 36.0),
317            ("labelMedium 1.24", 37.2, 41.76, 44.0, 35.0),
318            ("labelSmall 1.24", 32.72, 38.72, 39.0, 30.0),
319        ] {
320            let extent = FontExtent::new(size_px * 1900.0 / 2048.0, size_px * 500.0 / 2048.0, 0.0);
321            let resolved = line_box(
322                &styled(line_height_px, Some(wear())),
323                extent,
324                line_height_px,
325                1.0,
326            );
327            assert_eq!(
328                (resolved.height, resolved.baseline),
329                (height, baseline),
330                "{name}",
331            );
332        }
333    }
334
335    #[test]
336    fn the_odd_unit_of_leading_goes_below_the_baseline_not_above() {
337        let extent = FontExtent::new(20.0, 10.0, 0.0);
338        let box_ = line_box(&styled(33.0, Some(wear())), extent, 33.0, 1.0);
339        assert_eq!(box_.height, 33.0);
340        assert_eq!(box_.baseline, 21.0);
341        assert_ne!(box_.baseline, 20.0 + 1.5);
342    }
343
344    #[test]
345    fn a_line_height_is_a_whole_number_of_pixels() {
346        let extent = FontExtent::new(20.0, 10.0, 0.0);
347        let box_ = line_box(&styled(33.4, Some(wear())), extent, 33.4, 1.0);
348        assert_eq!(box_.height, 34.0);
349    }
350
351    #[test]
352    fn top_alignment_puts_the_glyphs_at_the_top_and_bottom_at_the_bottom() {
353        let extent = FontExtent::new(20.0, 10.0, 0.0);
354        let top = line_box(
355            &styled(
356                40.0,
357                Some(LineHeightStyle {
358                    alignment: LineHeightAlignment::Top,
359                    ..wear()
360                }),
361            ),
362            extent,
363            40.0,
364            1.0,
365        );
366        assert_eq!(top.baseline, 20.0);
367        let bottom = line_box(
368            &styled(
369                40.0,
370                Some(LineHeightStyle {
371                    alignment: LineHeightAlignment::Bottom,
372                    ..wear()
373                }),
374            ),
375            extent,
376            40.0,
377            1.0,
378        );
379        assert_eq!(bottom.baseline, 30.0);
380        assert_eq!(bottom.height - bottom.baseline, extent.descent);
381    }
382
383    #[test]
384    fn proportional_alignment_splits_the_leading_the_way_the_font_is_split() {
385        let extent = FontExtent::new(20.0, 10.0, 0.0);
386        let style = LineHeightStyle {
387            alignment: LineHeightAlignment::Proportional,
388            ..wear()
389        };
390        let box_ = line_box(&styled(60.0, Some(style)), extent, 60.0, 1.0);
391        assert_eq!(box_.baseline, 40.0);
392    }
393
394    #[test]
395    fn a_fixed_line_height_lets_the_font_overflow_and_tight_ignores_the_ask() {
396        let extent = roboto_16sp();
397        let fixed = line_box(
398            &styled(
399                36.0,
400                Some(LineHeightStyle {
401                    mode: LineHeightMode::Fixed,
402                    ..wear()
403                }),
404            ),
405            extent,
406            36.0,
407            1.0,
408        );
409        assert_eq!(
410            fixed.height, 36.0,
411            "the ask wins even though the font needs 38"
412        );
413        let tight = line_box(
414            &styled(
415                80.0,
416                Some(LineHeightStyle {
417                    mode: LineHeightMode::Tight,
418                    ..wear()
419                }),
420            ),
421            extent,
422            80.0,
423            1.0,
424        );
425        assert_eq!(tight.height, 38.0);
426        assert_eq!(tight.baseline, 30.0);
427    }
428
429    #[test]
430    fn trimming_removes_the_leading_on_the_edge_it_names() {
431        let extent = FontExtent::new(20.0, 10.0, 0.0);
432        let both = line_box(
433            &styled(
434                40.0,
435                Some(LineHeightStyle {
436                    trim: LineHeightTrim::Both,
437                    ..wear()
438                }),
439            ),
440            extent,
441            40.0,
442            1.0,
443        );
444        assert_eq!(both.height, 30.0);
445        assert_eq!(both.baseline, 20.0);
446
447        let top_only = line_box(
448            &styled(
449                40.0,
450                Some(LineHeightStyle {
451                    trim: LineHeightTrim::FirstLineTop,
452                    ..wear()
453                }),
454            ),
455            extent,
456            40.0,
457            1.0,
458        );
459        assert_eq!(top_only.height, 35.0);
460        assert_eq!(top_only.baseline, 20.0);
461    }
462
463    #[test]
464    fn font_padding_is_only_spent_when_a_style_asks_for_it() {
465        let extent = FontExtent::new(20.0, 10.0, 4.0);
466        let without = line_box(&styled(30.0, Some(wear())), extent, 30.0, 1.0);
467        assert_eq!(without.height, 30.0);
468        assert_eq!(without.baseline, 20.0);
469
470        let padded = TextStyle {
471            paragraph_style: ParagraphStyle {
472                line_height: TextUnit::Sp(30.0),
473                line_height_style: Some(wear()),
474                platform_style: Some(PlatformParagraphStyle {
475                    include_font_padding: Some(true),
476                    shaping: None,
477                }),
478                ..ParagraphStyle::default()
479            },
480            ..TextStyle::default()
481        };
482        let with = line_box(&padded, extent, 30.0, 1.0);
483        assert_eq!(with.height, 34.0, "the line gap widens the font's demand");
484        assert_eq!(with.baseline, 22.0, "and half of it sits above the ascent");
485    }
486
487    #[test]
488    fn a_nonsense_line_height_falls_back_to_the_font_rather_than_producing_nan() {
489        let extent = FontExtent::new(20.0, 10.0, 0.0);
490        let box_ = line_box(&styled(30.0, Some(wear())), extent, f32::NAN, 1.0);
491        assert_eq!(box_.height, 30.0);
492        assert!(box_.baseline.is_finite());
493    }
494}