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}