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's
13/// shorter side.
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/// The letter spacings text takes, as fractions of the font size: wide
20/// enough for display tracking, and no tighter than characters crossing
21/// their neighbours.
22pub const LETTER_SPACING_RANGE: std::ops::RangeInclusive<f32> = -0.5..=1.0;
23
24/// The font weights text takes, as CSS `font-weight` numbers.
25pub const FONT_WEIGHT_RANGE: std::ops::RangeInclusive<u16> = 1..=1000;
26
27/// Widest text outline, as a fraction of the font size.
28pub const MAX_TEXT_OUTLINE_WIDTH: f32 = 0.5;
29
30/// Largest text background padding, as a fraction of the font size.
31pub const MAX_TEXT_PADDING: f32 = 2.0;
32
33/// How text looks and where it lies: a caption track's cues, or a text
34/// [`crate::Generator`].
35///
36/// Sizes are resolution-independent: the font size is a fraction of the
37/// canvas's shorter side, as every other length is, the wrapping width a
38/// fraction of the canvas width, and the outline and background padding
39/// fractions of the font size. The engine draws with the fonts its host
40/// registered: those of `font_family` first, then the rest in their
41/// fallback order.
42#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44#[serde(deny_unknown_fields)]
45pub struct TextStyle {
46    /// The family the text draws in, as the font's own name table gives it
47    /// (typographic family, else family), matched without regard to ASCII
48    /// case. Glyphs its fonts lack, and a family no registered font has,
49    /// draw from the other fonts in fallback order. `None` draws from the
50    /// fallback order alone. Non-empty, at most [`MAX_FONT_FAMILY_BYTES`]
51    /// bytes.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub font_family: Option<String>,
54    /// Font size as a fraction of the canvas's shorter side: positive, at
55    /// most [`crate::MAX_CAPTION_FONT_SIZE`] on a caption track and
56    /// [`MAX_TEXT_GENERATOR_FONT_SIZE`] in a text generator.
57    #[serde(default = "default_font_size")]
58    pub font_size: f32,
59    /// Text color.
60    #[serde(default = "white")]
61    pub color: Rgba,
62    /// Baseline-to-baseline distance in font sizes, in `[0.5, 3]`. Each
63    /// line's em box, 0.8 of a font size above its baseline and 0.2 below,
64    /// is centred in its line when the line is taller than an em.
65    #[serde(default = "default_line_height")]
66    pub line_height: f32,
67    /// Space after each character, as a fraction of the font size, in
68    /// `[-0.5, 1]`, as CSS `letter-spacing` adds it: none after a line's
69    /// last character, and none after a character of a cursive script
70    /// (Arabic, say), whose joins it would break. Not zero, it turns optional
71    /// ligatures off.
72    #[serde(default)]
73    pub letter_spacing: f32,
74    /// The weight, in `[1, 1000]`, a family's faces are matched by, as CSS
75    /// Fonts 4 matches `font-weight`; a variable face draws at it, clamped
76    /// to its `wght` axis. No face is emboldened: a weight the family lacks
77    /// draws its nearest face.
78    #[serde(default = "default_font_weight")]
79    pub font_weight: u16,
80    /// How lines align within the text.
81    #[serde(default)]
82    pub align: TextAlign,
83    /// Width text wraps at, as a fraction of the canvas width, in `(0, 1]`.
84    #[serde(default = "default_max_width")]
85    pub max_width: f32,
86    /// Where on the canvas the text's anchor lies, `(0, 0)` top-left to
87    /// `(1, 1)` bottom-right.
88    #[serde(default = "default_position")]
89    pub position: Vec2,
90    /// The point of the text, background included, placed at
91    /// `position`: `(0.5, 1)` is its bottom centre.
92    #[serde(default = "default_anchor")]
93    pub anchor: Vec2,
94    /// A band around the glyphs.
95    #[serde(default)]
96    pub outline: Option<TextOutline>,
97    /// A box behind each line.
98    #[serde(default)]
99    pub background: Option<TextBackground>,
100}
101
102impl Default for TextStyle {
103    fn default() -> Self {
104        Self {
105            font_family: None,
106            font_size: default_font_size(),
107            color: white(),
108            line_height: default_line_height(),
109            letter_spacing: 0.0,
110            font_weight: default_font_weight(),
111            align: TextAlign::default(),
112            max_width: default_max_width(),
113            position: default_position(),
114            anchor: default_anchor(),
115            outline: None,
116            background: None,
117        }
118    }
119}
120
121const fn default_font_size() -> f32 {
122    0.05
123}
124
125const fn white() -> Rgba {
126    Rgba::new(1.0, 1.0, 1.0, 1.0)
127}
128
129const fn default_line_height() -> f32 {
130    1.2
131}
132
133const fn default_font_weight() -> u16 {
134    400
135}
136
137const fn default_max_width() -> f32 {
138    0.9
139}
140
141const fn default_position() -> Vec2 {
142    Vec2::new(0.5, 0.92)
143}
144
145const fn default_anchor() -> Vec2 {
146    Vec2::new(0.5, 1.0)
147}
148
149/// How lines of text align within it.
150#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152#[serde(rename_all = "snake_case")]
153pub enum TextAlign {
154    /// Left.
155    Start,
156    /// Centred.
157    #[default]
158    Center,
159    /// Right.
160    End,
161}
162
163/// A band around the glyphs, drawn behind them.
164#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
165#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
166#[serde(deny_unknown_fields)]
167pub struct TextOutline {
168    /// Band color.
169    pub color: Rgba,
170    /// Band width as a fraction of the font size, in `(0, 0.5]`.
171    pub width: f32,
172}
173
174/// A box behind each line of text.
175#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
176#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
177#[serde(deny_unknown_fields)]
178pub struct TextBackground {
179    /// Box color.
180    pub color: Rgba,
181    /// Space between the text and the box edge, as a fraction of the font
182    /// size, in `[0, 2]`.
183    #[serde(default)]
184    pub padding: f32,
185}
186
187impl TextStyle {
188    /// Whether every field is in range, the font size at most
189    /// `max_font_size`.
190    pub(crate) fn is_valid(&self, max_font_size: f32) -> bool {
191        let finite = |value: Vec2| value.x.is_finite() && value.y.is_finite();
192        self.font_family
193            .as_ref()
194            .is_none_or(|family| !family.is_empty() && family.len() <= MAX_FONT_FAMILY_BYTES)
195            && self.font_size > 0.0
196            && self.font_size <= max_font_size
197            && self.color.is_valid()
198            && LINE_HEIGHT_RANGE.contains(&self.line_height)
199            && LETTER_SPACING_RANGE.contains(&self.letter_spacing)
200            && FONT_WEIGHT_RANGE.contains(&self.font_weight)
201            && self.max_width > 0.0
202            && self.max_width <= 1.0
203            && finite(self.position)
204            && finite(self.anchor)
205            && self.outline.is_none_or(|outline| {
206                outline.color.is_valid()
207                    && outline.width > 0.0
208                    && outline.width <= MAX_TEXT_OUTLINE_WIDTH
209            })
210            && self.background.is_none_or(|background| {
211                background.color.is_valid()
212                    && (0.0..=MAX_TEXT_PADDING).contains(&background.padding)
213            })
214    }
215}