Skip to main content

alpha2_engine/
text.rs

1//! Text rendering: caption tracks' cues and text generators, rasterized by
2//! the portable text host.
3//!
4//! The engine draws text itself, from font bytes its host registers once,
5//! so a browser preview and a native export of one project, given the same
6//! fonts, draw the same text pixels. A host keeps one [`TextTextures`]
7//! beside its frame provider, calls [`TextTextures::prepare`] for each frame
8//! before rendering it, and answers [`crate::FrameProvider::text`] from it.
9//! A caption is composited as a layer between tracks; a text generator's
10//! texture is placed on the transparent picture the generator draws.
11
12use std::collections::HashMap;
13use std::fmt;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, Ordering};
16
17use alpha2_eval::{FrameDesc, StepOp};
18use alpha2_media::EmojiPolicy;
19use alpha2_model::{Generator, Rgba, TextAlign, TextStyle};
20use alpha2_render::{RenderCacheKey, RenderError, RenderSize, Renderer, SourceView, UploadTexture};
21use alpha2_text::{TextFont, TextRasterError, TextRasterRequest, TextRasterizer};
22
23/// The high 64 bits of a text's result cache key, before its font set is
24/// mixed in: font sets are counted from 1 and stay far below this, so those
25/// bits are never zero, as a host's source keys ([`RenderCacheKey::source`])
26/// leave them, and the two never meet.
27const TEXT_CACHE_DOMAIN: u64 = 0x6361_7074_696f_6e73;
28
29/// The next font set's identity: each store, and each font a store adds,
30/// takes one, so no two font sets that might draw text differently in one
31/// process share a text cache key.
32static NEXT_FONT_SET: AtomicU64 = AtomicU64::new(1);
33
34/// How many rasterized texts a host keeps between frames; the current
35/// frame's are kept however many it shows.
36const RETAINED_TEXTS: usize = 16;
37
38/// The largest font, outline, and padding the text host draws, in pixels.
39const MAX_FONT_PIXELS: f32 = 2_048.0;
40const MAX_OUTLINE_PIXELS: f32 = 64.0;
41const MAX_PADDING_PIXELS: f32 = 256.0;
42
43/// One prepared text: its texture, pixel size, and where its layout box
44/// lies in it.
45#[derive(Clone, Copy, Debug)]
46pub struct TextTexture<'a> {
47    /// Premultiplied, sRGB-encoded RGBA, as every source texture.
48    pub texture: SourceView<'a>,
49    /// Width in pixels.
50    pub width: u32,
51    /// Height in pixels.
52    pub height: u32,
53    /// The margin, in pixels, the same on both sides of each axis, that the
54    /// texture adds around the text and its decorations for ink reaching
55    /// past them: an accent above the first line, say. Placement leaves it
56    /// out, so such ink never moves the text.
57    pub margin: [u32; 2],
58    /// The key the renderer's result cache holds results drawn from this
59    /// texture under, or `None` to keep every frame showing it out of that
60    /// cache. It is the provider's promise that the texture's pixels depend
61    /// on nothing it leaves out, as [`crate::FrameProvider::source_key`] is
62    /// for a source: [`TextTextures`] keys each text by its [`text_key`] and
63    /// the fonts it was drawn with.
64    pub cache_key: Option<RenderCacheKey>,
65}
66
67/// Where a text lies, as fractions of the canvas, `(0, 0)` top-left to
68/// `(1, 1)` bottom-right, as its style's position is: on the picture a text
69/// generator draws, before its clip's transform and effects, and on the
70/// canvas for a caption. [`measure_text`] measures it.
71#[derive(Copy, Clone, Debug, PartialEq)]
72pub struct TextBounds {
73    /// The box the style's anchor and position place: the wrapping width
74    /// wide, every line's em box tall, and the whole pixels of outline and
75    /// background padding around that.
76    pub layout: CanvasRect,
77    /// The lines' advances: from the start of the line that starts farthest
78    /// left to the end of the one that ends farthest right, as tall as the
79    /// layout, with the same decorations around it. It moves only as the
80    /// advances do, not as glyphs with more or less ink are typed.
81    pub content: CanvasRect,
82    /// What the text draws: its glyphs, their outline, and the background's
83    /// boxes, as [`alpha2_text::TextMeasure::ink`] bounds them at the measured
84    /// size. Every pixel the text gives any alpha there meets it, give or
85    /// take the pixel sampling the texture spreads it by. `None` when the text
86    /// draws nothing.
87    pub ink: Option<CanvasRect>,
88    /// Number of laid-out lines.
89    pub line_count: u32,
90}
91
92/// A rectangle as fractions of the canvas, y down.
93#[derive(Copy, Clone, Debug, PartialEq)]
94pub struct CanvasRect {
95    /// Left edge, a fraction of the canvas width.
96    pub left: f32,
97    /// Top edge, a fraction of the canvas height.
98    pub top: f32,
99    /// Right edge, a fraction of the canvas width.
100    pub right: f32,
101    /// Bottom edge, a fraction of the canvas height.
102    pub bottom: f32,
103}
104
105/// Why text could not be prepared.
106#[derive(Clone, Debug, Eq, PartialEq)]
107#[non_exhaustive]
108pub enum TextError {
109    /// The text host refused: no fonts registered, a malformed font, or a
110    /// text too large to rasterize.
111    Raster(TextRasterError),
112    /// The rasterized text could not be uploaded.
113    Render(RenderError),
114}
115
116impl fmt::Display for TextError {
117    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Self::Raster(TextRasterError::NoFonts) => {
120                formatter.write_str("the project shows text, but no fonts were registered")
121            }
122            Self::Raster(error) => write!(formatter, "text failed: {error}"),
123            Self::Render(error) => write!(formatter, "text upload failed: {error}"),
124        }
125    }
126}
127
128impl std::error::Error for TextError {}
129
130struct TextEntry {
131    texture: UploadTexture,
132    width: u32,
133    height: u32,
134    margin: [u32; 2],
135    last_used: u64,
136}
137
138/// Fonts and the texts rasterized with them, keyed by content and render
139/// size.
140pub struct TextTextures {
141    rasterizer: TextRasterizer,
142    /// The identity of the fonts the texts are drawn with, from
143    /// [`NEXT_FONT_SET`].
144    font_set: u64,
145    entries: HashMap<u64, TextEntry>,
146    frame: u64,
147}
148
149impl fmt::Debug for TextTextures {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        formatter
152            .debug_struct("TextTextures")
153            .field("fonts", &self.rasterizer.font_count())
154            .field("texts", &self.entries.len())
155            .finish_non_exhaustive()
156    }
157}
158
159impl Default for TextTextures {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165impl TextTextures {
166    /// No fonts and no texts.
167    #[must_use]
168    pub fn new() -> Self {
169        Self {
170            rasterizer: TextRasterizer::empty(),
171            font_set: NEXT_FONT_SET.fetch_add(1, Ordering::Relaxed),
172            entries: HashMap::new(),
173            frame: 0,
174        }
175    }
176
177    /// Appends an OpenType font to the fallback stack text draws with. Texts
178    /// already rasterized are dropped.
179    ///
180    /// # Errors
181    /// Returns [`TextError::Raster`] for malformed font bytes.
182    pub fn add_font(
183        &mut self,
184        family: impl Into<String>,
185        bytes: impl Into<Arc<[u8]>>,
186        face_index: u32,
187    ) -> Result<(), TextError> {
188        self.rasterizer
189            .add_font(TextFont {
190                family: family.into(),
191                bytes: bytes.into(),
192                face_index,
193            })
194            .map_err(TextError::Raster)?;
195        self.entries.clear();
196        // The same text may now draw other glyphs.
197        self.font_set = NEXT_FONT_SET.fetch_add(1, Ordering::Relaxed);
198        Ok(())
199    }
200
201    /// Fonts in the fallback stack.
202    #[must_use]
203    pub fn font_count(&self) -> usize {
204        self.rasterizer.font_count()
205    }
206
207    /// Rasterizes and uploads every text `frame` shows at `size` that is not
208    /// already held: its captions and the text generators its clips draw.
209    /// Forgets the least recently shown beyond a small number.
210    ///
211    /// # Errors
212    /// Returns [`TextError`] when a text cannot be rasterized or uploaded;
213    /// with no fonts registered, only if the frame shows one.
214    pub fn prepare(
215        &mut self,
216        renderer: &Renderer,
217        frame: &FrameDesc,
218        size: RenderSize,
219    ) -> Result<(), TextError> {
220        self.frame += 1;
221        for (text, style) in frame_texts(frame) {
222            let key = text_key(text, style, size);
223            if let Some(entry) = self.entries.get_mut(&key) {
224                entry.last_used = self.frame;
225                continue;
226            }
227            let request = text_request(text, style, size, key);
228            let raster = self
229                .rasterizer
230                .rasterize(&request)
231                .map_err(TextError::Raster)?;
232            // Each text is rasterized once per key; keep no copy.
233            self.rasterizer.invalidate(key);
234            let texture_size = &raster.descriptor.texture;
235            // The anchor places the decorated text; the margin, which holds only ink reaching past
236            // it, takes no part in placement.
237            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
238            let margin = [raster.descriptor.layout_x, raster.descriptor.layout_y]
239                .map(|margin| (margin as u32).saturating_sub(request.decoration_margin()));
240            let texture = renderer
241                .upload_rgba8(
242                    RenderSize::new(texture_size.width, texture_size.height)
243                        .map_err(TextError::Render)?,
244                    &raster.pixels.rgba8,
245                )
246                .map_err(TextError::Render)?;
247            self.entries.insert(
248                key,
249                TextEntry {
250                    texture,
251                    width: texture_size.width,
252                    height: texture_size.height,
253                    margin,
254                    last_used: self.frame,
255                },
256            );
257        }
258        while self.entries.len() > RETAINED_TEXTS {
259            let Some(oldest) = self
260                .entries
261                .iter()
262                .filter(|(_, entry)| entry.last_used < self.frame)
263                .min_by_key(|(_, entry)| entry.last_used)
264                .map(|(key, _)| *key)
265            else {
266                break;
267            };
268            self.entries.remove(&oldest);
269        }
270        Ok(())
271    }
272
273    /// [`Self::prepare`] for the frame of `project` at `time`, rendered at
274    /// `size`; nothing to do, and no evaluation, when the project draws no
275    /// text ([`crate::EngineProject::draws_text`]).
276    ///
277    /// # Errors
278    /// Returns [`crate::EngineError::Evaluate`] or
279    /// [`crate::EngineError::Text`].
280    pub fn prepare_at(
281        &mut self,
282        renderer: &Renderer,
283        project: &crate::EngineProject,
284        time: alpha2_time::MediaTime,
285        size: RenderSize,
286    ) -> Result<(), crate::EngineError> {
287        if !project.draws_text() {
288            return Ok(());
289        }
290        let frame = project.evaluate(time)?;
291        self.prepare(renderer, &frame, size)
292            .map_err(crate::EngineError::Text)
293    }
294
295    /// Where `text` in `style` lies on a canvas of `size`, drawn with these
296    /// fonts: [`measure_text`].
297    ///
298    /// # Errors
299    /// Returns [`TextError::Raster`] as [`Self::prepare`] would for the text.
300    pub fn measure(
301        &self,
302        text: &str,
303        style: &TextStyle,
304        size: RenderSize,
305    ) -> Result<TextBounds, TextError> {
306        measure_text(&self.rasterizer, text, style, size)
307    }
308
309    /// The prepared text with `key`, as [`text_key`] names it.
310    #[must_use]
311    pub fn get(&self, key: u64) -> Option<TextTexture<'_>> {
312        self.entries.get(&key).map(|entry| TextTexture {
313            texture: SourceView::from(&entry.texture),
314            width: entry.width,
315            height: entry.height,
316            margin: entry.margin,
317            cache_key: Some(self.cache_key(key)),
318        })
319    }
320
321    /// The result cache key of the text with `key`, drawn with these fonts:
322    /// the font set in the high half, beside the text domain, and the text
323    /// in the low half, so two keys are equal only for the same text drawn
324    /// with the same fonts.
325    fn cache_key(&self, key: u64) -> RenderCacheKey {
326        let high = TEXT_CACHE_DOMAIN ^ self.font_set;
327        RenderCacheKey::from_value((u128::from(high) << 64) | u128::from(key))
328    }
329}
330
331/// Every text `frame` may draw, with its style: each caption, then each text
332/// generator of a clip it draws, the clip's own source or a step of its
333/// compositions. A text drawn twice is named twice.
334pub(crate) fn frame_texts<'f>(
335    frame: &'f FrameDesc<'_>,
336) -> impl Iterator<Item = (&'f str, &'f TextStyle)> {
337    let captions = frame
338        .captions
339        .iter()
340        .map(|caption| (caption.text, caption.style));
341    let generators = frame
342        .visual
343        .iter()
344        .filter(|clip| frame.draws(clip))
345        .flat_map(|clip| {
346            let steps = clip
347                .composition
348                .iter()
349                .chain(&clip.source_composition)
350                .flat_map(|composition| &composition.plan().steps)
351                .filter_map(|step| match &step.op {
352                    StepOp::Generator { generator, .. } => Some(generator),
353                    _ => None,
354                });
355            clip.generator.into_iter().chain(steps)
356        })
357        .filter_map(|generator| match generator {
358            Generator::Text { text, style } => Some((text.as_str(), style)),
359            _ => None,
360        });
361    captions.chain(generators)
362}
363
364/// A host's frame provider together with its text store: frames come from
365/// the one and text from the other.
366pub struct FramesWithText<'a> {
367    frames: &'a dyn crate::FrameProvider,
368    texts: &'a TextTextures,
369}
370
371impl<'a> FramesWithText<'a> {
372    /// `frames` with the texts `texts` holds.
373    #[must_use]
374    pub fn new(frames: &'a dyn crate::FrameProvider, texts: &'a TextTextures) -> Self {
375        Self { frames, texts }
376    }
377}
378
379impl crate::FrameProvider for FramesWithText<'_> {
380    fn texture_for(
381        &self,
382        source: &alpha2_media::SourceId,
383        time: alpha2_time::MediaTime,
384    ) -> Option<SourceView<'_>> {
385        self.frames.texture_for(source, time)
386    }
387
388    fn pending_source_work(
389        &self,
390        source: &alpha2_media::SourceId,
391        time: alpha2_time::MediaTime,
392    ) -> Option<&alpha2_render::PendingSourceWork> {
393        self.frames.pending_source_work(source, time)
394    }
395
396    fn source_key(
397        &self,
398        source: &alpha2_media::SourceId,
399        time: alpha2_time::MediaTime,
400    ) -> Option<alpha2_render::RenderCacheKey> {
401        self.frames.source_key(source, time)
402    }
403
404    fn text(&self, key: u64) -> Option<TextTexture<'_>> {
405        self.texts.get(key)
406    }
407}
408
409/// A text's identity at a render size: the text, its style, and the size,
410/// hashed (FNV-1a). Never zero.
411#[must_use]
412pub fn text_key(text: &str, style: &TextStyle, size: RenderSize) -> u64 {
413    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
414    let mut feed = |bytes: &[u8]| {
415        for byte in bytes {
416            hash = (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3);
417        }
418    };
419    feed(text.as_bytes());
420    feed(&[0xff]);
421    // Family names cannot hold 0xff (they are UTF-8), so the text and family stay apart.
422    feed(style.font_family.as_deref().unwrap_or_default().as_bytes());
423    feed(&[0xff, u8::from(style.font_family.is_some())]);
424    for value in [
425        style.font_size,
426        style.line_height,
427        style.max_width,
428        style.position.x,
429        style.position.y,
430        style.anchor.x,
431        style.anchor.y,
432    ] {
433        feed(&value.to_bits().to_le_bytes());
434    }
435    feed(&rgba8(style.color));
436    feed(&[match style.align {
437        TextAlign::Start => 0,
438        TextAlign::Center => 1,
439        TextAlign::End => 2,
440    }]);
441    match style.outline {
442        Some(outline) => {
443            feed(&[1]);
444            feed(&rgba8(outline.color));
445            feed(&outline.width.to_bits().to_le_bytes());
446        }
447        None => feed(&[0]),
448    }
449    match style.background {
450        Some(background) => {
451            feed(&[1]);
452            feed(&rgba8(background.color));
453            feed(&background.padding.to_bits().to_le_bytes());
454        }
455        None => feed(&[0]),
456    }
457    feed(&size.width.to_le_bytes());
458    feed(&size.height.to_le_bytes());
459    hash.max(1)
460}
461
462/// Where `text` in `style` lies on a canvas of `size` ([`TextBounds`]), laid
463/// out with `rasterizer`'s fonts as [`TextTextures::prepare`] lays it out and
464/// placed as a frame places it, without drawing it. Measured at the size a
465/// frame is drawn at, it agrees with that frame to the pixel; text laid out
466/// at another size can break its lines elsewhere.
467///
468/// # Errors
469/// Returns [`TextError::Raster`] when the text host refuses the text: no
470/// fonts, or a style out of range.
471#[allow(clippy::cast_precision_loss)]
472pub fn measure_text(
473    rasterizer: &TextRasterizer,
474    text: &str,
475    style: &TextStyle,
476    size: RenderSize,
477) -> Result<TextBounds, TextError> {
478    let request = text_request(text, style, size, text_key(text, style, size));
479    let measure = rasterizer.measure(&request).map_err(TextError::Raster)?;
480    let (width, height) = (size.width as f32, size.height as f32);
481    let margin = measure.decoration_margin as f32;
482    let (box_width, box_height) = (measure.box_width as f32, measure.box_height as f32);
483    // The layout box's top-left corner on the canvas: the anchor of the box with its decorations
484    // lies at the position, as `text_placement` places the texture.
485    let origin = [
486        style.position.x * width - style.anchor.x * (box_width + 2.0 * margin) + margin,
487        style.position.y * height - style.anchor.y * (box_height + 2.0 * margin) + margin,
488    ];
489    let rect = |[left, top, right, bottom]: [f32; 4]| CanvasRect {
490        left: (origin[0] + left) / width,
491        top: (origin[1] + top) / height,
492        right: (origin[0] + right) / width,
493        bottom: (origin[1] + bottom) / height,
494    };
495    Ok(TextBounds {
496        layout: rect([-margin, -margin, box_width + margin, box_height + margin]),
497        content: rect(measure.content),
498        ink: measure.ink.map(rect),
499        line_count: measure.line_count,
500    })
501}
502
503/// A straight, sRGB-encoded color as bytes.
504#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
505fn rgba8(color: Rgba) -> [u8; 4] {
506    [color.red, color.green, color.blue, color.alpha]
507        .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8)
508}
509
510/// The text host request for `text` at `size`: sizes scaled from canvas
511/// fractions to pixels, within what the text host draws.
512#[allow(clippy::cast_precision_loss)]
513fn text_request(text: &str, style: &TextStyle, size: RenderSize, key: u64) -> TextRasterRequest {
514    let font_size = (style.font_size * size.height as f32).clamp(1.0, MAX_FONT_PIXELS);
515    TextRasterRequest {
516        text: text.to_owned(),
517        font_size,
518        max_width: (style.max_width * size.width as f32).max(1.0),
519        line_height: style.line_height,
520        align: match style.align {
521            TextAlign::Start => alpha2_media::TextAlign::Start,
522            TextAlign::Center => alpha2_media::TextAlign::Center,
523            TextAlign::End => alpha2_media::TextAlign::End,
524        },
525        color: rgba8(style.color),
526        emoji_policy: EmojiPolicy::Color,
527        content_version: key,
528        outline: style.outline.map(|outline| alpha2_text::TextOutline {
529            color: rgba8(outline.color),
530            width: (outline.width * font_size).clamp(f32::MIN_POSITIVE, MAX_OUTLINE_PIXELS),
531        }),
532        background: style
533            .background
534            .map(|background| alpha2_text::TextBackground {
535                color: rgba8(background.color),
536                padding: (background.padding * font_size).clamp(0.0, MAX_PADDING_PIXELS),
537            }),
538        font_family: style.font_family.clone(),
539    }
540}
541
542#[cfg(test)]
543#[path = "text_tests.rs"]
544mod tests;