cranpose_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//! split by the style's [`LineHeightAlignment`] into **whole pixels**: the
13//! share below the baseline is `ceil`ed, so the odd pixel of a centred split
14//! sits below it;
15//! - a [`LineHeightMode::Tight`] line shorter than its font adds no leading back
16//! at a trimmed edge, and takes the font's whole extent at an untrimmed one;
17//! - [`LineHeightTrim`] gives the leading back **only at the paragraph's edges**:
18//! above its first line and below its last. Every line keeps the full advance
19//! between baselines, so a paragraph of `n` lines is
20//! [`LineBox::block_height`] tall and its first baseline sits at
21//! [`LineBox::first_baseline`].
22//!
23//! A style that names no [`LineHeightStyle`] gets Compose's default, which is
24//! [`LineHeightStyle::default`]: proportional leading, both edges trimmed, the
25//! requested height fixed. A style that asks for font padding instead gets the
26//! rule Compose keeps for padded text: proportional leading, nothing trimmed. A
27//! style that asks for no line height gets the font's own ascent + descent, as
28//! Compose's does, so a single line of it is exactly as tall as its font.
29
30use std::cell::Cell;
31
32use cranpose_ui_graphics::whole;
33
34use crate::style::{
35 LineHeightAlignment, LineHeightMode, LineHeightStyle, LineHeightTrim, ParagraphStyle, TextStyle,
36};
37
38/// A resolved line box: how far apart a paragraph's baselines are, where the
39/// baseline sits in each line, and what the paragraph's first and last lines
40/// give back at its edges. All measured down from the top of a line.
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub struct LineBox {
43 /// Baseline-to-baseline advance: the height of every line before the
44 /// paragraph's edges are trimmed.
45 pub height: f32,
46 /// Distance from the top of a line down to its baseline.
47 pub baseline: f32,
48 /// What the first line gives back above its glyphs.
49 pub trim_top: f32,
50 /// What the last line gives back below its glyphs.
51 pub trim_bottom: f32,
52}
53
54impl LineBox {
55 /// A box with nothing trimmed: `height` apart, baseline `baseline` down.
56 pub fn untrimmed(height: f32, baseline: f32) -> Self {
57 Self {
58 height,
59 baseline,
60 trim_top: 0.0,
61 trim_bottom: 0.0,
62 }
63 }
64
65 /// The height of a paragraph of `lines` lines: every line's advance, less
66 /// what the first line's top and the last line's bottom give back.
67 pub fn block_height(self, lines: usize) -> f32 {
68 (self.height * lines.max(1) as f32 - self.trim_top - self.trim_bottom).max(1.0)
69 }
70
71 /// The first line's baseline, measured down from the paragraph's top.
72 pub fn first_baseline(self) -> f32 {
73 self.baseline - self.trim_top
74 }
75
76 /// Where line `index` starts, measured down from the paragraph's top.
77 pub fn line_top(self, index: usize) -> f32 {
78 index as f32 * self.height - self.trim_top
79 }
80}
81
82/// The font's own vertical extent, in the same unit as the line height.
83///
84/// `ascent` and `descent` are both **positive distances** from the baseline,
85/// which is the sign convention AOSP states its rule in and the opposite of the
86/// one `ab_glyph` reports `descent` in.
87#[derive(Clone, Copy, Debug, PartialEq)]
88pub struct FontExtent {
89 /// Distance from the baseline up to the top of the font, as a positive number.
90 pub ascent: f32,
91 /// Distance from the baseline down to the bottom of the font, as a positive number.
92 pub descent: f32,
93 /// `hhea.lineGap`. Only read when a style asks for font padding.
94 pub line_gap: f32,
95}
96
97impl FontExtent {
98 /// Creates an extent from `ascent`, `descent` and `line_gap`, all in the same unit.
99 pub fn new(ascent: f32, descent: f32, line_gap: f32) -> Self {
100 Self {
101 ascent,
102 descent,
103 line_gap,
104 }
105 }
106
107 /// Ascent plus descent — the height the font needs with no leading at all.
108 pub fn natural(self) -> f32 {
109 self.ascent + self.descent
110 }
111}
112
113/// The line box a style asks for, given the font's extent and the line height
114/// already resolved from the style's own units.
115///
116/// `asked` is the line height in the same unit as the extent; a style that
117/// asks for no line height is laid out at the font's own extent whatever
118/// `asked` says. `grid` is how many device pixels there are to one of those
119/// units, and it is what every rounding in the AOSP rule is done against —
120/// pass `1.0` when the values are already device pixels, or the density when
121/// they are layout points. Getting it wrong does not shift a baseline by a
122/// fraction; it quantises the whole line box to the wrong step.
123pub fn line_box(style: &TextStyle, extent: FontExtent, asked: f32, grid: f32) -> LineBox {
124 paragraph_line_box(&style.paragraph_style, extent, asked, grid)
125}
126
127/// [`line_box`] for the paragraph settings alone.
128pub fn paragraph_line_box(
129 style: &ParagraphStyle,
130 extent: FontExtent,
131 asked: f32,
132 grid: f32,
133) -> LineBox {
134 let inputs = LineBoxInputs::of(style, extent, asked, pixel_grid(grid));
135 inputs.pixels().in_units(inputs.grid)
136}
137
138// A line box in whole device pixels, as AOSP lays a line out: a paragraph
139// stacked from these stays on the pixel grid however many lines it has.
140#[derive(Clone, Copy, Debug, PartialEq)]
141pub(crate) struct LinePixels {
142 pub(crate) height: f32,
143 pub(crate) baseline: f32,
144 pub(crate) trim_top: f32,
145 pub(crate) trim_bottom: f32,
146}
147
148impl LinePixels {
149 fn in_units(self, grid: f32) -> LineBox {
150 LineBox {
151 height: self.height / grid,
152 baseline: self.baseline / grid,
153 trim_top: self.trim_top / grid,
154 trim_bottom: self.trim_bottom / grid,
155 }
156 }
157}
158
159// Device pixels per layout pixel, `1.0` for a grid that names none.
160fn pixel_grid(grid: f32) -> f32 {
161 if grid.is_finite() && grid > 0.0 {
162 grid
163 } else {
164 1.0
165 }
166}
167
168// What a line box follows from: the line height rules a paragraph's style
169// takes, the font's extent, the height asked for, the font padding and the
170// pixel grid.
171#[derive(Clone, Copy)]
172pub(crate) struct LineBoxInputs {
173 style: LineHeightStyle,
174 extent: FontExtent,
175 asked: f32,
176 padding: f32,
177 grid: f32,
178}
179
180impl LineBoxInputs {
181 // `grid` is finite and positive.
182 pub(crate) fn of(style: &ParagraphStyle, extent: FontExtent, asked: f32, grid: f32) -> Self {
183 let asked = if style.line_height.is_unspecified() {
184 f32::NAN
185 } else {
186 asked
187 };
188 let padding = font_padding(style, extent);
189 let line_height_style = match style.line_height_style {
190 Some(line_height_style) => line_height_style,
191 None if padding > 0.0 || font_padding_asked(style) => LineHeightStyle {
192 alignment: LineHeightAlignment::Proportional,
193 trim: LineHeightTrim::None,
194 mode: LineHeightMode::Fixed,
195 },
196 None => LineHeightStyle::default(),
197 };
198 Self {
199 style: line_height_style,
200 extent,
201 asked,
202 padding,
203 grid,
204 }
205 }
206
207 // Out of line: the layouter takes most line boxes from `LastLineBox`.
208 #[inline(never)]
209 fn pixels(self) -> LinePixels {
210 aosp_line_pixels(self.style, self.extent, self.asked, self.padding, self.grid)
211 }
212
213 // Whether these are `other` bit for bit, a NaN asked height equal to
214 // itself.
215 fn same(&self, other: &Self) -> bool {
216 let bits = |inputs: &Self| {
217 [
218 inputs.extent.ascent,
219 inputs.extent.descent,
220 inputs.extent.line_gap,
221 inputs.asked,
222 inputs.padding,
223 inputs.grid,
224 ]
225 .map(f32::to_bits)
226 };
227 self.style == other.style && bits(self) == bits(other)
228 }
229}
230
231// The line box last made, kept for the next line of the same font and
232// style: most lines of a paragraph, and the line of a text laid out again
233// each frame, as a number that changes, need no rounding of their own.
234#[derive(Default)]
235pub(crate) struct LastLineBox(Cell<Option<(LineBoxInputs, LinePixels)>>);
236
237impl LastLineBox {
238 pub(crate) fn of(&self, inputs: LineBoxInputs) -> LinePixels {
239 if let Some((kept, pixels)) = self.0.get()
240 && kept.same(&inputs)
241 {
242 return pixels;
243 }
244 let pixels = inputs.pixels();
245 self.0.set(Some((inputs, pixels)));
246 pixels
247 }
248}
249
250fn font_padding_asked(style: &ParagraphStyle) -> bool {
251 style
252 .platform_style
253 .and_then(|platform| platform.include_font_padding)
254 .unwrap_or(false)
255}
256
257fn font_padding(style: &ParagraphStyle, extent: FontExtent) -> f32 {
258 if font_padding_asked(style) && extent.line_gap.is_finite() && extent.line_gap > 0.0 {
259 extent.line_gap
260 } else {
261 0.0
262 }
263}
264
265// AOSP's rule, in device pixels: `Paint.getFontMetricsInt()` rounds the
266// ascent, a negative number there, and the descent; `LineHeightStyleSpan`
267// `ceil`s the line height and the leading's share below the baseline.
268fn aosp_line_pixels(
269 style: LineHeightStyle,
270 extent: FontExtent,
271 asked: f32,
272 padding: f32,
273 grid: f32,
274) -> LinePixels {
275 let ascent = -whole::floor(0.5 - extent.ascent.max(0.0) * grid);
276 let descent = whole::floor(extent.descent.max(0.0) * grid + 0.5);
277 let padding = whole::ceil(padding * grid);
278 let above_padding = whole::floor(padding * 0.5);
279 let font = ascent + descent;
280 let natural = font + padding;
281 let asked = asked * grid;
282 let asked = if asked.is_finite() {
283 whole::ceil(asked)
284 } else {
285 natural
286 };
287
288 let height = match style.mode {
289 LineHeightMode::Fixed | LineHeightMode::Tight => asked,
290 LineHeightMode::Minimum => asked.max(natural),
291 }
292 .max(1.0);
293
294 let leading = height - natural;
295 // AOSP's `topRatio`: the share of the leading above the glyphs.
296 let top_ratio = match style.alignment {
297 LineHeightAlignment::Top => 0.0,
298 LineHeightAlignment::Center => 0.5,
299 LineHeightAlignment::Bottom => 1.0,
300 LineHeightAlignment::Proportional if font > 0.0 => ascent / font,
301 LineHeightAlignment::Proportional => 0.5,
302 };
303 // A line shorter than its font gives up below the baseline the share the
304 // ratio names, not the rest: AOSP's rule, kept as it is.
305 let below_share = if leading > 0.0 {
306 1.0 - top_ratio
307 } else {
308 top_ratio
309 };
310 let below = whole::ceil(leading * below_share);
311 let above = leading - below + above_padding;
312 let below = below + padding - above_padding;
313
314 let (trim_above, trim_below) = match style.trim {
315 LineHeightTrim::None => (false, false),
316 LineHeightTrim::FirstLineTop => (true, false),
317 LineHeightTrim::LastLineBottom => (false, true),
318 LineHeightTrim::Both => (true, true),
319 };
320 // A tight line shorter than its font keeps that height at a trimmed edge
321 // and takes the font's whole extent at an untrimmed one.
322 let tight = style.mode == LineHeightMode::Tight && leading < 0.0;
323 let given_back = |trimmed: bool, share: f32| if trimmed != tight { share } else { 0.0 };
324
325 LinePixels {
326 height,
327 baseline: above + ascent,
328 trim_top: given_back(trim_above, above),
329 trim_bottom: given_back(trim_below, below),
330 }
331}
332
333#[cfg(test)]
334#[path = "tests/line_box_tests.rs"]
335mod tests;