Skip to main content

alpha2_layout/
text.rs

1//! Text in a text style: its text host request, its identity, and where it lies on a picture.
2
3use alpha2_media::EmojiPolicy;
4use alpha2_model::{Paint, Rgba, Spread, TextAlign, TextShadow, TextStroke, TextStyle};
5use alpha2_text::{
6    TextFill, TextGradient, TextGradientKind, TextGradientStop, TextRasterError, TextRasterRequest,
7    TextRasterizer, TextSpread,
8};
9
10use crate::{Baselines, CanvasRect, PictureSize};
11
12/// The largest font, stroke extent, and padding the text host draws, in pixels.
13pub const MAX_FONT_PIXELS: f32 = 2_048.0;
14/// The most a text's strokes reach together, in pixels. See [`MAX_FONT_PIXELS`].
15pub const MAX_STROKE_PIXELS: f32 = 64.0;
16/// See [`MAX_FONT_PIXELS`].
17pub const MAX_PADDING_PIXELS: f32 = 256.0;
18
19/// Where a text lies, as fractions of the canvas from `(0, 0)` top-left to `(1, 1)`
20/// bottom-right. For a text generator this is on its picture, before the clip's transform
21/// and effects; for a caption it is on the canvas.
22#[derive(Copy, Clone, Debug, PartialEq)]
23pub struct TextBounds {
24    /// The box the style's anchor and position place: the wrapping width wide, every line's
25    /// em box tall, plus whole pixels of outline and background padding.
26    pub layout: CanvasRect,
27    /// The span of the lines' advances, as tall as the layout and with the same decorations.
28    /// It moves only with the advances, not with glyph ink.
29    pub content: CanvasRect,
30    /// What the text draws (glyphs, outline, background boxes), as
31    /// [`alpha2_text::TextMeasure::ink`] bounds it, within the texture's sampling spread.
32    /// `None` when the text draws nothing.
33    pub ink: Option<CanvasRect>,
34    /// Number of laid-out lines.
35    pub line_count: u32,
36    /// The first and last lines' alphabetic baselines.
37    pub baselines: Baselines,
38}
39
40/// A layout text's raster request, shadows and animators hashed as a text store key
41/// (FNV-1a over a domain byte and every field). Never zero.
42#[must_use]
43pub fn layout_text_key(
44    request: &TextRasterRequest,
45    shadows: &[ShadowPixels],
46    animators: &[alpha2_eval::SampledAnimator],
47) -> 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    if !shadows.is_empty() {
58        feed(&[0xfd]);
59        feed_count(&mut feed, shadows.len());
60        for shadow in shadows {
61            for value in [shadow.offset[0], shadow.offset[1], shadow.sigma] {
62                feed(&value.to_bits().to_le_bytes());
63            }
64            for value in shadow.color {
65                feed(&value.to_bits().to_le_bytes());
66            }
67        }
68    }
69    if !animators.is_empty() {
70        let mut stable = alpha2_geometry::StableHash::new();
71        alpha2_eval::hash_sampled_animators(&mut stable, animators);
72        feed(&[0xfa]);
73        feed(&stable.finish().to_le_bytes());
74    }
75    hash.max(1)
76}
77
78/// What a request is shaped from, as a shaped-text cache key. Equal keys shape alike with
79/// the same fonts.
80#[must_use]
81pub fn shaped_key(request: &TextRasterRequest) -> u64 {
82    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
83    let mut feed = |bytes: &[u8]| {
84        for byte in bytes {
85            hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
86        }
87    };
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    feed(&request.font_size.to_bits().to_le_bytes());
99    feed(&request.line_height.to_bits().to_le_bytes());
100    feed(&request.letter_spacing.to_bits().to_le_bytes());
101    feed(&request.font_weight.to_le_bytes());
102    hash
103}
104
105fn feed_request(feed: &mut impl FnMut(&[u8]), request: &TextRasterRequest) {
106    feed(request.text.as_bytes());
107    feed(&[0xff]);
108    feed(
109        request
110            .font_family
111            .as_deref()
112            .unwrap_or_default()
113            .as_bytes(),
114    );
115    feed(&[0xff, u8::from(request.font_family.is_some())]);
116    for value in [
117        request.font_size,
118        request.max_width,
119        request.line_height,
120        request.letter_spacing,
121    ] {
122        feed(&value.to_bits().to_le_bytes());
123    }
124    feed(&request.font_weight.to_le_bytes());
125    match &request.fill {
126        TextFill::Solid(color) => feed(color),
127        TextFill::Gradient(gradient) => feed_text_gradient(feed, gradient),
128    }
129    feed(&[match request.align {
130        alpha2_media::TextAlign::Start => 0,
131        alpha2_media::TextAlign::Center => 1,
132        alpha2_media::TextAlign::End => 2,
133    }]);
134    feed_count(feed, request.strokes.len());
135    for stroke in &request.strokes {
136        feed(&stroke.color);
137        feed(&stroke.width.to_bits().to_le_bytes());
138    }
139    if request.split_background {
140        feed(&[0xfd]);
141    }
142}
143
144/// A list's length as one byte. A list of one feeds the same byte the single optional value
145/// it replaced fed, which keeps existing keys stable.
146#[allow(clippy::cast_possible_truncation)]
147fn feed_count(feed: &mut impl FnMut(&[u8]), count: usize) {
148    feed(&[count.min(255) as u8]);
149}
150
151fn feed_text_gradient(feed: &mut impl FnMut(&[u8]), gradient: &TextGradient) {
152    // A tag no color's first byte can be mistaken for: a solid fill feeds four bytes only.
153    feed(&[0xfe]);
154    let points: &[f32] = match &gradient.kind {
155        TextGradientKind::Linear { start, end } => &[0.0, start[0], start[1], end[0], end[1]],
156        TextGradientKind::Radial { center, radius } => &[1.0, center[0], center[1], *radius],
157        TextGradientKind::Conic { center, angle } => &[2.0, center[0], center[1], *angle],
158    };
159    for value in points {
160        feed(&value.to_bits().to_le_bytes());
161    }
162    feed(&[gradient.spread as u8]);
163    feed_count(feed, gradient.stops.len());
164    for stop in &gradient.stops {
165        feed(&stop.offset.to_bits().to_le_bytes());
166        for value in stop.color {
167            feed(&value.to_bits().to_le_bytes());
168        }
169    }
170}
171
172/// A paint's identity for a text key: a solid color as its bytes, a gradient by every field.
173fn feed_paint(feed: &mut impl FnMut(&[u8]), paint: &Paint) {
174    match paint {
175        Paint::Solid { color } => feed(&rgba8(*color)),
176        gradient => feed_text_gradient(feed, &text_gradient(gradient)),
177    }
178}
179
180/// A text style's strokes and shadows for a text key. One stroke feeds what an outline fed,
181/// and no shadows feed nothing.
182fn feed_look(feed: &mut impl FnMut(&[u8]), strokes: &[TextStroke], shadows: &[TextShadow]) {
183    feed_count(feed, strokes.len());
184    for stroke in strokes {
185        feed(&rgba8(stroke.color));
186        feed(&stroke.width.to_bits().to_le_bytes());
187    }
188    if !shadows.is_empty() {
189        feed(&[0xfd]);
190        feed_count(feed, shadows.len());
191        for shadow in shadows {
192            feed(&rgba8(shadow.color));
193            for value in [shadow.offset.x, shadow.offset.y, shadow.blur] {
194                feed(&value.to_bits().to_le_bytes());
195            }
196        }
197    }
198}
199
200/// A text's identity at a render size: the text, its style and the size, hashed with FNV-1a.
201/// Never zero.
202#[must_use]
203pub fn text_key(text: &str, style: &TextStyle, size: PictureSize) -> u64 {
204    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
205    let mut feed = |bytes: &[u8]| {
206        for byte in bytes {
207            hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
208        }
209    };
210    feed(text.as_bytes());
211    feed(&[0xff]);
212    // Family names cannot hold 0xff (they are UTF-8), so the text and family stay apart.
213    feed(style.font_family.as_deref().unwrap_or_default().as_bytes());
214    feed(&[0xff, u8::from(style.font_family.is_some())]);
215    for value in [
216        style.font_size,
217        style.line_height,
218        style.letter_spacing,
219        style.max_width,
220        style.position.x,
221        style.position.y,
222        style.anchor.x,
223        style.anchor.y,
224    ] {
225        feed(&value.to_bits().to_le_bytes());
226    }
227    feed(&style.font_weight.to_le_bytes());
228    feed_paint(&mut feed, &style.fill);
229    feed(&[match style.align {
230        TextAlign::Start => 0,
231        TextAlign::Center => 1,
232        TextAlign::End => 2,
233    }]);
234    feed_look(&mut feed, &style.strokes, &style.shadows);
235    match style.background {
236        Some(background) => {
237            feed(&[1]);
238            feed(&rgba8(background.color));
239            feed(&background.padding.to_bits().to_le_bytes());
240        }
241        None => feed(&[0]),
242    }
243    feed(&size.width().to_le_bytes());
244    feed(&size.height().to_le_bytes());
245    hash.max(1)
246}
247
248/// Where `text` in `style` lies on a canvas of `size`, laid out with `rasterizer`'s fonts and
249/// placed exactly as a frame places it, without drawing it.
250///
251/// Measured at the frame's render size it agrees with that frame to the pixel; at another
252/// size lines can break elsewhere.
253///
254/// # Errors
255/// Returns the text host's refusal: no fonts, or a style out of range.
256#[allow(clippy::cast_precision_loss)]
257pub fn measure_text(
258    rasterizer: &TextRasterizer,
259    text: &str,
260    style: &TextStyle,
261    size: PictureSize,
262) -> Result<TextBounds, TextRasterError> {
263    let request = text_request(text, style, size, text_key(text, style, size));
264    let measure = rasterizer.measure(&request)?;
265    let (width, height) = (size.width() as f32, size.height() as f32);
266    let margin = measure.decoration_margin as f32;
267    let (box_width, box_height) = (measure.box_width as f32, measure.box_height as f32);
268    // The layout box's top-left corner on the canvas. The anchor of the decorated box lies at the
269    // position, as `text_placement` places the texture.
270    let origin = [
271        style.position.x * width - style.anchor.x * (box_width + 2.0 * margin) + margin,
272        style.position.y * height - style.anchor.y * (box_height + 2.0 * margin) + margin,
273    ];
274    let rect = |[left, top, right, bottom]: [f32; 4]| CanvasRect {
275        left: (origin[0] + left) / width,
276        top: (origin[1] + top) / height,
277        right: (origin[0] + right) / width,
278        bottom: (origin[1] + bottom) / height,
279    };
280    Ok(TextBounds {
281        layout: rect([-margin, -margin, box_width + margin, box_height + margin]),
282        content: rect(measure.content),
283        ink: measure.ink.map(rect),
284        line_count: measure.line_count,
285        baselines: Baselines {
286            first: (origin[1] + measure.first_baseline) / height,
287            last: (origin[1] + measure.last_baseline) / height,
288        },
289    })
290}
291
292/// A straight, sRGB-encoded color as bytes.
293#[must_use]
294#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
295pub fn rgba8(color: Rgba) -> [u8; 4] {
296    [color.red, color.green, color.blue, color.alpha]
297        .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8)
298}
299
300/// The text host request for `text` in `style` on a picture of `size`, with content version
301/// `key`. Sizes are scaled from canvas fractions to pixels and clamped to what the host draws.
302#[must_use]
303#[allow(clippy::cast_precision_loss)]
304pub fn text_request(
305    text: &str,
306    style: &TextStyle,
307    size: PictureSize,
308    key: u64,
309) -> TextRasterRequest {
310    let font_size = font_pixels(style, size);
311    TextRasterRequest {
312        text: text.to_owned(),
313        font_size,
314        max_width: (style.max_width * size.width() as f32).max(1.0),
315        line_height: style.line_height,
316        align: match style.align {
317            TextAlign::Start => alpha2_media::TextAlign::Start,
318            TextAlign::Center => alpha2_media::TextAlign::Center,
319            TextAlign::End => alpha2_media::TextAlign::End,
320        },
321        fill: text_fill(&style.fill),
322        emoji_policy: EmojiPolicy::Color,
323        content_version: key,
324        strokes: stroke_pixels(&style.strokes, font_size),
325        split_background: !style.shadows.is_empty() && style.background.is_some(),
326        background: style
327            .background
328            .map(|background| alpha2_text::TextBackground {
329                color: rgba8(background.color),
330                padding: (background.padding * font_size).clamp(0.0, MAX_PADDING_PIXELS),
331            }),
332        font_family: style.font_family.clone(),
333        letter_spacing: style.letter_spacing * font_size,
334        font_weight: style.font_weight,
335    }
336}
337
338/// A paint as the text host's fill: a color as bytes, or a gradient over the layout box with
339/// the same stops and the conic angle in radians.
340#[must_use]
341pub fn text_fill(paint: &Paint) -> TextFill {
342    match paint {
343        Paint::Solid { color } => TextFill::Solid(rgba8(*color)),
344        gradient => TextFill::Gradient(text_gradient(gradient)),
345    }
346}
347
348fn text_gradient(paint: &Paint) -> TextGradient {
349    let pair = |value: alpha2_model::Vec2| [value.x, value.y];
350    let stops = |stops: &[alpha2_model::GradientStop]| {
351        stops
352            .iter()
353            .map(|stop| TextGradientStop {
354                offset: stop.offset,
355                color: [
356                    stop.color.red,
357                    stop.color.green,
358                    stop.color.blue,
359                    stop.color.alpha,
360                ],
361            })
362            .collect()
363    };
364    let spread = |spread: Spread| match spread {
365        Spread::Pad => TextSpread::Pad,
366        Spread::Repeat => TextSpread::Repeat,
367        Spread::Reflect => TextSpread::Reflect,
368    };
369    match paint {
370        // A solid paint is a gradient of one color, for a caller that needs the gradient form.
371        Paint::Solid { color } => TextGradient {
372            kind: TextGradientKind::Linear {
373                start: [0.0, 0.0],
374                end: [1.0, 0.0],
375            },
376            stops: stops(&[
377                alpha2_model::GradientStop {
378                    offset: 0.0,
379                    color: *color,
380                },
381                alpha2_model::GradientStop {
382                    offset: 1.0,
383                    color: *color,
384                },
385            ]),
386            spread: TextSpread::Pad,
387        },
388        Paint::LinearGradient {
389            start,
390            end,
391            stops: list,
392            spread: mode,
393        } => TextGradient {
394            kind: TextGradientKind::Linear {
395                start: pair(*start),
396                end: pair(*end),
397            },
398            stops: stops(list),
399            spread: spread(*mode),
400        },
401        Paint::RadialGradient {
402            center,
403            radius,
404            stops: list,
405            spread: mode,
406        } => TextGradient {
407            kind: TextGradientKind::Radial {
408                center: pair(*center),
409                radius: *radius,
410            },
411            stops: stops(list),
412            spread: spread(*mode),
413        },
414        Paint::ConicGradient {
415            center,
416            angle_degrees,
417            stops: list,
418        } => TextGradient {
419            kind: TextGradientKind::Conic {
420                center: pair(*center),
421                angle: angle_degrees.to_radians(),
422            },
423            stops: stops(list),
424            spread: TextSpread::Pad,
425        },
426    }
427}
428
429/// `strokes` in pixels of a `font_size`-pixel font (widths are fractions of the font size).
430/// Each is at least the smallest positive width, and the last is shortened so together they
431/// reach at most [`MAX_STROKE_PIXELS`].
432#[must_use]
433pub fn stroke_pixels(strokes: &[TextStroke], font_size: f32) -> Vec<alpha2_text::TextStroke> {
434    let mut reach = 0.0_f32;
435    let mut pixels = Vec::with_capacity(strokes.len());
436    for stroke in strokes {
437        let room = MAX_STROKE_PIXELS - reach;
438        if room <= 0.0 {
439            break;
440        }
441        let width = (stroke.width * font_size).clamp(f32::MIN_POSITIVE, room);
442        reach += width;
443        pixels.push(alpha2_text::TextStroke {
444            color: rgba8(stroke.color),
445            width,
446        });
447    }
448    pixels
449}
450
451/// What the engine draws for one text: the raster, and the shadows composited under its
452/// glyphs.
453#[derive(Clone, Debug, PartialEq)]
454pub struct TextDraw {
455    /// The raster request.
456    pub request: TextRasterRequest,
457    /// The shadows, first on top.
458    pub shadows: Vec<ShadowPixels>,
459    /// The text's animators at its time, for a text that has them.
460    pub animation: Option<TextAnimation>,
461}
462
463/// A text's animators sampled at one time, with what they select from.
464#[derive(Clone, Debug, PartialEq)]
465pub struct TextAnimation {
466    /// The animators, in order.
467    pub animators: Vec<alpha2_eval::SampledAnimator>,
468    /// The text's runs, none for a plain string.
469    pub runs: Vec<alpha2_model::TextRun>,
470    /// The time Timing selectors compare run times with.
471    pub time: alpha2_time::MediaTime,
472}
473
474impl TextDraw {
475    /// Each cluster's state and the states' hash
476    /// ([`crate::text_animation::cluster_states`]), `None` without animators.
477    ///
478    /// # Errors
479    /// Returns the text host's refusal to lay the text out.
480    pub fn states(
481        &self,
482        rasterizer: &TextRasterizer,
483    ) -> Result<Option<(Vec<alpha2_text::ClusterState>, u64)>, TextRasterError> {
484        let Some(animation) = &self.animation else {
485            return Ok(None);
486        };
487        let layout = rasterizer.layout(&self.request)?;
488        Ok(Some(crate::text_animation::cluster_states(
489            &layout,
490            &animation.runs,
491            &animation.animators,
492            animation.time,
493            self.request.font_size,
494        )))
495    }
496}
497
498/// [`text_key`] for `content` in `style` shown `time` after its holder started. Without
499/// animators this is the static key; otherwise the runs and time are mixed in, so each moment
500/// of an animation is a distinct text. Never zero.
501#[must_use]
502pub fn animated_text_key(
503    content: &alpha2_model::TextContent,
504    style: &TextStyle,
505    size: PictureSize,
506    time: alpha2_time::MediaTime,
507) -> u64 {
508    let key = text_key(&content.text(), style, size);
509    if style.animators.is_empty() {
510        return key;
511    }
512    let mut hash = key;
513    let mut feed = |bytes: &[u8]| {
514        for byte in bytes {
515            hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
516        }
517    };
518    feed(&[0xfa]);
519    for run in content.runs() {
520        feed(&run.text.len().to_le_bytes());
521        for class in &run.class {
522            feed(class.as_bytes());
523            feed(&[0xff]);
524        }
525        if let Some(span) = run.time {
526            feed(&span.start.ticks().to_le_bytes());
527            feed(&span.end.ticks().to_le_bytes());
528        }
529        feed(&[0xfe]);
530    }
531    feed(&time.ticks().to_le_bytes());
532    hash.max(1)
533}
534
535/// The [`TextDraw`] of `text` in `style` on a picture of `size`, with content version `key`.
536#[must_use]
537pub fn text_draw(text: &str, style: &TextStyle, size: PictureSize, key: u64) -> TextDraw {
538    let request = text_request(text, style, size, key);
539    let shadows = shadow_pixels(&style.shadows, request.font_size);
540    TextDraw {
541        request,
542        shadows,
543        animation: None,
544    }
545}
546
547/// A text shadow in pixels of the picture its text is placed on.
548#[derive(Copy, Clone, Debug, PartialEq)]
549pub struct ShadowPixels {
550    /// Offset in pixels, +x right, +y down.
551    pub offset: [f32; 2],
552    /// The Gaussian's standard deviation in pixels: half the blur radius.
553    pub sigma: f32,
554    /// Straight, sRGB-encoded RGBA in `0..=1`.
555    pub color: [f32; 4],
556}
557
558/// `shadows` in pixels of a `font_size`-pixel font (lengths are fractions of the font size),
559/// first on top.
560#[must_use]
561pub fn shadow_pixels(shadows: &[TextShadow], font_size: f32) -> Vec<ShadowPixels> {
562    shadows
563        .iter()
564        .map(|shadow| ShadowPixels {
565            offset: [shadow.offset.x * font_size, shadow.offset.y * font_size],
566            sigma: shadow.blur * font_size * 0.5,
567            color: [
568                shadow.color.red,
569                shadow.color.green,
570                shadow.color.blue,
571                shadow.color.alpha,
572            ],
573        })
574        .collect()
575}
576
577/// The pixel font size `style` draws at on a picture of `size`, as [`text_request`] sets it.
578#[must_use]
579pub fn font_pixels(style: &TextStyle, size: PictureSize) -> f32 {
580    (style.font_size * size.unit()).clamp(1.0, MAX_FONT_PIXELS)
581}
582
583#[cfg(test)]
584#[path = "text_tests.rs"]
585mod tests;