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
97/// The rule for a style that names no line-height policy: the box is the
98/// requested height and the leading is split evenly.
99///
100/// The ceil is on the **device grid**, not on the caller's own unit, and that
101/// is the whole of the fix here. A measurer works in layout points and passes
102/// the density; the rasterizer works in device pixels and passes `1.0`. Ceiling
103/// in the caller's unit made those two disagree: one 12sp/16sp style came out
104/// `ceil(14.0625 dp) = 15 dp = 30 px` on the measuring side and
105/// `ceil(28.125 px) = 29 px` on the drawing side, and the glyph landed on a
106/// half pixel that then rounded down. Faces whose two ceils happened to agree
107/// were exact, which is why this hid for so long and why it showed on Credits
108/// and the title but never on Settings.
109///
110/// There is no ground truth for what an unstyled box *should* be — Compose
111/// always names a policy, so there is no platform behaviour to copy — and this
112/// change does not invent one. It moves the measurer onto the number the
113/// rasterizer already produces, because the rasterizer can only work in whole
114/// device pixels and is therefore the side that cannot be wrong about them.
115/// At `grid = 1.0` the arithmetic is unchanged bit for bit.
116fn unstyled_line_box(extent: FontExtent, asked: f32, grid: f32) -> LineBox {
117    let natural = (extent.natural() * grid).ceil() / grid;
118    LineBox {
119        height: asked,
120        baseline: extent.ascent + (asked - natural) * 0.5,
121    }
122}
123
124/// `includeFontPadding`'s share of the leading.
125///
126/// Android's font padding is the gap between the `hhea` metrics and the tighter
127/// typographic ones. `ab_glyph` reports one pair of metrics and the line gap
128/// separately, so the closest honest reading is the line gap: `Some(true)`
129/// spends it, `Some(false)` and `None` do not. Wear's `DefaultTextStyle` sets
130/// it to `false`, which is the case this module exists to serve.
131fn font_padding(style: &TextStyle, extent: FontExtent) -> f32 {
132    let asked = style
133        .paragraph_style
134        .platform_style
135        .and_then(|platform| platform.include_font_padding)
136        .unwrap_or(false);
137    if asked && extent.line_gap.is_finite() && extent.line_gap > 0.0 {
138        extent.line_gap
139    } else {
140        0.0
141    }
142}
143
144fn aosp_line_box(
145    style: LineHeightStyle,
146    extent: FontExtent,
147    asked: f32,
148    padding: f32,
149    grid: f32,
150) -> LineBox {
151    let up = |value: f32| (value * grid).ceil() / grid;
152    let down = |value: f32| (value * grid).floor() / grid;
153    // Android hands its layout `Paint.FontMetricsInt`, whose ascent and descent
154    // are whole pixels. Doing the leading split on unrounded metrics leaves a
155    // fractional remainder that the rounding below then spends in the wrong
156    // direction, and the baseline comes out under the ascent.
157    //
158    // The rounding is Skia's `SkScalarRoundToInt`, `floor(x + 0.5)`, applied to
159    // the SIGNED metric — so it is round-half-up on the descent and round-half-
160    // down on the ascent, which is a positive distance here and therefore the
161    // negative one there. It is NOT rounding away from the baseline. Measured
162    // on a Wear OS 5 emulator (API 34, density 2) by asking the platform:
163    // `Paint.getFontMetricsInt()` for Roboto at eight text sizes, against
164    // `getFontMetrics()`'s floats.
165    //
166    //     size px   float ascent / descent      FontMetricsInt ascent / descent
167    //      24.0     -22.265625 /  5.859375           -22 /  6
168    //      26.0     -24.121094 /  6.3476562          -24 /  6
169    //      30.0     -27.832031 /  7.3242188          -28 /  7
170    //      32.0     -29.6875   /  7.8125             -30 /  8
171    //      37.2     -34.51172  /  9.082031           -35 /  9
172    //      38.72    -35.921875 /  9.453125           -36 /  9
173    //
174    // Five of those six fractions are under a half on at least one metric, and
175    // a `ceil` gets every one of them wrong. `StaticLayout`'s own line height
176    // with `includeFontPadding=false` was `(-ascent) + descent` of the rounded
177    // pair in all eight sizes, so this is the pair the layout is built from.
178    //
179    // The size that made it visible is 19sp at density 2, a 38px font: the
180    // rounded pair is 35 and 9 for a 44px line, and `ceil` makes it 36 and 10
181    // for a 46px one. The shipping Compose build lays that line out in 44.
182    let round = |value: f32| ((value * grid) + 0.5).floor() / grid;
183    let ascent = -round(-extent.ascent.max(0.0));
184    let descent = round(extent.descent.max(0.0));
185    // Font padding widens the font's own demand, so it survives `Tight` and it
186    // is what a shorter requested line height has to beat.
187    let above_padding = down(padding * 0.5);
188    let below_padding = padding - above_padding;
189    let natural = up(ascent + descent + padding);
190    let asked = if asked.is_finite() {
191        up(asked)
192    } else {
193        natural
194    };
195
196    let height = match style.mode {
197        // The requested height, whatever the font wants.
198        LineHeightMode::Fixed => asked.max(1.0),
199        // The font's demand is a floor. This is what Android does and what the
200        // Wear text styles measure as.
201        LineHeightMode::Minimum => asked.max(natural).max(1.0),
202        // The font's demand, whatever was requested.
203        LineHeightMode::Tight => natural.max(1.0),
204    };
205
206    // Leading is whatever the box has over the font's own extent. It can be
207    // negative under `Fixed`, and then the glyphs simply overflow their box —
208    // which is also what Android does.
209    let leading = height - (ascent + descent + padding);
210    let (mut above, mut below) = match style.alignment {
211        // All of it below: the text sits at the top of its box.
212        LineHeightAlignment::Top => (0.0, leading),
213        // All of it above.
214        LineHeightAlignment::Bottom => (leading, 0.0),
215        // Split evenly, with the odd whole unit going BELOW the baseline.
216        // Splitting the other way is a one-pixel error on every line whose
217        // leading is odd, which at density 2 is every other line height.
218        LineHeightAlignment::Center => {
219            let below = up(leading * 0.5);
220            (leading - below, below)
221        }
222        // Split in the font's own ascent-to-descent ratio.
223        LineHeightAlignment::Proportional => {
224            let total = ascent + descent;
225            if total > 0.0 {
226                let above = leading * (ascent / total);
227                (above, leading - above)
228            } else {
229                (leading * 0.5, leading * 0.5)
230            }
231        }
232    };
233    above += above_padding;
234    below += below_padding;
235
236    // Trimming removes the leading the alignment just handed out, on whichever
237    // edge of the block it lands. A Wear row is one line, so both edges belong
238    // to the same box; a multi-line paragraph needs per-line boxes, which
239    // `TextMetrics` does not carry (its height is a flat `lines * advance`),
240    // so trimming there is not yet expressible and this stays a single-line
241    // rule.
242    let (trim_above, trim_below) = match style.trim {
243        LineHeightTrim::None => (false, false),
244        LineHeightTrim::FirstLineTop => (true, false),
245        LineHeightTrim::LastLineBottom => (false, true),
246        LineHeightTrim::Both => (true, true),
247    };
248    let mut height = height;
249    if trim_above {
250        height -= above;
251        above = 0.0;
252    }
253    if trim_below {
254        height -= below;
255    }
256
257    LineBox {
258        height: height.max(1.0),
259        baseline: above + ascent,
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::text::{
267        TextUnit,
268        style::{ParagraphStyle, PlatformParagraphStyle},
269    };
270
271    /// Roboto at 16sp on a density-2 watch: 32px of glyph, `hhea` ascender
272    /// 1900/2048 and descender 500/2048, so 29.69px above the baseline and
273    /// 7.81px below — 37.5px of natural extent against a 36px line height.
274    fn roboto_16sp() -> FontExtent {
275        FontExtent::new(32.0 * 1900.0 / 2048.0, 32.0 * 500.0 / 2048.0, 0.0)
276    }
277
278    fn styled(line_height_px: f32, line_height_style: Option<LineHeightStyle>) -> TextStyle {
279        TextStyle {
280            paragraph_style: ParagraphStyle {
281                line_height: TextUnit::Sp(line_height_px),
282                line_height_style,
283                ..ParagraphStyle::default()
284            },
285            ..TextStyle::default()
286        }
287    }
288
289    fn wear() -> LineHeightStyle {
290        LineHeightStyle {
291            alignment: LineHeightAlignment::Center,
292            trim: LineHeightTrim::None,
293            mode: LineHeightMode::Minimum,
294        }
295    }
296
297    #[test]
298    fn a_style_that_asks_for_nothing_gets_exactly_what_it_got_before() {
299        let extent = roboto_16sp();
300        let plain = line_box(&styled(36.0, None), extent, 36.0, 1.0);
301        assert_eq!(plain.height, 36.0);
302        let natural = extent.natural().ceil();
303        assert_eq!(plain.baseline, extent.ascent + (36.0 - natural) * 0.5);
304    }
305
306    #[test]
307    fn an_unstyled_box_is_the_same_box_measured_in_points_or_in_pixels() {
308        // The measurer works in layout points and passes the density; the
309        // rasterizer works in device pixels and passes 1.0. A style naming no
310        // line-height policy has to come out the same physical box either way,
311        // or a run measures one height and draws another. It did not: at
312        // density 2 the 12sp/16sp case below was 15dp = 30px measured and 29px
313        // drawn, and the glyph landed on a half pixel.
314        //
315        // The style here is deliberately the unstyled one -- `None` -- because
316        // the AOSP branch has always taken the grid and was never adrift.
317        let density = 2.0;
318        for glyph_px in [24.0f32, 26.0, 28.125, 30.0, 32.0, 37.2, 38.72] {
319            let ascent_px = glyph_px * 1900.0 / 2048.0;
320            let descent_px = glyph_px * 500.0 / 2048.0;
321            let asked_px = (glyph_px * 4.0 / 3.0).round();
322
323            let in_pixels = line_box(
324                &styled(asked_px, None),
325                FontExtent::new(ascent_px, descent_px, 0.0),
326                asked_px,
327                1.0,
328            );
329            let in_points = line_box(
330                &styled(asked_px / density, None),
331                FontExtent::new(ascent_px / density, descent_px / density, 0.0),
332                asked_px / density,
333                density,
334            );
335
336            assert!(
337                (in_points.height * density - in_pixels.height).abs() < 1e-4,
338                "{glyph_px}px: height {} in points against {} in pixels",
339                in_points.height * density,
340                in_pixels.height
341            );
342            assert!(
343                (in_points.baseline * density - in_pixels.baseline).abs() < 1e-4,
344                "{glyph_px}px: baseline {} in points against {} in pixels",
345                in_points.baseline * density,
346                in_pixels.baseline
347            );
348        }
349    }
350
351    #[test]
352    fn title_medium_overflows_its_own_line_height_and_the_font_wins() {
353        // The measured case: 16sp/18sp titleMedium lays out in 38px, not 36px.
354        let box_ = line_box(&styled(36.0, Some(wear())), roboto_16sp(), 36.0, 1.0);
355        assert_eq!(box_.height, 38.0);
356    }
357
358    #[test]
359    fn a_line_height_the_font_fits_inside_is_honoured_as_asked() {
360        // 15sp labelMedium: 30px glyph, 35.16px natural, 36px asked. The ask
361        // wins, and this is the case that is NOT visible on these screens.
362        let extent = FontExtent::new(30.0 * 1900.0 / 2048.0, 30.0 * 500.0 / 2048.0, 0.0);
363        let box_ = line_box(&styled(36.0, Some(wear())), extent, 36.0, 1.0);
364        assert_eq!(box_.height, 36.0);
365        // Whole-pixel metrics are 28 above and 7 below, so one pixel of leading
366        // is left and it goes below the baseline.
367        assert_eq!(box_.baseline, 28.0);
368    }
369
370    #[test]
371    fn the_font_metrics_are_rounded_the_way_the_platform_rounds_them() {
372        // `Paint.getFontMetricsInt()` is `SkScalarRoundToInt` per metric, which
373        // is `floor(x + 0.5)` on the SIGNED value. Rounding away from the
374        // baseline instead makes a 19sp line at density 2 two pixels too tall,
375        // which moved the whole title screen. Every pair here was read off a
376        // Wear OS 5 emulator at density 2; the extent is the font's own float
377        // metrics at that size, and the assertion is the pair the platform
378        // reported.
379        for (size_px, ascent_px, descent_px) in [
380            (24.0_f32, 22.0_f32, 6.0_f32),
381            (26.0, 24.0, 6.0),
382            (30.0, 28.0, 7.0),
383            (32.0, 30.0, 8.0),
384            (37.2, 35.0, 9.0),
385            (38.72, 36.0, 9.0),
386            (43.76, 41.0, 11.0),
387            // Not in the probe's list, but the size the defect showed up at:
388            // 19sp at density 2, which Compose lays out in 44px and a `ceil`
389            // would lay out in 46.
390            (38.0, 35.0, 9.0),
391        ] {
392            let extent = FontExtent::new(size_px * 1900.0 / 2048.0, size_px * 500.0 / 2048.0, 0.0);
393            // `Tight` reports the font's own demand, which is exactly the
394            // rounded pair the platform's layout is built from.
395            let tight = line_box(
396                &styled(
397                    0.0,
398                    Some(LineHeightStyle {
399                        mode: LineHeightMode::Tight,
400                        ..wear()
401                    }),
402                ),
403                extent,
404                0.0,
405                1.0,
406            );
407            assert_eq!(
408                (tight.baseline, tight.height - tight.baseline),
409                (ascent_px, descent_px),
410                "{size_px}px",
411            );
412        }
413    }
414
415    #[test]
416    fn the_wear_type_scale_lays_out_in_the_boxes_the_platform_gives_it() {
417        // The composed widgets and the drawn canvas both resolve through here,
418        // so these are the numbers a Wear screen is built out of. Density 2,
419        // device pixels, at the two text-size settings that matter: 1.0, where
420        // an sp is a plain doubling, and the setting Wear calls large, where
421        // Android 14's table gives 13sp -> 32.72px, 15sp -> 37.2px,
422        // 16sp -> 38.72px and 18sp -> 41.76px.
423        for (name, size_px, line_height_px, height, baseline) in [
424            ("titleMedium 1.0", 32.0_f32, 36.0_f32, 38.0_f32, 30.0_f32),
425            ("labelMedium 1.0", 30.0, 36.0, 36.0, 28.0),
426            ("labelSmall 1.0", 26.0, 32.0, 32.0, 25.0),
427            ("titleMedium 1.24", 38.72, 41.76, 45.0, 36.0),
428            ("labelMedium 1.24", 37.2, 41.76, 44.0, 35.0),
429            ("labelSmall 1.24", 32.72, 38.72, 39.0, 30.0),
430        ] {
431            let extent = FontExtent::new(size_px * 1900.0 / 2048.0, size_px * 500.0 / 2048.0, 0.0);
432            let resolved = line_box(
433                &styled(line_height_px, Some(wear())),
434                extent,
435                line_height_px,
436                1.0,
437            );
438            assert_eq!(
439                (resolved.height, resolved.baseline),
440                (height, baseline),
441                "{name}",
442            );
443        }
444    }
445
446    #[test]
447    fn the_odd_unit_of_leading_goes_below_the_baseline_not_above() {
448        // 3 units of leading over a whole-numbered font: 1 above, 2 below.
449        let extent = FontExtent::new(20.0, 10.0, 0.0);
450        let box_ = line_box(&styled(33.0, Some(wear())), extent, 33.0, 1.0);
451        assert_eq!(box_.height, 33.0);
452        assert_eq!(box_.baseline, 21.0);
453        // Splitting the other way would put the baseline at 21.5 and every
454        // glyph row half a pixel out.
455        assert_ne!(box_.baseline, 20.0 + 1.5);
456    }
457
458    #[test]
459    fn a_line_height_is_a_whole_number_of_pixels() {
460        let extent = FontExtent::new(20.0, 10.0, 0.0);
461        let box_ = line_box(&styled(33.4, Some(wear())), extent, 33.4, 1.0);
462        assert_eq!(box_.height, 34.0);
463    }
464
465    #[test]
466    fn top_alignment_puts_the_glyphs_at_the_top_and_bottom_at_the_bottom() {
467        let extent = FontExtent::new(20.0, 10.0, 0.0);
468        let top = line_box(
469            &styled(
470                40.0,
471                Some(LineHeightStyle {
472                    alignment: LineHeightAlignment::Top,
473                    ..wear()
474                }),
475            ),
476            extent,
477            40.0,
478            1.0,
479        );
480        assert_eq!(top.baseline, 20.0);
481        let bottom = line_box(
482            &styled(
483                40.0,
484                Some(LineHeightStyle {
485                    alignment: LineHeightAlignment::Bottom,
486                    ..wear()
487                }),
488            ),
489            extent,
490            40.0,
491            1.0,
492        );
493        assert_eq!(bottom.baseline, 30.0);
494        assert_eq!(bottom.height - bottom.baseline, extent.descent);
495    }
496
497    #[test]
498    fn proportional_alignment_splits_the_leading_the_way_the_font_is_split() {
499        let extent = FontExtent::new(20.0, 10.0, 0.0);
500        let style = LineHeightStyle {
501            alignment: LineHeightAlignment::Proportional,
502            ..wear()
503        };
504        let box_ = line_box(&styled(60.0, Some(style)), extent, 60.0, 1.0);
505        // 30 of leading split 2:1, so 20 above.
506        assert_eq!(box_.baseline, 40.0);
507    }
508
509    #[test]
510    fn a_fixed_line_height_lets_the_font_overflow_and_tight_ignores_the_ask() {
511        let extent = roboto_16sp();
512        let fixed = line_box(
513            &styled(
514                36.0,
515                Some(LineHeightStyle {
516                    mode: LineHeightMode::Fixed,
517                    ..wear()
518                }),
519            ),
520            extent,
521            36.0,
522            1.0,
523        );
524        assert_eq!(
525            fixed.height, 36.0,
526            "the ask wins even though the font needs 38"
527        );
528        let tight = line_box(
529            &styled(
530                80.0,
531                Some(LineHeightStyle {
532                    mode: LineHeightMode::Tight,
533                    ..wear()
534                }),
535            ),
536            extent,
537            80.0,
538            1.0,
539        );
540        // Whole-pixel metrics: 30 above the baseline and 8 below.
541        assert_eq!(tight.height, 38.0);
542        assert_eq!(tight.baseline, 30.0);
543    }
544
545    #[test]
546    fn trimming_removes_the_leading_on_the_edge_it_names() {
547        let extent = FontExtent::new(20.0, 10.0, 0.0);
548        let both = line_box(
549            &styled(
550                40.0,
551                Some(LineHeightStyle {
552                    trim: LineHeightTrim::Both,
553                    ..wear()
554                }),
555            ),
556            extent,
557            40.0,
558            1.0,
559        );
560        assert_eq!(both.height, 30.0);
561        assert_eq!(both.baseline, 20.0);
562
563        let top_only = line_box(
564            &styled(
565                40.0,
566                Some(LineHeightStyle {
567                    trim: LineHeightTrim::FirstLineTop,
568                    ..wear()
569                }),
570            ),
571            extent,
572            40.0,
573            1.0,
574        );
575        // 10 of leading, 5 above and 5 below; only the top is removed.
576        assert_eq!(top_only.height, 35.0);
577        assert_eq!(top_only.baseline, 20.0);
578    }
579
580    #[test]
581    fn font_padding_is_only_spent_when_a_style_asks_for_it() {
582        let extent = FontExtent::new(20.0, 10.0, 4.0);
583        let without = line_box(&styled(30.0, Some(wear())), extent, 30.0, 1.0);
584        assert_eq!(without.height, 30.0);
585        assert_eq!(without.baseline, 20.0);
586
587        let padded = TextStyle {
588            paragraph_style: ParagraphStyle {
589                line_height: TextUnit::Sp(30.0),
590                line_height_style: Some(wear()),
591                platform_style: Some(PlatformParagraphStyle {
592                    include_font_padding: Some(true),
593                    shaping: None,
594                }),
595                ..ParagraphStyle::default()
596            },
597            ..TextStyle::default()
598        };
599        let with = line_box(&padded, extent, 30.0, 1.0);
600        assert_eq!(with.height, 34.0, "the line gap widens the font's demand");
601        assert_eq!(with.baseline, 22.0, "and half of it sits above the ascent");
602    }
603
604    #[test]
605    fn a_nonsense_line_height_falls_back_to_the_font_rather_than_producing_nan() {
606        let extent = FontExtent::new(20.0, 10.0, 0.0);
607        let box_ = line_box(&styled(30.0, Some(wear())), extent, f32::NAN, 1.0);
608        assert_eq!(box_.height, 30.0);
609        assert!(box_.baseline.is_finite());
610    }
611}