1use crate::text::style::{
32 LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim, TextStyle,
33};
34
35#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct LineBox {
39 pub height: f32,
41 pub baseline: f32,
43}
44
45#[derive(Clone, Copy, Debug, PartialEq)]
51pub struct FontExtent {
52 pub ascent: f32,
53 pub descent: f32,
54 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 pub fn natural(self) -> f32 {
69 self.ascent + self.descent
70 }
71}
72
73pub fn line_box(style: &TextStyle, extent: FontExtent, asked: f32, grid: f32) -> LineBox {
83 let grid = if grid.is_finite() && grid > 0.0 {
84 grid
85 } else {
86 1.0
87 };
88 match style.paragraph_style.line_height_style {
89 None => unstyled_line_box(extent, asked, grid),
90 Some(line_height_style) => {
91 let padding = font_padding(style, extent);
92 aosp_line_box(line_height_style, extent, asked, padding, grid)
93 }
94 }
95}
96
97fn unstyled_line_box(extent: FontExtent, asked: f32, grid: f32) -> LineBox {
98 let natural = (extent.natural() * grid).ceil() / grid;
99 LineBox {
100 height: asked,
101 baseline: extent.ascent + (asked - natural) * 0.5,
102 }
103}
104
105fn font_padding(style: &TextStyle, extent: FontExtent) -> f32 {
106 let asked = style
107 .paragraph_style
108 .platform_style
109 .and_then(|platform| platform.include_font_padding)
110 .unwrap_or(false);
111 if asked && extent.line_gap.is_finite() && extent.line_gap > 0.0 {
112 extent.line_gap
113 } else {
114 0.0
115 }
116}
117
118fn aosp_line_box(
119 style: LineHeightStyle,
120 extent: FontExtent,
121 asked: f32,
122 padding: f32,
123 grid: f32,
124) -> LineBox {
125 let up = |value: f32| (value * grid).ceil() / grid;
126 let down = |value: f32| (value * grid).floor() / grid;
127 let round = |value: f32| ((value * grid) + 0.5).floor() / grid;
128 let ascent = -round(-extent.ascent.max(0.0));
129 let descent = round(extent.descent.max(0.0));
130 let above_padding = down(padding * 0.5);
131 let below_padding = padding - above_padding;
132 let natural = up(ascent + descent + padding);
133 let asked = if asked.is_finite() {
134 up(asked)
135 } else {
136 natural
137 };
138
139 let height = match style.mode {
140 LineHeightMode::Fixed => asked.max(1.0),
141 LineHeightMode::Minimum => asked.max(natural).max(1.0),
142 LineHeightMode::Tight => natural.max(1.0),
143 };
144
145 let leading = height - (ascent + descent + padding);
146 let (mut above, mut below) = match style.alignment {
147 LineHeightAlignment::Top => (0.0, leading),
148 LineHeightAlignment::Bottom => (leading, 0.0),
149 LineHeightAlignment::Center => {
150 let below = up(leading * 0.5);
151 (leading - below, below)
152 }
153 LineHeightAlignment::Proportional => {
154 let total = ascent + descent;
155 if total > 0.0 {
156 let above = leading * (ascent / total);
157 (above, leading - above)
158 } else {
159 (leading * 0.5, leading * 0.5)
160 }
161 }
162 };
163 above += above_padding;
164 below += below_padding;
165
166 let (trim_above, trim_below) = match style.trim {
167 LineHeightTrim::None => (false, false),
168 LineHeightTrim::FirstLineTop => (true, false),
169 LineHeightTrim::LastLineBottom => (false, true),
170 LineHeightTrim::Both => (true, true),
171 };
172 let mut height = height;
173 if trim_above {
174 height -= above;
175 above = 0.0;
176 }
177 if trim_below {
178 height -= below;
179 }
180
181 LineBox {
182 height: height.max(1.0),
183 baseline: above + ascent,
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::text::{
191 TextUnit,
192 style::{ParagraphStyle, PlatformParagraphStyle},
193 };
194
195 fn roboto_16sp() -> FontExtent {
196 FontExtent::new(32.0 * 1900.0 / 2048.0, 32.0 * 500.0 / 2048.0, 0.0)
197 }
198
199 fn styled(line_height_px: f32, line_height_style: Option<LineHeightStyle>) -> TextStyle {
200 TextStyle {
201 paragraph_style: ParagraphStyle {
202 line_height: TextUnit::Sp(line_height_px),
203 line_height_style,
204 ..ParagraphStyle::default()
205 },
206 ..TextStyle::default()
207 }
208 }
209
210 fn wear() -> LineHeightStyle {
211 LineHeightStyle {
212 alignment: LineHeightAlignment::Center,
213 trim: LineHeightTrim::None,
214 mode: LineHeightMode::Minimum,
215 }
216 }
217
218 #[test]
219 fn a_style_that_asks_for_nothing_gets_exactly_what_it_got_before() {
220 let extent = roboto_16sp();
221 let plain = line_box(&styled(36.0, None), extent, 36.0, 1.0);
222 assert_eq!(plain.height, 36.0);
223 let natural = extent.natural().ceil();
224 assert_eq!(plain.baseline, extent.ascent + (36.0 - natural) * 0.5);
225 }
226
227 #[test]
228 fn an_unstyled_box_is_the_same_box_measured_in_points_or_in_pixels() {
229 let density = 2.0;
230 for glyph_px in [24.0f32, 26.0, 28.125, 30.0, 32.0, 37.2, 38.72] {
231 let ascent_px = glyph_px * 1900.0 / 2048.0;
232 let descent_px = glyph_px * 500.0 / 2048.0;
233 let asked_px = (glyph_px * 4.0 / 3.0).round();
234
235 let in_pixels = line_box(
236 &styled(asked_px, None),
237 FontExtent::new(ascent_px, descent_px, 0.0),
238 asked_px,
239 1.0,
240 );
241 let in_points = line_box(
242 &styled(asked_px / density, None),
243 FontExtent::new(ascent_px / density, descent_px / density, 0.0),
244 asked_px / density,
245 density,
246 );
247
248 assert!(
249 (in_points.height * density - in_pixels.height).abs() < 1e-4,
250 "{glyph_px}px: height {} in points against {} in pixels",
251 in_points.height * density,
252 in_pixels.height
253 );
254 assert!(
255 (in_points.baseline * density - in_pixels.baseline).abs() < 1e-4,
256 "{glyph_px}px: baseline {} in points against {} in pixels",
257 in_points.baseline * density,
258 in_pixels.baseline
259 );
260 }
261 }
262
263 #[test]
264 fn title_medium_overflows_its_own_line_height_and_the_font_wins() {
265 let box_ = line_box(&styled(36.0, Some(wear())), roboto_16sp(), 36.0, 1.0);
266 assert_eq!(box_.height, 38.0);
267 }
268
269 #[test]
270 fn a_line_height_the_font_fits_inside_is_honoured_as_asked() {
271 let extent = FontExtent::new(30.0 * 1900.0 / 2048.0, 30.0 * 500.0 / 2048.0, 0.0);
272 let box_ = line_box(&styled(36.0, Some(wear())), extent, 36.0, 1.0);
273 assert_eq!(box_.height, 36.0);
274 assert_eq!(box_.baseline, 28.0);
275 }
276
277 #[test]
278 fn the_font_metrics_are_rounded_the_way_the_platform_rounds_them() {
279 for (size_px, ascent_px, descent_px) in [
280 (24.0_f32, 22.0_f32, 6.0_f32),
281 (26.0, 24.0, 6.0),
282 (30.0, 28.0, 7.0),
283 (32.0, 30.0, 8.0),
284 (37.2, 35.0, 9.0),
285 (38.72, 36.0, 9.0),
286 (43.76, 41.0, 11.0),
287 (38.0, 35.0, 9.0),
288 ] {
289 let extent = FontExtent::new(size_px * 1900.0 / 2048.0, size_px * 500.0 / 2048.0, 0.0);
290 let tight = line_box(
291 &styled(
292 0.0,
293 Some(LineHeightStyle {
294 mode: LineHeightMode::Tight,
295 ..wear()
296 }),
297 ),
298 extent,
299 0.0,
300 1.0,
301 );
302 assert_eq!(
303 (tight.baseline, tight.height - tight.baseline),
304 (ascent_px, descent_px),
305 "{size_px}px",
306 );
307 }
308 }
309
310 #[test]
311 fn the_wear_type_scale_lays_out_in_the_boxes_the_platform_gives_it() {
312 for (name, size_px, line_height_px, height, baseline) in [
313 ("titleMedium 1.0", 32.0_f32, 36.0_f32, 38.0_f32, 30.0_f32),
314 ("labelMedium 1.0", 30.0, 36.0, 36.0, 28.0),
315 ("labelSmall 1.0", 26.0, 32.0, 32.0, 25.0),
316 ("titleMedium 1.24", 38.72, 41.76, 45.0, 36.0),
317 ("labelMedium 1.24", 37.2, 41.76, 44.0, 35.0),
318 ("labelSmall 1.24", 32.72, 38.72, 39.0, 30.0),
319 ] {
320 let extent = FontExtent::new(size_px * 1900.0 / 2048.0, size_px * 500.0 / 2048.0, 0.0);
321 let resolved = line_box(
322 &styled(line_height_px, Some(wear())),
323 extent,
324 line_height_px,
325 1.0,
326 );
327 assert_eq!(
328 (resolved.height, resolved.baseline),
329 (height, baseline),
330 "{name}",
331 );
332 }
333 }
334
335 #[test]
336 fn the_odd_unit_of_leading_goes_below_the_baseline_not_above() {
337 let extent = FontExtent::new(20.0, 10.0, 0.0);
338 let box_ = line_box(&styled(33.0, Some(wear())), extent, 33.0, 1.0);
339 assert_eq!(box_.height, 33.0);
340 assert_eq!(box_.baseline, 21.0);
341 assert_ne!(box_.baseline, 20.0 + 1.5);
342 }
343
344 #[test]
345 fn a_line_height_is_a_whole_number_of_pixels() {
346 let extent = FontExtent::new(20.0, 10.0, 0.0);
347 let box_ = line_box(&styled(33.4, Some(wear())), extent, 33.4, 1.0);
348 assert_eq!(box_.height, 34.0);
349 }
350
351 #[test]
352 fn top_alignment_puts_the_glyphs_at_the_top_and_bottom_at_the_bottom() {
353 let extent = FontExtent::new(20.0, 10.0, 0.0);
354 let top = line_box(
355 &styled(
356 40.0,
357 Some(LineHeightStyle {
358 alignment: LineHeightAlignment::Top,
359 ..wear()
360 }),
361 ),
362 extent,
363 40.0,
364 1.0,
365 );
366 assert_eq!(top.baseline, 20.0);
367 let bottom = line_box(
368 &styled(
369 40.0,
370 Some(LineHeightStyle {
371 alignment: LineHeightAlignment::Bottom,
372 ..wear()
373 }),
374 ),
375 extent,
376 40.0,
377 1.0,
378 );
379 assert_eq!(bottom.baseline, 30.0);
380 assert_eq!(bottom.height - bottom.baseline, extent.descent);
381 }
382
383 #[test]
384 fn proportional_alignment_splits_the_leading_the_way_the_font_is_split() {
385 let extent = FontExtent::new(20.0, 10.0, 0.0);
386 let style = LineHeightStyle {
387 alignment: LineHeightAlignment::Proportional,
388 ..wear()
389 };
390 let box_ = line_box(&styled(60.0, Some(style)), extent, 60.0, 1.0);
391 assert_eq!(box_.baseline, 40.0);
392 }
393
394 #[test]
395 fn a_fixed_line_height_lets_the_font_overflow_and_tight_ignores_the_ask() {
396 let extent = roboto_16sp();
397 let fixed = line_box(
398 &styled(
399 36.0,
400 Some(LineHeightStyle {
401 mode: LineHeightMode::Fixed,
402 ..wear()
403 }),
404 ),
405 extent,
406 36.0,
407 1.0,
408 );
409 assert_eq!(
410 fixed.height, 36.0,
411 "the ask wins even though the font needs 38"
412 );
413 let tight = line_box(
414 &styled(
415 80.0,
416 Some(LineHeightStyle {
417 mode: LineHeightMode::Tight,
418 ..wear()
419 }),
420 ),
421 extent,
422 80.0,
423 1.0,
424 );
425 assert_eq!(tight.height, 38.0);
426 assert_eq!(tight.baseline, 30.0);
427 }
428
429 #[test]
430 fn trimming_removes_the_leading_on_the_edge_it_names() {
431 let extent = FontExtent::new(20.0, 10.0, 0.0);
432 let both = line_box(
433 &styled(
434 40.0,
435 Some(LineHeightStyle {
436 trim: LineHeightTrim::Both,
437 ..wear()
438 }),
439 ),
440 extent,
441 40.0,
442 1.0,
443 );
444 assert_eq!(both.height, 30.0);
445 assert_eq!(both.baseline, 20.0);
446
447 let top_only = line_box(
448 &styled(
449 40.0,
450 Some(LineHeightStyle {
451 trim: LineHeightTrim::FirstLineTop,
452 ..wear()
453 }),
454 ),
455 extent,
456 40.0,
457 1.0,
458 );
459 assert_eq!(top_only.height, 35.0);
460 assert_eq!(top_only.baseline, 20.0);
461 }
462
463 #[test]
464 fn font_padding_is_only_spent_when_a_style_asks_for_it() {
465 let extent = FontExtent::new(20.0, 10.0, 4.0);
466 let without = line_box(&styled(30.0, Some(wear())), extent, 30.0, 1.0);
467 assert_eq!(without.height, 30.0);
468 assert_eq!(without.baseline, 20.0);
469
470 let padded = TextStyle {
471 paragraph_style: ParagraphStyle {
472 line_height: TextUnit::Sp(30.0),
473 line_height_style: Some(wear()),
474 platform_style: Some(PlatformParagraphStyle {
475 include_font_padding: Some(true),
476 shaping: None,
477 }),
478 ..ParagraphStyle::default()
479 },
480 ..TextStyle::default()
481 };
482 let with = line_box(&padded, extent, 30.0, 1.0);
483 assert_eq!(with.height, 34.0, "the line gap widens the font's demand");
484 assert_eq!(with.baseline, 22.0, "and half of it sits above the ascent");
485 }
486
487 #[test]
488 fn a_nonsense_line_height_falls_back_to_the_font_rather_than_producing_nan() {
489 let extent = FontExtent::new(20.0, 10.0, 0.0);
490 let box_ = line_box(&styled(30.0, Some(wear())), extent, f32::NAN, 1.0);
491 assert_eq!(box_.height, 30.0);
492 assert!(box_.baseline.is_finite());
493 }
494}