Skip to main content

alpha2_layout/
text.rs

1//! Text in a text style: its request to the text host, its identity, and
2//! where it lies on a picture.
3
4use alpha2_media::EmojiPolicy;
5use alpha2_model::{Rgba, TextAlign, TextStyle};
6use alpha2_text::{TextRasterError, TextRasterRequest, TextRasterizer};
7
8use crate::{Baselines, CanvasRect, PictureSize};
9
10/// The largest font, outline, and padding the text host draws, in pixels.
11pub const MAX_FONT_PIXELS: f32 = 2_048.0;
12/// See [`MAX_FONT_PIXELS`].
13pub const MAX_OUTLINE_PIXELS: f32 = 64.0;
14/// See [`MAX_FONT_PIXELS`].
15pub const MAX_PADDING_PIXELS: f32 = 256.0;
16
17/// Where a text lies, as fractions of the canvas, `(0, 0)` top-left to
18/// `(1, 1)` bottom-right, as its style's position is: on the picture a text
19/// generator draws, before its clip's transform and effects, and on the
20/// canvas for a caption. [`measure_text`] measures it.
21#[derive(Copy, Clone, Debug, PartialEq)]
22pub struct TextBounds {
23    /// The box the style's anchor and position place: the wrapping width
24    /// wide, every line's em box tall, and the whole pixels of outline and
25    /// background padding around that.
26    pub layout: CanvasRect,
27    /// The lines' advances: from the start of the line that starts farthest
28    /// left to the end of the one that ends farthest right, as tall as the
29    /// layout, with the same decorations around it. It moves only as the
30    /// advances do, not as glyphs with more or less ink are typed.
31    pub content: CanvasRect,
32    /// What the text draws: its glyphs, their outline, and the background's
33    /// boxes, as [`alpha2_text::TextMeasure::ink`] bounds them at the measured
34    /// size. Every pixel the text gives any alpha there meets it, give or
35    /// take the pixel sampling the texture spreads it by. `None` when the text
36    /// draws nothing.
37    pub ink: Option<CanvasRect>,
38    /// Number of laid-out lines.
39    pub line_count: u32,
40    /// The first and last lines' alphabetic baselines.
41    pub baselines: Baselines,
42}
43
44/// A layout text's raster request as a text store key: FNV-1a over a domain
45/// byte and every field of the request. Never zero.
46#[must_use]
47pub fn layout_text_key(request: &TextRasterRequest) -> u64 {
48    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
49    let mut feed = |bytes: &[u8]| {
50        for byte in bytes {
51            hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
52        }
53    };
54    // Layout texts take keys apart from captions' and text generators'.
55    feed(&[0x4c]);
56    feed_request(&mut feed, request);
57    hash.max(1)
58}
59
60/// What a request is shaped from, as a shaped-text cache key: equal keys
61/// shape alike with the same fonts.
62#[must_use]
63pub fn shaped_key(request: &TextRasterRequest) -> u64 {
64    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
65    let mut feed = |bytes: &[u8]| {
66        for byte in bytes {
67            hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
68        }
69    };
70    feed(request.text.as_bytes());
71    feed(&[0xff]);
72    feed(
73        request
74            .font_family
75            .as_deref()
76            .unwrap_or_default()
77            .as_bytes(),
78    );
79    feed(&[0xff, u8::from(request.font_family.is_some())]);
80    feed(&request.font_size.to_bits().to_le_bytes());
81    feed(&request.line_height.to_bits().to_le_bytes());
82    feed(&request.letter_spacing.to_bits().to_le_bytes());
83    feed(&request.font_weight.to_le_bytes());
84    hash
85}
86
87fn feed_request(feed: &mut impl FnMut(&[u8]), request: &TextRasterRequest) {
88    feed(request.text.as_bytes());
89    feed(&[0xff]);
90    feed(
91        request
92            .font_family
93            .as_deref()
94            .unwrap_or_default()
95            .as_bytes(),
96    );
97    feed(&[0xff, u8::from(request.font_family.is_some())]);
98    for value in [
99        request.font_size,
100        request.max_width,
101        request.line_height,
102        request.letter_spacing,
103    ] {
104        feed(&value.to_bits().to_le_bytes());
105    }
106    feed(&request.font_weight.to_le_bytes());
107    feed(&request.color);
108    feed(&[match request.align {
109        alpha2_media::TextAlign::Start => 0,
110        alpha2_media::TextAlign::Center => 1,
111        alpha2_media::TextAlign::End => 2,
112    }]);
113    match request.outline {
114        Some(outline) => {
115            feed(&[1]);
116            feed(&outline.color);
117            feed(&outline.width.to_bits().to_le_bytes());
118        }
119        None => feed(&[0]),
120    }
121}
122
123/// A text's identity at a render size: the text, its style, and the size,
124/// hashed (FNV-1a). Never zero.
125#[must_use]
126pub fn text_key(text: &str, style: &TextStyle, size: PictureSize) -> u64 {
127    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
128    let mut feed = |bytes: &[u8]| {
129        for byte in bytes {
130            hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
131        }
132    };
133    feed(text.as_bytes());
134    feed(&[0xff]);
135    // Family names cannot hold 0xff (they are UTF-8), so the text and family stay apart.
136    feed(style.font_family.as_deref().unwrap_or_default().as_bytes());
137    feed(&[0xff, u8::from(style.font_family.is_some())]);
138    for value in [
139        style.font_size,
140        style.line_height,
141        style.letter_spacing,
142        style.max_width,
143        style.position.x,
144        style.position.y,
145        style.anchor.x,
146        style.anchor.y,
147    ] {
148        feed(&value.to_bits().to_le_bytes());
149    }
150    feed(&style.font_weight.to_le_bytes());
151    feed(&rgba8(style.color));
152    feed(&[match style.align {
153        TextAlign::Start => 0,
154        TextAlign::Center => 1,
155        TextAlign::End => 2,
156    }]);
157    match style.outline {
158        Some(outline) => {
159            feed(&[1]);
160            feed(&rgba8(outline.color));
161            feed(&outline.width.to_bits().to_le_bytes());
162        }
163        None => feed(&[0]),
164    }
165    match style.background {
166        Some(background) => {
167            feed(&[1]);
168            feed(&rgba8(background.color));
169            feed(&background.padding.to_bits().to_le_bytes());
170        }
171        None => feed(&[0]),
172    }
173    feed(&size.width().to_le_bytes());
174    feed(&size.height().to_le_bytes());
175    hash.max(1)
176}
177
178/// Where `text` in `style` lies on a canvas of `size` ([`TextBounds`]), laid
179/// out with `rasterizer`'s fonts as the engine's text store lays it out and
180/// placed as a frame places it, without drawing it. Measured at the size a
181/// frame is drawn at, it agrees with that frame to the pixel; text laid out
182/// at another size can break its lines elsewhere.
183///
184/// # Errors
185/// Returns the text host's refusal: no fonts, or a style out of range.
186#[allow(clippy::cast_precision_loss)]
187pub fn measure_text(
188    rasterizer: &TextRasterizer,
189    text: &str,
190    style: &TextStyle,
191    size: PictureSize,
192) -> Result<TextBounds, TextRasterError> {
193    let request = text_request(text, style, size, text_key(text, style, size));
194    let measure = rasterizer.measure(&request)?;
195    let (width, height) = (size.width() as f32, size.height() as f32);
196    let margin = measure.decoration_margin as f32;
197    let (box_width, box_height) = (measure.box_width as f32, measure.box_height as f32);
198    // The layout box's top-left corner on the canvas: the anchor of the box with its decorations
199    // lies at the position, as `text_placement` places the texture.
200    let origin = [
201        style.position.x * width - style.anchor.x * (box_width + 2.0 * margin) + margin,
202        style.position.y * height - style.anchor.y * (box_height + 2.0 * margin) + margin,
203    ];
204    let rect = |[left, top, right, bottom]: [f32; 4]| CanvasRect {
205        left: (origin[0] + left) / width,
206        top: (origin[1] + top) / height,
207        right: (origin[0] + right) / width,
208        bottom: (origin[1] + bottom) / height,
209    };
210    Ok(TextBounds {
211        layout: rect([-margin, -margin, box_width + margin, box_height + margin]),
212        content: rect(measure.content),
213        ink: measure.ink.map(rect),
214        line_count: measure.line_count,
215        baselines: Baselines {
216            first: (origin[1] + measure.first_baseline) / height,
217            last: (origin[1] + measure.last_baseline) / height,
218        },
219    })
220}
221
222/// A straight, sRGB-encoded color as bytes.
223#[must_use]
224#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
225pub fn rgba8(color: Rgba) -> [u8; 4] {
226    [color.red, color.green, color.blue, color.alpha]
227        .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8)
228}
229
230/// The text host request for `text` in `style` on a picture of `size`, its
231/// content version `key`: sizes scaled from canvas fractions to pixels,
232/// within what the text host draws.
233#[must_use]
234#[allow(clippy::cast_precision_loss)]
235pub fn text_request(
236    text: &str,
237    style: &TextStyle,
238    size: PictureSize,
239    key: u64,
240) -> TextRasterRequest {
241    let font_size = (style.font_size * size.unit()).clamp(1.0, MAX_FONT_PIXELS);
242    TextRasterRequest {
243        text: text.to_owned(),
244        font_size,
245        max_width: (style.max_width * size.width() as f32).max(1.0),
246        line_height: style.line_height,
247        align: match style.align {
248            TextAlign::Start => alpha2_media::TextAlign::Start,
249            TextAlign::Center => alpha2_media::TextAlign::Center,
250            TextAlign::End => alpha2_media::TextAlign::End,
251        },
252        color: rgba8(style.color),
253        emoji_policy: EmojiPolicy::Color,
254        content_version: key,
255        outline: style.outline.map(|outline| alpha2_text::TextOutline {
256            color: rgba8(outline.color),
257            width: (outline.width * font_size).clamp(f32::MIN_POSITIVE, MAX_OUTLINE_PIXELS),
258        }),
259        background: style
260            .background
261            .map(|background| alpha2_text::TextBackground {
262                color: rgba8(background.color),
263                padding: (background.padding * font_size).clamp(0.0, MAX_PADDING_PIXELS),
264            }),
265        font_family: style.font_family.clone(),
266        letter_spacing: style.letter_spacing * font_size,
267        font_weight: style.font_weight,
268    }
269}
270
271#[cfg(test)]
272#[path = "text_tests.rs"]
273mod tests;