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 three ways that each move a glyph row:
11//!
12//! - the line advance is a **whole pixel**, `ceil`ed, not a float;
13//! - a requested line height **shorter than the font's own ascent + descent
14//!   does not shrink the line** — the font wins, which is why a 16sp/18sp
15//!   style lays out in 38px rather than 36px at density 2;
16//! - the leading is split with the **odd pixel below** the baseline, not above.
17//!
18//! [`line_box`] implements that, and it implements it **only when the caller
19//! asked for it**. A style whose `line_height_style` is `None` — which is every
20//! style in the framework today — gets exactly the arithmetic it got before,
21//! bit for bit. That is deliberate: the rule changes where every glyph in the
22//! framework lands, and it is not a change to make silently on behalf of text
23//! that never asked.
24
25use crate::text::style::{
26    LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim, TextStyle,
27};
28
29/// A resolved line box: how tall the line is and where its baseline sits inside
30/// it, both measured down from the top of the box.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct LineBox {
33    /// Baseline-to-baseline advance, and the height of a single-line block.
34    pub height: f32,
35    /// Distance from the top of the box down to the baseline.
36    pub baseline: f32,
37}
38
39/// The font's own vertical extent, in the same unit as the line height.
40///
41/// `ascent` and `descent` are both **positive distances** from the baseline,
42/// which is the sign convention AOSP states its rule in and the opposite of the
43/// one `ab_glyph` reports `descent` in.
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub struct FontExtent {
46    pub ascent: f32,
47    pub descent: f32,
48    /// `hhea.lineGap`. Only read when a style asks for font padding.
49    pub line_gap: f32,
50}
51
52impl FontExtent {
53    pub fn new(ascent: f32, descent: f32, line_gap: f32) -> Self {
54        Self {
55            ascent,
56            descent,
57            line_gap,
58        }
59    }
60
61    /// Ascent plus descent — the height the font needs with no leading at all.
62    pub fn natural(self) -> f32 {
63        self.ascent + self.descent
64    }
65}
66
67/// The line box a style asks for, given the font's extent and the line height
68/// already resolved from the style's own units.
69///
70/// `asked` is the line height in the same unit as the extent. `grid` is how
71/// many device pixels there are to one of those units, and it is what every
72/// rounding in the AOSP rule is done against — pass `1.0` when the values are
73/// already device pixels, or the density when they are layout points. Getting
74/// it wrong does not shift a baseline by a fraction; it quantises the whole
75/// line box to the wrong step.
76pub fn line_box(style: &TextStyle, extent: FontExtent, asked: f32, grid: f32) -> LineBox {
77    match style.paragraph_style.line_height_style {
78        None => unstyled_line_box(extent, asked),
79        Some(line_height_style) => {
80            let padding = font_padding(style, extent);
81            let grid = if grid.is_finite() && grid > 0.0 {
82                grid
83            } else {
84                1.0
85            };
86            aosp_line_box(line_height_style, extent, asked, padding, grid)
87        }
88    }
89}
90
91/// The rule for a style that names no line-height policy: the box is the
92/// requested height and the leading is split evenly.
93///
94/// This is what the rasterizer has always done, kept bit for bit, because every
95/// style in the framework still lands here and none of them asked to move.
96fn unstyled_line_box(extent: FontExtent, asked: f32) -> LineBox {
97    let natural = extent.natural().ceil();
98    LineBox {
99        height: asked,
100        baseline: extent.ascent + (asked - natural) * 0.5,
101    }
102}
103
104/// `includeFontPadding`'s share of the leading.
105///
106/// Android's font padding is the gap between the `hhea` metrics and the tighter
107/// typographic ones. `ab_glyph` reports one pair of metrics and the line gap
108/// separately, so the closest honest reading is the line gap: `Some(true)`
109/// spends it, `Some(false)` and `None` do not. Wear's `DefaultTextStyle` sets
110/// it to `false`, which is the case this module exists to serve.
111fn font_padding(style: &TextStyle, extent: FontExtent) -> f32 {
112    let asked = style
113        .paragraph_style
114        .platform_style
115        .and_then(|platform| platform.include_font_padding)
116        .unwrap_or(false);
117    if asked && extent.line_gap.is_finite() && extent.line_gap > 0.0 {
118        extent.line_gap
119    } else {
120        0.0
121    }
122}
123
124fn aosp_line_box(
125    style: LineHeightStyle,
126    extent: FontExtent,
127    asked: f32,
128    padding: f32,
129    grid: f32,
130) -> LineBox {
131    let up = |value: f32| (value * grid).ceil() / grid;
132    let down = |value: f32| (value * grid).floor() / grid;
133    // Android hands its layout `Paint.FontMetricsInt`, whose ascent and descent
134    // are whole pixels — both rounded AWAY from the baseline, so neither can
135    // clip a glyph. Doing the leading split on unrounded metrics leaves a
136    // fractional remainder that the `ceil` below then spends in the wrong
137    // direction, and the baseline comes out under the ascent.
138    //
139    // `round` per metric, which is what the app this was measured against
140    // uses, agrees with `ceil` on all four text styles these screens draw.
141    // They diverge only where a metric's fraction is under a half, and none of
142    // Roboto's are at these sizes.
143    let ascent = up(extent.ascent.max(0.0));
144    let descent = up(extent.descent.max(0.0));
145    // Font padding widens the font's own demand, so it survives `Tight` and it
146    // is what a shorter requested line height has to beat.
147    let above_padding = down(padding * 0.5);
148    let below_padding = padding - above_padding;
149    let natural = up(ascent + descent + padding);
150    let asked = if asked.is_finite() {
151        up(asked)
152    } else {
153        natural
154    };
155
156    let height = match style.mode {
157        // The requested height, whatever the font wants.
158        LineHeightMode::Fixed => asked.max(1.0),
159        // The font's demand is a floor. This is what Android does and what the
160        // Wear text styles measure as.
161        LineHeightMode::Minimum => asked.max(natural).max(1.0),
162        // The font's demand, whatever was requested.
163        LineHeightMode::Tight => natural.max(1.0),
164    };
165
166    // Leading is whatever the box has over the font's own extent. It can be
167    // negative under `Fixed`, and then the glyphs simply overflow their box —
168    // which is also what Android does.
169    let leading = height - (ascent + descent + padding);
170    let (mut above, mut below) = match style.alignment {
171        // All of it below: the text sits at the top of its box.
172        LineHeightAlignment::Top => (0.0, leading),
173        // All of it above.
174        LineHeightAlignment::Bottom => (leading, 0.0),
175        // Split evenly, with the odd whole unit going BELOW the baseline.
176        // Splitting the other way is a one-pixel error on every line whose
177        // leading is odd, which at density 2 is every other line height.
178        LineHeightAlignment::Center => {
179            let below = up(leading * 0.5);
180            (leading - below, below)
181        }
182        // Split in the font's own ascent-to-descent ratio.
183        LineHeightAlignment::Proportional => {
184            let total = ascent + descent;
185            if total > 0.0 {
186                let above = leading * (ascent / total);
187                (above, leading - above)
188            } else {
189                (leading * 0.5, leading * 0.5)
190            }
191        }
192    };
193    above += above_padding;
194    below += below_padding;
195
196    // Trimming removes the leading the alignment just handed out, on whichever
197    // edge of the block it lands. A Wear row is one line, so both edges belong
198    // to the same box; a multi-line paragraph needs per-line boxes, which
199    // `TextMetrics` does not carry (its height is a flat `lines * advance`),
200    // so trimming there is not yet expressible and this stays a single-line
201    // rule.
202    let (trim_above, trim_below) = match style.trim {
203        LineHeightTrim::None => (false, false),
204        LineHeightTrim::FirstLineTop => (true, false),
205        LineHeightTrim::LastLineBottom => (false, true),
206        LineHeightTrim::Both => (true, true),
207    };
208    let mut height = height;
209    if trim_above {
210        height -= above;
211        above = 0.0;
212    }
213    if trim_below {
214        height -= below;
215    }
216
217    LineBox {
218        height: height.max(1.0),
219        baseline: above + ascent,
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::text::style::{ParagraphStyle, PlatformParagraphStyle};
227    use crate::text::TextUnit;
228
229    /// Roboto at 16sp on a density-2 watch: 32px of glyph, `hhea` ascender
230    /// 1900/2048 and descender 500/2048, so 29.69px above the baseline and
231    /// 7.81px below — 37.5px of natural extent against a 36px line height.
232    fn roboto_16sp() -> FontExtent {
233        FontExtent::new(32.0 * 1900.0 / 2048.0, 32.0 * 500.0 / 2048.0, 0.0)
234    }
235
236    fn styled(line_height_px: f32, line_height_style: Option<LineHeightStyle>) -> TextStyle {
237        TextStyle {
238            paragraph_style: ParagraphStyle {
239                line_height: TextUnit::Sp(line_height_px),
240                line_height_style,
241                ..ParagraphStyle::default()
242            },
243            ..TextStyle::default()
244        }
245    }
246
247    fn wear() -> LineHeightStyle {
248        LineHeightStyle {
249            alignment: LineHeightAlignment::Center,
250            trim: LineHeightTrim::None,
251            mode: LineHeightMode::Minimum,
252        }
253    }
254
255    #[test]
256    fn a_style_that_asks_for_nothing_gets_exactly_what_it_got_before() {
257        let extent = roboto_16sp();
258        let plain = line_box(&styled(36.0, None), extent, 36.0, 1.0);
259        assert_eq!(plain.height, 36.0);
260        let natural = extent.natural().ceil();
261        assert_eq!(plain.baseline, extent.ascent + (36.0 - natural) * 0.5);
262    }
263
264    #[test]
265    fn title_medium_overflows_its_own_line_height_and_the_font_wins() {
266        // The measured case: 16sp/18sp titleMedium lays out in 38px, not 36px.
267        let box_ = line_box(&styled(36.0, Some(wear())), roboto_16sp(), 36.0, 1.0);
268        assert_eq!(box_.height, 38.0);
269    }
270
271    #[test]
272    fn a_line_height_the_font_fits_inside_is_honoured_as_asked() {
273        // 15sp labelMedium: 30px glyph, 35.16px natural, 36px asked. The ask
274        // wins, and this is the case that is NOT visible on these screens.
275        let extent = FontExtent::new(30.0 * 1900.0 / 2048.0, 30.0 * 500.0 / 2048.0, 0.0);
276        let box_ = line_box(&styled(36.0, Some(wear())), extent, 36.0, 1.0);
277        assert_eq!(box_.height, 36.0);
278        // Whole-pixel metrics are 28 above and 8 below, so the ask is spent
279        // exactly and the baseline sits on the ascent.
280        assert_eq!(box_.baseline, 28.0);
281    }
282
283    #[test]
284    fn the_odd_unit_of_leading_goes_below_the_baseline_not_above() {
285        // 3 units of leading over a whole-numbered font: 1 above, 2 below.
286        let extent = FontExtent::new(20.0, 10.0, 0.0);
287        let box_ = line_box(&styled(33.0, Some(wear())), extent, 33.0, 1.0);
288        assert_eq!(box_.height, 33.0);
289        assert_eq!(box_.baseline, 21.0);
290        // Splitting the other way would put the baseline at 21.5 and every
291        // glyph row half a pixel out.
292        assert_ne!(box_.baseline, 20.0 + 1.5);
293    }
294
295    #[test]
296    fn a_line_height_is_a_whole_number_of_pixels() {
297        let extent = FontExtent::new(20.0, 10.0, 0.0);
298        let box_ = line_box(&styled(33.4, Some(wear())), extent, 33.4, 1.0);
299        assert_eq!(box_.height, 34.0);
300    }
301
302    #[test]
303    fn top_alignment_puts_the_glyphs_at_the_top_and_bottom_at_the_bottom() {
304        let extent = FontExtent::new(20.0, 10.0, 0.0);
305        let top = line_box(
306            &styled(
307                40.0,
308                Some(LineHeightStyle {
309                    alignment: LineHeightAlignment::Top,
310                    ..wear()
311                }),
312            ),
313            extent,
314            40.0,
315            1.0,
316        );
317        assert_eq!(top.baseline, 20.0);
318        let bottom = line_box(
319            &styled(
320                40.0,
321                Some(LineHeightStyle {
322                    alignment: LineHeightAlignment::Bottom,
323                    ..wear()
324                }),
325            ),
326            extent,
327            40.0,
328            1.0,
329        );
330        assert_eq!(bottom.baseline, 30.0);
331        assert_eq!(bottom.height - bottom.baseline, extent.descent);
332    }
333
334    #[test]
335    fn proportional_alignment_splits_the_leading_the_way_the_font_is_split() {
336        let extent = FontExtent::new(20.0, 10.0, 0.0);
337        let style = LineHeightStyle {
338            alignment: LineHeightAlignment::Proportional,
339            ..wear()
340        };
341        let box_ = line_box(&styled(60.0, Some(style)), extent, 60.0, 1.0);
342        // 30 of leading split 2:1, so 20 above.
343        assert_eq!(box_.baseline, 40.0);
344    }
345
346    #[test]
347    fn a_fixed_line_height_lets_the_font_overflow_and_tight_ignores_the_ask() {
348        let extent = roboto_16sp();
349        let fixed = line_box(
350            &styled(
351                36.0,
352                Some(LineHeightStyle {
353                    mode: LineHeightMode::Fixed,
354                    ..wear()
355                }),
356            ),
357            extent,
358            36.0,
359            1.0,
360        );
361        assert_eq!(
362            fixed.height, 36.0,
363            "the ask wins even though the font needs 38"
364        );
365        let tight = line_box(
366            &styled(
367                80.0,
368                Some(LineHeightStyle {
369                    mode: LineHeightMode::Tight,
370                    ..wear()
371                }),
372            ),
373            extent,
374            80.0,
375            1.0,
376        );
377        // Whole-pixel metrics: 30 above the baseline and 8 below.
378        assert_eq!(tight.height, 38.0);
379        assert_eq!(tight.baseline, 30.0);
380    }
381
382    #[test]
383    fn trimming_removes_the_leading_on_the_edge_it_names() {
384        let extent = FontExtent::new(20.0, 10.0, 0.0);
385        let both = line_box(
386            &styled(
387                40.0,
388                Some(LineHeightStyle {
389                    trim: LineHeightTrim::Both,
390                    ..wear()
391                }),
392            ),
393            extent,
394            40.0,
395            1.0,
396        );
397        assert_eq!(both.height, 30.0);
398        assert_eq!(both.baseline, 20.0);
399
400        let top_only = line_box(
401            &styled(
402                40.0,
403                Some(LineHeightStyle {
404                    trim: LineHeightTrim::FirstLineTop,
405                    ..wear()
406                }),
407            ),
408            extent,
409            40.0,
410            1.0,
411        );
412        // 10 of leading, 5 above and 5 below; only the top is removed.
413        assert_eq!(top_only.height, 35.0);
414        assert_eq!(top_only.baseline, 20.0);
415    }
416
417    #[test]
418    fn font_padding_is_only_spent_when_a_style_asks_for_it() {
419        let extent = FontExtent::new(20.0, 10.0, 4.0);
420        let without = line_box(&styled(30.0, Some(wear())), extent, 30.0, 1.0);
421        assert_eq!(without.height, 30.0);
422        assert_eq!(without.baseline, 20.0);
423
424        let padded = TextStyle {
425            paragraph_style: ParagraphStyle {
426                line_height: TextUnit::Sp(30.0),
427                line_height_style: Some(wear()),
428                platform_style: Some(PlatformParagraphStyle {
429                    include_font_padding: Some(true),
430                    shaping: None,
431                }),
432                ..ParagraphStyle::default()
433            },
434            ..TextStyle::default()
435        };
436        let with = line_box(&padded, extent, 30.0, 1.0);
437        assert_eq!(with.height, 34.0, "the line gap widens the font's demand");
438        assert_eq!(with.baseline, 22.0, "and half of it sits above the ascent");
439    }
440
441    #[test]
442    fn a_nonsense_line_height_falls_back_to_the_font_rather_than_producing_nan() {
443        let extent = FontExtent::new(20.0, 10.0, 0.0);
444        let box_ = line_box(&styled(30.0, Some(wear())), extent, f32::NAN, 1.0);
445        assert_eq!(box_.height, 30.0);
446        assert!(box_.baseline.is_finite());
447    }
448}