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}