Skip to main content

alpha2_model/
text.rs

1//! Styled text: what caption tracks and text generators draw.
2
3use crate::{Paint, Rgba, TextAnimator, 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's shorter side.
13pub const MAX_TEXT_GENERATOR_FONT_SIZE: f32 = 1.0;
14
15/// The line heights text takes, as multiples of the font size.
16pub const LINE_HEIGHT_RANGE: std::ops::RangeInclusive<f32> = 0.5..=3.0;
17
18/// The letter spacings text takes, as fractions of the font size.
19pub const LETTER_SPACING_RANGE: std::ops::RangeInclusive<f32> = -0.5..=1.0;
20
21/// The font weights text takes, as CSS `font-weight` numbers.
22pub const FONT_WEIGHT_RANGE: std::ops::RangeInclusive<u16> = 1..=1000;
23
24/// Most strokes around one text.
25pub const MAX_TEXT_STROKES: usize = 4;
26
27/// Widest a text's strokes reach together (the sum of their widths), as a fraction of the
28/// font size.
29pub const MAX_TEXT_STROKE_EXTENT: f32 = 0.5;
30
31/// Most shadows under one text.
32pub const MAX_TEXT_SHADOWS: usize = 4;
33
34/// Farthest a text shadow lies from its text on each axis, as a fraction of the font size.
35pub const MAX_TEXT_SHADOW_OFFSET: f32 = 1.0;
36
37/// Widest text shadow blur, as a fraction of the font size.
38pub const MAX_TEXT_SHADOW_BLUR: f32 = 1.0;
39
40/// Largest text background padding, as a fraction of the font size.
41pub const MAX_TEXT_PADDING: f32 = 2.0;
42
43/// How text looks and where it lies, for caption cues or a text [`crate::Generator`].
44///
45/// The font size is a fraction of the canvas's shorter side, the wrapping width a fraction
46/// of the canvas width, and strokes, shadows and padding fractions of the font size. The
47/// engine draws with its host's registered fonts, `font_family` first, then the rest in
48/// fallback order.
49///
50/// Painting is bottom to top as in CSS: background boxes, shadows (last listed lowest),
51/// strokes (outermost lowest), fill, then color glyphs.
52#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54#[serde(deny_unknown_fields)]
55pub struct TextStyle {
56    /// The family, as the font's name table gives it (typographic family, else family), matched
57    /// ASCII case-insensitively. Missing glyphs and unknown families fall back to the other fonts
58    /// in order; `None` uses the fallback order alone. Non-empty, at most
59    /// [`MAX_FONT_FAMILY_BYTES`] bytes.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub font_family: Option<String>,
62    /// Font size as a fraction of the canvas's shorter side: positive, at most
63    /// [`crate::MAX_CAPTION_FONT_SIZE`] on a caption track and [`MAX_TEXT_GENERATOR_FONT_SIZE`]
64    /// in a text generator.
65    #[serde(default = "default_font_size")]
66    pub font_size: f32,
67    /// What paints the glyphs; a color reads as a solid paint. Gradient positions are in the
68    /// text's layout box, `(0, 0)` top-left to `(1, 1)` bottom-right, and a radial radius is a
69    /// fraction of the box's shorter side. Default solid white.
70    #[serde(default = "white_paint")]
71    pub fill: Paint,
72    /// Baseline-to-baseline distance in font sizes, in `[0.5, 3]`. Each line's em box (0.8 font
73    /// sizes above the baseline, 0.2 below) is centred in a taller line.
74    #[serde(default = "default_line_height")]
75    pub line_height: f32,
76    /// Space after each character, as a fraction of the font size, in `[-0.5, 1]`, added as CSS
77    /// `letter-spacing` does: none after a line's last character or within cursive scripts such
78    /// as Arabic. A non-zero value turns optional ligatures off.
79    #[serde(default)]
80    pub letter_spacing: f32,
81    /// The weight in `[1, 1000]` used to match a family's faces, as CSS Fonts 4 matches
82    /// `font-weight`. Variable faces draw at it, clamped to their `wght` axis; no face is
83    /// synthetically emboldened.
84    #[serde(default = "default_font_weight")]
85    pub font_weight: u16,
86    /// How lines align within the text.
87    #[serde(default)]
88    pub align: TextAlign,
89    /// Width text wraps at, as a fraction of the canvas width, in `(0, 1]`.
90    #[serde(default = "default_max_width")]
91    pub max_width: f32,
92    /// Where on the canvas the text's anchor lies, `(0, 0)` top-left to `(1, 1)` bottom-right.
93    #[serde(default = "default_position")]
94    pub position: Vec2,
95    /// The point of the text, background included, placed at `position`; `(0.5, 1)` is its
96    /// bottom centre.
97    #[serde(default = "default_anchor")]
98    pub anchor: Vec2,
99    /// Bands around the glyphs, innermost first, each outside the one before. At most
100    /// [`MAX_TEXT_STROKES`], widths summing to at most [`MAX_TEXT_STROKE_EXTENT`].
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub strokes: Vec<TextStroke>,
103    /// Shadows of the glyphs and strokes, the first on top, as CSS `text-shadow` lists them. At
104    /// most [`MAX_TEXT_SHADOWS`].
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub shadows: Vec<TextShadow>,
107    /// A box behind each line.
108    #[serde(default)]
109    pub background: Option<TextBackground>,
110    /// Per-character motion and color, applied in order, at most [`crate::MAX_TEXT_ANIMATORS`].
111    /// Keyframes are in the holder's time (since a caption cue's start, or a text generator's
112    /// picture time). They never change line layout.
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub animators: Vec<TextAnimator>,
115}
116
117impl Default for TextStyle {
118    fn default() -> Self {
119        Self {
120            font_family: None,
121            font_size: default_font_size(),
122            fill: white_paint(),
123            line_height: default_line_height(),
124            letter_spacing: 0.0,
125            font_weight: default_font_weight(),
126            align: TextAlign::default(),
127            max_width: default_max_width(),
128            position: default_position(),
129            anchor: default_anchor(),
130            strokes: Vec::new(),
131            shadows: Vec::new(),
132            background: None,
133            animators: Vec::new(),
134        }
135    }
136}
137
138const fn default_font_size() -> f32 {
139    0.05
140}
141
142const fn white_paint() -> Paint {
143    Paint::Solid {
144        color: Rgba::new(1.0, 1.0, 1.0, 1.0),
145    }
146}
147
148const fn default_line_height() -> f32 {
149    1.2
150}
151
152const fn default_font_weight() -> u16 {
153    400
154}
155
156const fn default_max_width() -> f32 {
157    0.9
158}
159
160const fn default_position() -> Vec2 {
161    Vec2::new(0.5, 0.92)
162}
163
164const fn default_anchor() -> Vec2 {
165    Vec2::new(0.5, 1.0)
166}
167
168/// How lines of text align within it.
169#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[serde(rename_all = "snake_case")]
172pub enum TextAlign {
173    /// Left.
174    Start,
175    /// Centred.
176    #[default]
177    Center,
178    /// Right.
179    End,
180}
181
182/// A band around the glyphs and the strokes listed before it, drawn behind them.
183#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[serde(deny_unknown_fields)]
186pub struct TextStroke {
187    /// Band color.
188    pub color: Rgba,
189    /// Band width as a fraction of the font size, positive; a text's strokes sum to at most
190    /// [`MAX_TEXT_STROKE_EXTENT`].
191    pub width: f32,
192}
193
194/// A shadow of a text's glyphs and strokes, behind them.
195#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197#[serde(deny_unknown_fields)]
198pub struct TextShadow {
199    /// Shadow color.
200    pub color: Rgba,
201    /// Offset in font sizes, +x right, +y down; each component in `[-1, 1]`
202    /// ([`MAX_TEXT_SHADOW_OFFSET`]).
203    #[serde(default)]
204    pub offset: Vec2,
205    /// Blur radius in font sizes, as CSS `text-shadow` takes it (the Gaussian's standard
206    /// deviation is half of it). In `[0, 1]` ([`MAX_TEXT_SHADOW_BLUR`]).
207    #[serde(default)]
208    pub blur: f32,
209}
210
211/// A box behind each line of text.
212#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
214#[serde(deny_unknown_fields)]
215pub struct TextBackground {
216    /// Box color.
217    pub color: Rgba,
218    /// Space between the text and the box edge, as a fraction of the font size, in `[0, 2]`.
219    #[serde(default)]
220    pub padding: f32,
221}
222
223impl TextStyle {
224    /// Whether every field is in range, with the font size at most `max_font_size`.
225    pub(crate) fn is_valid(&self, max_font_size: f32) -> bool {
226        let finite = |value: Vec2| value.x.is_finite() && value.y.is_finite();
227        self.font_family
228            .as_ref()
229            .is_none_or(|family| !family.is_empty() && family.len() <= MAX_FONT_FAMILY_BYTES)
230            && self.font_size > 0.0
231            && self.font_size <= max_font_size
232            && self.fill.is_valid()
233            && LINE_HEIGHT_RANGE.contains(&self.line_height)
234            && LETTER_SPACING_RANGE.contains(&self.letter_spacing)
235            && FONT_WEIGHT_RANGE.contains(&self.font_weight)
236            && self.max_width > 0.0
237            && self.max_width <= 1.0
238            && finite(self.position)
239            && finite(self.anchor)
240            && valid_strokes(&self.strokes)
241            && valid_shadows(&self.shadows)
242            && crate::text_animation::valid_animators(&self.animators, false)
243            && self.background.is_none_or(|background| {
244                background.color.is_valid()
245                    && (0.0..=MAX_TEXT_PADDING).contains(&background.padding)
246            })
247    }
248}
249
250/// Whether `strokes` are at most [`MAX_TEXT_STROKES`] valid bands reaching at most
251/// [`MAX_TEXT_STROKE_EXTENT`] together.
252pub(crate) fn valid_strokes(strokes: &[TextStroke]) -> bool {
253    strokes.len() <= MAX_TEXT_STROKES
254        && strokes
255            .iter()
256            .all(|stroke| stroke.color.is_valid() && stroke.width.is_finite() && stroke.width > 0.0)
257        && strokes.iter().map(|stroke| stroke.width).sum::<f32>() <= MAX_TEXT_STROKE_EXTENT
258}
259
260/// Whether `shadows` are at most [`MAX_TEXT_SHADOWS`] shadows in range.
261pub(crate) fn valid_shadows(shadows: &[TextShadow]) -> bool {
262    let offset = |value: f32| value.is_finite() && value.abs() <= MAX_TEXT_SHADOW_OFFSET;
263    shadows.len() <= MAX_TEXT_SHADOWS
264        && shadows.iter().all(|shadow| {
265            shadow.color.is_valid()
266                && offset(shadow.offset.x)
267                && offset(shadow.offset.y)
268                && (0.0..=MAX_TEXT_SHADOW_BLUR).contains(&shadow.blur)
269        })
270}