Skip to main content

cranpose_ui/text/
line_box.rs

1//! Where a line of text sits inside the height it was given, and what a
2//! paragraph gives back at its edges.
3//!
4//! Jetpack Compose lays text out with AOSP's `StaticLayout` and its
5//! `LineHeightStyle` span, and so does [`line_box`]:
6//!
7//! - the font's ascent and descent are **whole pixels**, rounded the way
8//!   `Paint.getFontMetricsInt()` rounds them, and the line is built from that
9//!   pair rather than from the float metrics;
10//! - the line advance is a **whole pixel**, `ceil`ed, not a float;
11//! - the leading, the line height past the font's own ascent + descent, is
12//!   placed by the style's [`LineHeightAlignment`], with the odd pixel of a
13//!   centred split below the baseline;
14//! - [`LineHeightTrim`] gives the leading back **only at the paragraph's edges**:
15//!   above its first line and below its last. Every line keeps the full advance
16//!   between baselines, so a paragraph of `n` lines is
17//!   [`LineBox::block_height`] tall and its first baseline sits at
18//!   [`LineBox::first_baseline`].
19//!
20//! A style that names no [`LineHeightStyle`] gets Compose's default, which is
21//! [`LineHeightStyle::default`]: proportional leading, both edges trimmed, the
22//! requested height fixed. A style that asks for font padding instead gets the
23//! rule Compose keeps for padded text: proportional leading, nothing trimmed. A
24//! style that asks for no line height gets the font's own ascent + descent, as
25//! Compose's does, so a single line of it is exactly as tall as its font.
26
27use crate::text::style::{
28    LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim, TextStyle,
29};
30
31/// A resolved line box: how far apart a paragraph's baselines are, where the
32/// baseline sits in each line, and what the paragraph's first and last lines
33/// give back at its edges. All measured down from the top of a line.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct LineBox {
36    /// Baseline-to-baseline advance: the height of every line before the
37    /// paragraph's edges are trimmed.
38    pub height: f32,
39    /// Distance from the top of a line down to its baseline.
40    pub baseline: f32,
41    /// What the first line gives back above its glyphs.
42    pub trim_top: f32,
43    /// What the last line gives back below its glyphs.
44    pub trim_bottom: f32,
45}
46
47impl LineBox {
48    /// A box with nothing trimmed: `height` apart, baseline `baseline` down.
49    pub fn untrimmed(height: f32, baseline: f32) -> Self {
50        Self {
51            height,
52            baseline,
53            trim_top: 0.0,
54            trim_bottom: 0.0,
55        }
56    }
57
58    /// The height of a paragraph of `lines` lines: every line's advance, less
59    /// what the first line's top and the last line's bottom give back.
60    pub fn block_height(self, lines: usize) -> f32 {
61        (self.height * lines.max(1) as f32 - self.trim_top - self.trim_bottom).max(1.0)
62    }
63
64    /// The first line's baseline, measured down from the paragraph's top.
65    pub fn first_baseline(self) -> f32 {
66        self.baseline - self.trim_top
67    }
68
69    /// Where line `index` starts, measured down from the paragraph's top.
70    pub fn line_top(self, index: usize) -> f32 {
71        index as f32 * self.height - self.trim_top
72    }
73}
74
75/// The font's own vertical extent, in the same unit as the line height.
76///
77/// `ascent` and `descent` are both **positive distances** from the baseline,
78/// which is the sign convention AOSP states its rule in and the opposite of the
79/// one `ab_glyph` reports `descent` in.
80#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct FontExtent {
82    pub ascent: f32,
83    pub descent: f32,
84    /// `hhea.lineGap`. Only read when a style asks for font padding.
85    pub line_gap: f32,
86}
87
88impl FontExtent {
89    pub fn new(ascent: f32, descent: f32, line_gap: f32) -> Self {
90        Self {
91            ascent,
92            descent,
93            line_gap,
94        }
95    }
96
97    /// Ascent plus descent — the height the font needs with no leading at all.
98    pub fn natural(self) -> f32 {
99        self.ascent + self.descent
100    }
101}
102
103/// The line box a style asks for, given the font's extent and the line height
104/// already resolved from the style's own units.
105///
106/// `asked` is the line height in the same unit as the extent; a style that
107/// asks for no line height is laid out at the font's own extent whatever
108/// `asked` says. `grid` is how many device pixels there are to one of those
109/// units, and it is what every rounding in the AOSP rule is done against —
110/// pass `1.0` when the values are already device pixels, or the density when
111/// they are layout points. Getting it wrong does not shift a baseline by a
112/// fraction; it quantises the whole line box to the wrong step.
113pub fn line_box(style: &TextStyle, extent: FontExtent, asked: f32, grid: f32) -> LineBox {
114    let grid = if grid.is_finite() && grid > 0.0 {
115        grid
116    } else {
117        1.0
118    };
119    let asked = if style.paragraph_style.line_height.is_unspecified() {
120        f32::NAN
121    } else {
122        asked
123    };
124    let padding = font_padding(style, extent);
125    let line_height_style = match style.paragraph_style.line_height_style {
126        Some(line_height_style) => line_height_style,
127        None if padding > 0.0 || font_padding_asked(style) => LineHeightStyle {
128            alignment: LineHeightAlignment::Proportional,
129            trim: LineHeightTrim::None,
130            mode: LineHeightMode::Fixed,
131        },
132        None => LineHeightStyle::default(),
133    };
134    aosp_line_box(line_height_style, extent, asked, padding, grid)
135}
136
137fn font_padding_asked(style: &TextStyle) -> bool {
138    style
139        .paragraph_style
140        .platform_style
141        .and_then(|platform| platform.include_font_padding)
142        .unwrap_or(false)
143}
144
145fn font_padding(style: &TextStyle, extent: FontExtent) -> f32 {
146    if font_padding_asked(style) && extent.line_gap.is_finite() && extent.line_gap > 0.0 {
147        extent.line_gap
148    } else {
149        0.0
150    }
151}
152
153fn aosp_line_box(
154    style: LineHeightStyle,
155    extent: FontExtent,
156    asked: f32,
157    padding: f32,
158    grid: f32,
159) -> LineBox {
160    let up = |value: f32| (value * grid).ceil() / grid;
161    let down = |value: f32| (value * grid).floor() / grid;
162    let round = |value: f32| ((value * grid) + 0.5).floor() / grid;
163    let ascent = -round(-extent.ascent.max(0.0));
164    let descent = round(extent.descent.max(0.0));
165    let above_padding = down(padding * 0.5);
166    let below_padding = padding - above_padding;
167    let natural = up(ascent + descent + padding);
168    let asked = if asked.is_finite() {
169        up(asked)
170    } else {
171        natural
172    };
173
174    let height = match style.mode {
175        LineHeightMode::Fixed => asked.max(1.0),
176        LineHeightMode::Minimum => asked.max(natural).max(1.0),
177        LineHeightMode::Tight => natural.max(1.0),
178    };
179
180    let leading = height - (ascent + descent + padding);
181    let (mut above, mut below) = match style.alignment {
182        LineHeightAlignment::Top => (0.0, leading),
183        LineHeightAlignment::Bottom => (leading, 0.0),
184        LineHeightAlignment::Center => {
185            let below = up(leading * 0.5);
186            (leading - below, below)
187        }
188        LineHeightAlignment::Proportional => {
189            let total = ascent + descent;
190            if total > 0.0 {
191                let above = leading * (ascent / total);
192                (above, leading - above)
193            } else {
194                (leading * 0.5, leading * 0.5)
195            }
196        }
197    };
198    above += above_padding;
199    below += below_padding;
200
201    let (trim_above, trim_below) = match style.trim {
202        LineHeightTrim::None => (false, false),
203        LineHeightTrim::FirstLineTop => (true, false),
204        LineHeightTrim::LastLineBottom => (false, true),
205        LineHeightTrim::Both => (true, true),
206    };
207
208    LineBox {
209        height: height.max(1.0),
210        baseline: above + ascent,
211        trim_top: if trim_above { above } else { 0.0 },
212        trim_bottom: if trim_below { below } else { 0.0 },
213    }
214}
215
216#[cfg(test)]
217#[path = "tests/line_box_tests.rs"]
218mod tests;