1use std::path::{Path, PathBuf};
10
11use serde_json::{Map, Value};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Align {
16 Left,
17 #[default]
18 Center,
19 Right,
20}
21
22#[derive(Debug, Clone, PartialEq)]
23pub struct Shadow {
24 pub colour: [f32; 4],
25 pub offset: [f32; 2],
27 pub blur: f32,
29}
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct Outline {
33 pub colour: [f32; 4],
34 pub width: f32,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub struct BoxStyle {
41 pub colour: [f32; 4],
42 pub padding: f32,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct TextStyle {
49 pub font: Option<String>,
50 pub face: Option<String>,
51 pub font_file: Option<PathBuf>,
53 pub font_index: u32,
55 pub size: f32,
56 pub colour: [f32; 4],
57 pub highlight: Option<[f32; 4]>,
59 pub position: [f32; 2],
61 pub anchor: [f32; 2],
63 pub alignment: Align,
64 pub bold: bool,
65 pub italic: bool,
66 pub line_height: f32,
68 pub max_width: f32,
70 pub tracking: f32,
72 pub uppercase: bool,
73 pub shadow: Option<Shadow>,
74 pub outline: Option<Outline>,
75 pub box_: Option<BoxStyle>,
76 pub delay: f64,
78 pub fade_in: f64,
79 pub fade_out: f64,
80}
81
82impl TextStyle {
83 pub fn default_for(height: f32) -> Self {
85 Self {
86 font: None,
87 face: None,
88 font_file: None,
89 font_index: 0,
90 size: 0.05 * height,
91 colour: [1.0; 4],
92 highlight: None,
93 position: [0.0, 0.0],
94 anchor: [0.5, 0.5],
95 alignment: Align::Center,
96 bold: false,
97 italic: false,
98 line_height: 1.2,
99 max_width: 0.9,
100 tracking: 0.0,
101 uppercase: false,
102 shadow: None,
103 outline: None,
104 box_: None,
105 delay: 0.0,
106 fade_in: 0.0,
107 fade_out: 0.0,
108 }
109 }
110}
111
112pub fn merge(base: &mut Map<String, Value>, top: &Map<String, Value>) {
114 for (k, v) in top {
115 match (base.get_mut(k), v) {
116 (Some(Value::Object(b)), Value::Object(t)) => merge(b, t),
117 _ => {
118 base.insert(k.clone(), v.clone());
119 }
120 }
121 }
122}
123
124pub fn cascade(styles: &Map<String, Value>, params: &Map<String, Value>) -> Map<String, Value> {
126 let mut chain = Vec::new();
127 let mut name = params.get("style").and_then(Value::as_str);
128 while let Some(n) = name {
129 if n == "base" || chain.iter().any(|(c, _)| *c == n) || chain.len() > 16 {
130 break;
131 }
132 let Some(s) = styles.get(n).and_then(Value::as_object) else {
133 break;
134 };
135 chain.push((n, s));
136 name = s.get("based_on").and_then(Value::as_str);
137 }
138 let mut out = styles
139 .get("base")
140 .and_then(Value::as_object)
141 .cloned()
142 .unwrap_or_default();
143 for (_, s) in chain.iter().rev() {
144 merge(&mut out, s);
145 }
146 merge(&mut out, params);
147 out
148}
149
150fn f(v: Option<&Value>) -> Option<f32> {
151 #[allow(clippy::cast_possible_truncation)]
152 v.and_then(Value::as_f64).map(|x| x as f32)
153}
154
155fn vec2(v: Option<&Value>) -> Option<[f32; 2]> {
156 let a = v?.as_array()?;
157 Some([f(a.first())?, f(a.get(1))?])
158}
159
160pub fn colour(v: Option<&Value>) -> Option<[f32; 4]> {
162 let a = v?.as_array()?;
163 let mut c: Vec<f32> = a.iter().map(|x| f(Some(x))).collect::<Option<_>>()?;
164 if c.len() < 3 {
165 return None;
166 }
167 if c.iter().take(3).any(|x| *x > 1.0) {
168 for x in c.iter_mut().take(3) {
169 *x /= 255.0;
170 }
171 }
172 Some([c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0)])
173}
174
175fn bool_(v: Option<&Value>) -> Option<bool> {
176 v.and_then(|v| v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)))
177}
178
179fn length(v: Option<&Value>, height: f32, fractions: bool) -> Option<f32> {
182 let x = f(v)?;
183 Some(if fractions && x > 0.0 && x <= 1.0 {
184 x * height
185 } else {
186 x
187 })
188}
189
190fn fade(m: &Map<String, Value>, dir: &str, flat: &str) -> Option<f64> {
192 let a = m.get("animation").and_then(|a| a.get(dir));
193 if let Some(a) = a {
194 return a.get("duration").and_then(Value::as_f64);
196 }
197 m.get(flat).and_then(Value::as_f64)
198}
199
200#[allow(clippy::too_many_lines)] pub fn resolve(m: &Map<String, Value>, frame: [f32; 2], name: Option<&str>) -> TextStyle {
203 let [_, fh] = frame;
204 let mut s = TextStyle::default_for(fh);
205 s.font = m.get("font").and_then(Value::as_str).map(str::to_owned);
206 s.face = m.get("face").and_then(Value::as_str).map(str::to_owned);
207 let style_name = m
208 .get("style")
209 .and_then(Value::as_str)
210 .or(name)
211 .unwrap_or("");
212 let legacy = match style_name {
214 _ if m.contains_key("font_file") || m.contains_key("font") => None,
215 "card" => m.get("serif_card").or_else(|| m.get("serif")),
216 "location" => m.get("serif_sc"),
217 "title" => m.get("serif"),
218 "endcard" | "endscreen" => m.get("serif_italic"),
219 "caption" => m.get("sans"),
220 _ => None,
221 };
222 s.font_file = m
223 .get("font_file")
224 .or(legacy)
225 .and_then(Value::as_str)
226 .map(|p| {
227 let dir = m.get("fonts_dir").and_then(Value::as_str).unwrap_or("");
228 Path::new(dir).join(p)
229 });
230 #[allow(clippy::cast_possible_truncation)]
232 let index = m
233 .get("font_index")
234 .and_then(Value::as_u64)
235 .map(|i| i as u32)
236 .or((style_name == "caption" && legacy.is_some()).then_some(14));
237 s.font_index = index.unwrap_or(0);
238 if let Some(x) = length(m.get("size"), fh, true) {
239 s.size = x;
240 }
241 if let Some(c) = colour(m.get("colour").or_else(|| m.get("color"))) {
242 s.colour = c;
243 }
244 s.highlight = colour(m.get("highlight"));
245 if let Some(o) = f(m.get("opacity")) {
246 s.colour[3] *= o;
247 }
248 let (lx, ly) = (f(m.get("x")), f(m.get("y")));
252 if lx.is_some() || ly.is_some() {
253 let y = ly.unwrap_or(0.9) - 0.5;
254 (s.position, s.anchor, s.alignment) = match style_name {
255 "location" => ([0.5 - lx.unwrap_or(0.05), y], [1.0, 1.0], Align::Right),
256 "caption" | "subs" | "subtitle" => ([0.0, y], [0.5, 1.0], Align::Center),
257 _ if lx.is_none() => ([0.0, y], [0.5, 1.0], Align::Center),
258 _ => ([lx.unwrap_or(0.05) - 0.5, y], [0.0, 1.0], Align::Left),
259 };
260 }
261 if let Some(p) = vec2(m.get("position")) {
262 s.position = p;
263 }
264 if let Some(a) = vec2(m.get("anchor")) {
265 s.anchor = a;
266 }
267 match m.get("alignment").and_then(Value::as_str) {
268 Some("left") => s.alignment = Align::Left,
269 Some("right") => s.alignment = Align::Right,
270 Some("center" | "centre") => s.alignment = Align::Center,
271 _ => {}
272 }
273 s.bold = bool_(m.get("bold")).unwrap_or(false);
274 s.italic = bool_(m.get("italic")).unwrap_or(false);
275 s.uppercase = bool_(m.get("uppercase")).unwrap_or(false);
276 if let Some(x) = f(m.get("line_height").or_else(|| m.get("line_spacing"))) {
277 s.line_height = x;
278 }
279 if let Some(x) = f(m.get("max_width")) {
280 s.max_width = x;
281 }
282 if let Some(x) = f(m.get("tracking")) {
283 s.tracking = x;
284 }
285 s.shadow = match m.get("shadow") {
286 Some(Value::Object(o)) => Some(Shadow {
287 colour: colour(o.get("colour").or_else(|| o.get("color")))
288 .unwrap_or([0.0, 0.0, 0.0, 0.5]),
289 offset: vec2(o.get("offset")).unwrap_or([0.0, 0.002 * fh]),
290 blur: f(o.get("blur")).unwrap_or(0.006 * fh),
291 }),
292 Some(Value::Bool(false) | Value::Null) => None,
293 _ => f(m.get("shadow_opacity"))
294 .filter(|o| *o > 0.0)
295 .map(|o| Shadow {
296 colour: [0.0, 0.0, 0.0, o],
297 offset: [0.0, 0.002 * fh],
298 blur: length(m.get("shadow_blur"), fh, true).unwrap_or(0.006 * fh),
299 }),
300 };
301 s.outline = match m.get("outline") {
302 Some(Value::Object(o)) => Some(Outline {
303 colour: colour(o.get("colour").or_else(|| o.get("color")))
304 .unwrap_or([0.0, 0.0, 0.0, 1.0]),
305 width: f(o.get("width")).unwrap_or(0.002 * fh),
306 }),
307 _ => None,
308 };
309 s.box_ = match m.get("box") {
310 Some(Value::Object(o)) => Some(BoxStyle {
311 colour: colour(o.get("colour").or_else(|| o.get("color")))
312 .unwrap_or([0.0, 0.0, 0.0, 0.4]),
313 padding: f(o.get("padding")).unwrap_or(0.25 * s.size),
314 }),
315 _ => f(m.get("pill_opacity").or_else(|| m.get("scrim")))
316 .filter(|o| *o > 0.0)
317 .map(|o| BoxStyle {
318 colour: [0.0, 0.0, 0.0, o],
319 padding: 0.25 * s.size,
320 }),
321 };
322 s.delay = m.get("delay").and_then(Value::as_f64).unwrap_or(0.0);
323 s.fade_in = fade(m, "in", "fade_in").unwrap_or(0.0);
324 s.fade_out = fade(m, "out", "fade_out").unwrap_or(0.0);
325 s
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use serde_json::json;
332
333 #[allow(clippy::needless_pass_by_value)]
334 fn obj(v: Value) -> Map<String, Value> {
335 v.as_object().cloned().unwrap_or_default()
336 }
337
338 #[test]
339 fn cascade_order_and_based_on() {
340 let styles = obj(json!({
341 "base": {"font": "Inter", "colour": [1, 1, 1, 1], "size": 40, "shadow": {"blur": 3}},
342 "card": {"based_on": "place", "size": 80},
343 "place": {"alignment": "left", "font": "Cormorant", "shadow": {"offset": [0, 2]}},
344 }));
345 let m = cascade(
346 &styles,
347 &obj(json!({"style": "card", "text": "Arrival", "bold": true})),
348 );
349 assert_eq!(m["font"], "Cormorant");
350 assert_eq!(m["size"], 80);
351 assert_eq!(m["alignment"], "left");
352 assert_eq!(m["shadow"], json!({"blur": 3, "offset": [0, 2]}));
353 let s = resolve(&m, [3840.0, 2160.0], None);
354 assert!(s.bold);
355 assert!((s.size - 80.0).abs() < 1e-6);
356 assert_eq!(s.alignment, Align::Left);
357 assert_eq!(s.shadow.map(|s| s.offset), Some([0.0, 2.0]));
358 }
359
360 #[test]
361 fn prototype_keys() {
362 let m = obj(json!({
363 "style": "card", "size": 0.10, "x": 0.055, "y": 0.175, "fade_in": 0.7,
364 "fade_out": 0.6, "color": [250, 246, 238], "shadow_opacity": 0.55, "scrim": 0.34,
365 }));
366 let s = resolve(&m, [1920.0, 1080.0], None);
367 assert!((s.size - 108.0).abs() < 1e-3);
368 assert_eq!(s.anchor, [0.0, 1.0]);
369 assert!((s.position[0] - (0.055 - 0.5)).abs() < 1e-6);
370 assert!((s.colour[0] - 250.0 / 255.0).abs() < 1e-6);
371 assert!(s.shadow.is_some() && s.box_.is_some());
372 assert!((s.fade_in - 0.7).abs() < 1e-9);
373 }
374
375 #[test]
376 fn animation_fade_wins() {
377 let m =
378 obj(json!({"fade_in": 1.0, "animation": {"in": {"kind": "fade", "duration": 0.25}}}));
379 let s = resolve(&m, [1920.0, 1080.0], None);
380 assert!((s.fade_in - 0.25).abs() < 1e-9);
381 }
382}