Skip to main content

alpha2_model/
text.rs

1//! Styled text: what caption tracks and text generators draw.
2
3use crate::{Rgba, Vec2};
4use serde::{Deserialize, Serialize};
5
6/// Most bytes of text in one caption cue or text generator.
7pub const MAX_TEXT_CONTENT_BYTES: usize = 4_096;
8
9/// Most bytes in a text style's font family name.
10pub const MAX_FONT_FAMILY_BYTES: usize = 256;
11
12/// Largest font size of a text generator, as a fraction of the canvas
13/// height.
14pub const MAX_TEXT_GENERATOR_FONT_SIZE: f32 = 1.0;
15
16/// The line heights text takes, as multiples of the font size.
17pub const LINE_HEIGHT_RANGE: std::ops::RangeInclusive<f32> = 0.5..=3.0;
18
19/// Widest text outline, as a fraction of the font size.
20pub const MAX_TEXT_OUTLINE_WIDTH: f32 = 0.5;
21
22/// Largest text background padding, as a fraction of the font size.
23pub const MAX_TEXT_PADDING: f32 = 2.0;
24
25/// How text looks and where it lies: a caption track's cues, or a text
26/// [`crate::Generator`].
27///
28/// Sizes are resolution-independent: the font size is a fraction of the
29/// canvas height, the wrapping width a fraction of the canvas width, and the
30/// outline and background padding fractions of the font size. The engine
31/// draws with the fonts its host registered: those of `font_family` first,
32/// then the rest in their fallback order.
33#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(deny_unknown_fields)]
36pub struct TextStyle {
37    /// The family the text draws in, as the font's own name table gives it
38    /// (typographic family, else family), matched without regard to ASCII
39    /// case. Glyphs its fonts lack, and a family no registered font has,
40    /// draw from the other fonts in fallback order. `None` draws from the
41    /// fallback order alone. Non-empty, at most [`MAX_FONT_FAMILY_BYTES`]
42    /// bytes.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub font_family: Option<String>,
45    /// Font size as a fraction of the canvas height: positive, at most
46    /// [`crate::MAX_CAPTION_FONT_SIZE`] on a caption track and
47    /// [`MAX_TEXT_GENERATOR_FONT_SIZE`] in a text generator.
48    #[serde(default = "default_font_size")]
49    pub font_size: f32,
50    /// Text color.
51    #[serde(default = "white")]
52    pub color: Rgba,
53    /// Baseline-to-baseline distance in font sizes, in `[0.5, 3]`.
54    #[serde(default = "default_line_height")]
55    pub line_height: f32,
56    /// How lines align within the text.
57    #[serde(default)]
58    pub align: TextAlign,
59    /// Width text wraps at, as a fraction of the canvas width, in `(0, 1]`.
60    #[serde(default = "default_max_width")]
61    pub max_width: f32,
62    /// Where on the canvas the text's anchor lies, `(0, 0)` top-left to
63    /// `(1, 1)` bottom-right.
64    #[serde(default = "default_position")]
65    pub position: Vec2,
66    /// The point of the text, background included, placed at
67    /// `position`: `(0.5, 1)` is its bottom centre.
68    #[serde(default = "default_anchor")]
69    pub anchor: Vec2,
70    /// A band around the glyphs.
71    #[serde(default)]
72    pub outline: Option<TextOutline>,
73    /// A box behind each line.
74    #[serde(default)]
75    pub background: Option<TextBackground>,
76}
77
78impl Default for TextStyle {
79    fn default() -> Self {
80        Self {
81            font_family: None,
82            font_size: default_font_size(),
83            color: white(),
84            line_height: default_line_height(),
85            align: TextAlign::default(),
86            max_width: default_max_width(),
87            position: default_position(),
88            anchor: default_anchor(),
89            outline: None,
90            background: None,
91        }
92    }
93}
94
95const fn default_font_size() -> f32 {
96    0.05
97}
98
99const fn white() -> Rgba {
100    Rgba::new(1.0, 1.0, 1.0, 1.0)
101}
102
103const fn default_line_height() -> f32 {
104    1.2
105}
106
107const fn default_max_width() -> f32 {
108    0.9
109}
110
111const fn default_position() -> Vec2 {
112    Vec2::new(0.5, 0.92)
113}
114
115const fn default_anchor() -> Vec2 {
116    Vec2::new(0.5, 1.0)
117}
118
119/// How lines of text align within it.
120#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122#[serde(rename_all = "snake_case")]
123pub enum TextAlign {
124    /// Left.
125    Start,
126    /// Centred.
127    #[default]
128    Center,
129    /// Right.
130    End,
131}
132
133/// A band around the glyphs, drawn behind them.
134#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
135#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
136#[serde(deny_unknown_fields)]
137pub struct TextOutline {
138    /// Band color.
139    pub color: Rgba,
140    /// Band width as a fraction of the font size, in `(0, 0.5]`.
141    pub width: f32,
142}
143
144/// A box behind each line of text.
145#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147#[serde(deny_unknown_fields)]
148pub struct TextBackground {
149    /// Box color.
150    pub color: Rgba,
151    /// Space between the text and the box edge, as a fraction of the font
152    /// size, in `[0, 2]`.
153    #[serde(default)]
154    pub padding: f32,
155}
156
157impl TextStyle {
158    /// Whether every field is in range, the font size at most
159    /// `max_font_size`.
160    pub(crate) fn is_valid(&self, max_font_size: f32) -> bool {
161        let finite = |value: Vec2| value.x.is_finite() && value.y.is_finite();
162        self.font_family
163            .as_ref()
164            .is_none_or(|family| !family.is_empty() && family.len() <= MAX_FONT_FAMILY_BYTES)
165            && self.font_size > 0.0
166            && self.font_size <= max_font_size
167            && self.color.is_valid()
168            && LINE_HEIGHT_RANGE.contains(&self.line_height)
169            && self.max_width > 0.0
170            && self.max_width <= 1.0
171            && finite(self.position)
172            && finite(self.anchor)
173            && self.outline.is_none_or(|outline| {
174                outline.color.is_valid()
175                    && outline.width > 0.0
176                    && outline.width <= MAX_TEXT_OUTLINE_WIDTH
177            })
178            && self.background.is_none_or(|background| {
179                background.color.is_valid()
180                    && (0.0..=MAX_TEXT_PADDING).contains(&background.padding)
181            })
182    }
183}