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::style::{ParagraphStyle, PlatformParagraphStyle};
267    use crate::text::TextUnit;
268
269    /// Roboto at 16sp on a density-2 watch: 32px of glyph, `hhea` ascender
270    /// 1900/2048 and descender 500/2048, so 29.69px above the baseline and
271    /// 7.81px below — 37.5px of natural extent against a 36px line height.
272    fn roboto_16sp() -> FontExtent {
273        FontExtent::new(32.0 * 1900.0 / 2048.0, 32.0 * 500.0 / 2048.0, 0.0)
274    }
275
276    fn styled(line_height_px: f32, line_height_style: Option<LineHeightStyle>) -> TextStyle {
277        TextStyle {
278            paragraph_style: ParagraphStyle {
279                line_height: TextUnit::Sp(line_height_px),
280                line_height_style,
281                ..ParagraphStyle::default()
282            },
283            ..TextStyle::default()
284        }
285    }
286
287    fn wear() -> LineHeightStyle {
288        LineHeightStyle {
289            alignment: LineHeightAlignment::Center,
290            trim: LineHeightTrim::None,
291            mode: LineHeightMode::Minimum,
292        }
293    }
294
295    #[test]
296    fn a_style_that_asks_for_nothing_gets_exactly_what_it_got_before() {
297        let extent = roboto_16sp();
298        let plain = line_box(&styled(36.0, None), extent, 36.0, 1.0);
299        assert_eq!(plain.height, 36.0);
300        let natural = extent.natural().ceil();
301        assert_eq!(plain.baseline, extent.ascent + (36.0 - natural) * 0.5);
302    }
303
304    #[test]
305    fn an_unstyled_box_is_the_same_box_measured_in_points_or_in_pixels() {
306        // The measurer works in layout points and passes the density; the
307        // rasterizer works in device pixels and passes 1.0. A style naming no
308        // line-height policy has to come out the same physical box either way,
309        // or a run measures one height and draws another. It did not: at
310        // density 2 the 12sp/16sp case below was 15dp = 30px measured and 29px
311        // drawn, and the glyph landed on a half pixel.
312        //
313        // The style here is deliberately the unstyled one -- `None` -- because
314        // the AOSP branch has always taken the grid and was never adrift.
315        let density = 2.0;
316        for glyph_px in [24.0f32, 26.0, 28.125, 30.0, 32.0, 37.2, 38.72] {
317            let ascent_px = glyph_px * 1900.0 / 2048.0;
318            let descent_px = glyph_px * 500.0 / 2048.0;
319            let asked_px = (glyph_px * 4.0 / 3.0).round();
320
321            let in_pixels = line_box(
322                &styled(asked_px, None),
323                FontExtent::new(ascent_px, descent_px, 0.0),
324                asked_px,
325                1.0,
326            );
327            let in_points = line_box(
328                &styled(asked_px / density, None),
329                FontExtent::new(ascent_px / density, descent_px / density, 0.0),
330                asked_px / density,
331                density,
332            );
333
334            assert!(
335                (in_points.height * density - in_pixels.height).abs() < 1e-4,
336                "{glyph_px}px: height {} in points against {} in pixels",
337                in_points.height * density,
338                in_pixels.height
339            );
340            assert!(
341                (in_points.baseline * density - in_pixels.baseline).abs() < 1e-4,
342                "{glyph_px}px: baseline {} in points against {} in pixels",
343                in_points.baseline * density,
344                in_pixels.baseline
345            );
346        }
347    }
348
349    #[test]
350    fn title_medium_overflows_its_own_line_height_and_the_font_wins() {
351        // The measured case: 16sp/18sp titleMedium lays out in 38px, not 36px.
352        let box_ = line_box(&styled(36.0, Some(wear())), roboto_16sp(), 36.0, 1.0);
353        assert_eq!(box_.height, 38.0);
354    }
355
356    #[test]
357    fn a_line_height_the_font_fits_inside_is_honoured_as_asked() {
358        // 15sp labelMedium: 30px glyph, 35.16px natural, 36px asked. The ask
359        // wins, and this is the case that is NOT visible on these screens.
360        let extent = FontExtent::new(30.0 * 1900.0 / 2048.0, 30.0 * 500.0 / 2048.0, 0.0);
361        let box_ = line_box(&styled(36.0, Some(wear())), extent, 36.0, 1.0);
362        assert_eq!(box_.height, 36.0);
363        // Whole-pixel metrics are 28 above and 7 below, so one pixel of leading
364        // is left and it goes below the baseline.
365        assert_eq!(box_.baseline, 28.0);
366    }
367
368    #[test]
369    fn the_font_metrics_are_rounded_the_way_the_platform_rounds_them() {
370        // `Paint.getFontMetricsInt()` is `SkScalarRoundToInt` per metric, which
371        // is `floor(x + 0.5)` on the SIGNED value. Rounding away from the
372        // baseline instead makes a 19sp line at density 2 two pixels too tall,
373        // which moved the whole title screen. Every pair here was read off a
374        // Wear OS 5 emulator at density 2; the extent is the font's own float
375        // metrics at that size, and the assertion is the pair the platform
376        // reported.
377        for (size_px, ascent_px, descent_px) in [
378            (24.0_f32, 22.0_f32, 6.0_f32),
379            (26.0, 24.0, 6.0),
380            (30.0, 28.0, 7.0),
381            (32.0, 30.0, 8.0),
382            (37.2, 35.0, 9.0),
383            (38.72, 36.0, 9.0),
384            (43.76, 41.0, 11.0),
385            // Not in the probe's list, but the size the defect showed up at:
386            // 19sp at density 2, which Compose lays out in 44px and a `ceil`
387            // would lay out in 46.
388            (38.0, 35.0, 9.0),
389        ] {
390            let extent = FontExtent::new(size_px * 1900.0 / 2048.0, size_px * 500.0 / 2048.0, 0.0);
391            // `Tight` reports the font's own demand, which is exactly the
392            // rounded pair the platform's layout is built from.
393            let tight = line_box(
394                &styled(
395                    0.0,
396                    Some(LineHeightStyle {
397                        mode: LineHeightMode::Tight,
398                        ..wear()
399                    }),
400                ),
401                extent,
402                0.0,
403                1.0,
404            );
405            assert_eq!(
406                (tight.baseline, tight.height - tight.baseline),
407                (ascent_px, descent_px),
408                "{size_px}px",
409            );
410        }
411    }
412
413    #[test]
414    fn the_wear_type_scale_lays_out_in_the_boxes_the_platform_gives_it() {
415        // The composed widgets and the drawn canvas both resolve through here,
416        // so these are the numbers a Wear screen is built out of. Density 2,
417        // device pixels, at the two text-size settings that matter: 1.0, where
418        // an sp is a plain doubling, and the setting Wear calls large, where
419        // Android 14's table gives 13sp -> 32.72px, 15sp -> 37.2px,
420        // 16sp -> 38.72px and 18sp -> 41.76px.
421        for (name, size_px, line_height_px, height, baseline) in [
422            ("titleMedium 1.0", 32.0_f32, 36.0_f32, 38.0_f32, 30.0_f32),
423            ("labelMedium 1.0", 30.0, 36.0, 36.0, 28.0),
424            ("labelSmall 1.0", 26.0, 32.0, 32.0, 25.0),
425            ("titleMedium 1.24", 38.72, 41.76, 45.0, 36.0),
426            ("labelMedium 1.24", 37.2, 41.76, 44.0, 35.0),
427            ("labelSmall 1.24", 32.72, 38.72, 39.0, 30.0),
428        ] {
429            let extent = FontExtent::new(size_px * 1900.0 / 2048.0, size_px * 500.0 / 2048.0, 0.0);
430            let resolved = line_box(
431                &styled(line_height_px, Some(wear())),
432                extent,
433                line_height_px,
434                1.0,
435            );
436            assert_eq!(
437                (resolved.height, resolved.baseline),
438                (height, baseline),
439                "{name}",
440            );
441        }
442    }
443
444    #[test]
445    fn the_odd_unit_of_leading_goes_below_the_baseline_not_above() {
446        // 3 units of leading over a whole-numbered font: 1 above, 2 below.
447        let extent = FontExtent::new(20.0, 10.0, 0.0);
448        let box_ = line_box(&styled(33.0, Some(wear())), extent, 33.0, 1.0);
449        assert_eq!(box_.height, 33.0);
450        assert_eq!(box_.baseline, 21.0);
451        // Splitting the other way would put the baseline at 21.5 and every
452        // glyph row half a pixel out.
453        assert_ne!(box_.baseline, 20.0 + 1.5);
454    }
455
456    #[test]
457    fn a_line_height_is_a_whole_number_of_pixels() {
458        let extent = FontExtent::new(20.0, 10.0, 0.0);
459        let box_ = line_box(&styled(33.4, Some(wear())), extent, 33.4, 1.0);
460        assert_eq!(box_.height, 34.0);
461    }
462
463    #[test]
464    fn top_alignment_puts_the_glyphs_at_the_top_and_bottom_at_the_bottom() {
465        let extent = FontExtent::new(20.0, 10.0, 0.0);
466        let top = line_box(
467            &styled(
468                40.0,
469                Some(LineHeightStyle {
470                    alignment: LineHeightAlignment::Top,
471                    ..wear()
472                }),
473            ),
474            extent,
475            40.0,
476            1.0,
477        );
478        assert_eq!(top.baseline, 20.0);
479        let bottom = line_box(
480            &styled(
481                40.0,
482                Some(LineHeightStyle {
483                    alignment: LineHeightAlignment::Bottom,
484                    ..wear()
485                }),
486            ),
487            extent,
488            40.0,
489            1.0,
490        );
491        assert_eq!(bottom.baseline, 30.0);
492        assert_eq!(bottom.height - bottom.baseline, extent.descent);
493    }
494
495    #[test]
496    fn proportional_alignment_splits_the_leading_the_way_the_font_is_split() {
497        let extent = FontExtent::new(20.0, 10.0, 0.0);
498        let style = LineHeightStyle {
499            alignment: LineHeightAlignment::Proportional,
500            ..wear()
501        };
502        let box_ = line_box(&styled(60.0, Some(style)), extent, 60.0, 1.0);
503        // 30 of leading split 2:1, so 20 above.
504        assert_eq!(box_.baseline, 40.0);
505    }
506
507    #[test]
508    fn a_fixed_line_height_lets_the_font_overflow_and_tight_ignores_the_ask() {
509        let extent = roboto_16sp();
510        let fixed = line_box(
511            &styled(
512                36.0,
513                Some(LineHeightStyle {
514                    mode: LineHeightMode::Fixed,
515                    ..wear()
516                }),
517            ),
518            extent,
519            36.0,
520            1.0,
521        );
522        assert_eq!(
523            fixed.height, 36.0,
524            "the ask wins even though the font needs 38"
525        );
526        let tight = line_box(
527            &styled(
528                80.0,
529                Some(LineHeightStyle {
530                    mode: LineHeightMode::Tight,
531                    ..wear()
532                }),
533            ),
534            extent,
535            80.0,
536            1.0,
537        );
538        // Whole-pixel metrics: 30 above the baseline and 8 below.
539        assert_eq!(tight.height, 38.0);
540        assert_eq!(tight.baseline, 30.0);
541    }
542
543    #[test]
544    fn trimming_removes_the_leading_on_the_edge_it_names() {
545        let extent = FontExtent::new(20.0, 10.0, 0.0);
546        let both = line_box(
547            &styled(
548                40.0,
549                Some(LineHeightStyle {
550                    trim: LineHeightTrim::Both,
551                    ..wear()
552                }),
553            ),
554            extent,
555            40.0,
556            1.0,
557        );
558        assert_eq!(both.height, 30.0);
559        assert_eq!(both.baseline, 20.0);
560
561        let top_only = line_box(
562            &styled(
563                40.0,
564                Some(LineHeightStyle {
565                    trim: LineHeightTrim::FirstLineTop,
566                    ..wear()
567                }),
568            ),
569            extent,
570            40.0,
571            1.0,
572        );
573        // 10 of leading, 5 above and 5 below; only the top is removed.
574        assert_eq!(top_only.height, 35.0);
575        assert_eq!(top_only.baseline, 20.0);
576    }
577
578    #[test]
579    fn font_padding_is_only_spent_when_a_style_asks_for_it() {
580        let extent = FontExtent::new(20.0, 10.0, 4.0);
581        let without = line_box(&styled(30.0, Some(wear())), extent, 30.0, 1.0);
582        assert_eq!(without.height, 30.0);
583        assert_eq!(without.baseline, 20.0);
584
585        let padded = TextStyle {
586            paragraph_style: ParagraphStyle {
587                line_height: TextUnit::Sp(30.0),
588                line_height_style: Some(wear()),
589                platform_style: Some(PlatformParagraphStyle {
590                    include_font_padding: Some(true),
591                    shaping: None,
592                }),
593                ..ParagraphStyle::default()
594            },
595            ..TextStyle::default()
596        };
597        let with = line_box(&padded, extent, 30.0, 1.0);
598        assert_eq!(with.height, 34.0, "the line gap widens the font's demand");
599        assert_eq!(with.baseline, 22.0, "and half of it sits above the ascent");
600    }
601
602    #[test]
603    fn a_nonsense_line_height_falls_back_to_the_font_rather_than_producing_nan() {
604        let extent = FontExtent::new(20.0, 10.0, 0.0);
605        let box_ = line_box(&styled(30.0, Some(wear())), extent, f32::NAN, 1.0);
606        assert_eq!(box_.height, 30.0);
607        assert!(box_.baseline.is_finite());
608    }
609}