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}