use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Align {
Left,
#[default]
Center,
Right,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Shadow {
pub colour: [f32; 4],
pub offset: [f32; 2],
pub blur: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Outline {
pub colour: [f32; 4],
pub width: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BoxStyle {
pub colour: [f32; 4],
pub padding: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextStyle {
pub font: Option<String>,
pub face: Option<String>,
pub font_file: Option<PathBuf>,
pub font_index: u32,
pub size: f32,
pub colour: [f32; 4],
pub highlight: Option<[f32; 4]>,
pub position: [f32; 2],
pub anchor: [f32; 2],
pub alignment: Align,
pub bold: bool,
pub italic: bool,
pub line_height: f32,
pub max_width: f32,
pub tracking: f32,
pub uppercase: bool,
pub shadow: Option<Shadow>,
pub outline: Option<Outline>,
pub box_: Option<BoxStyle>,
pub delay: f64,
pub fade_in: f64,
pub fade_out: f64,
}
impl TextStyle {
pub fn default_for(height: f32) -> Self {
Self {
font: None,
face: None,
font_file: None,
font_index: 0,
size: 0.05 * height,
colour: [1.0; 4],
highlight: None,
position: [0.0, 0.0],
anchor: [0.5, 0.5],
alignment: Align::Center,
bold: false,
italic: false,
line_height: 1.2,
max_width: 0.9,
tracking: 0.0,
uppercase: false,
shadow: None,
outline: None,
box_: None,
delay: 0.0,
fade_in: 0.0,
fade_out: 0.0,
}
}
}
pub fn merge(base: &mut Map<String, Value>, top: &Map<String, Value>) {
for (k, v) in top {
match (base.get_mut(k), v) {
(Some(Value::Object(b)), Value::Object(t)) => merge(b, t),
_ => {
base.insert(k.clone(), v.clone());
}
}
}
}
pub fn cascade(styles: &Map<String, Value>, params: &Map<String, Value>) -> Map<String, Value> {
let mut chain = Vec::new();
let mut name = params.get("style").and_then(Value::as_str);
while let Some(n) = name {
if n == "base" || chain.iter().any(|(c, _)| *c == n) || chain.len() > 16 {
break;
}
let Some(s) = styles.get(n).and_then(Value::as_object) else {
break;
};
chain.push((n, s));
name = s.get("based_on").and_then(Value::as_str);
}
let mut out = styles
.get("base")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for (_, s) in chain.iter().rev() {
merge(&mut out, s);
}
merge(&mut out, params);
out
}
fn f(v: Option<&Value>) -> Option<f32> {
#[allow(clippy::cast_possible_truncation)]
v.and_then(Value::as_f64).map(|x| x as f32)
}
fn vec2(v: Option<&Value>) -> Option<[f32; 2]> {
let a = v?.as_array()?;
Some([f(a.first())?, f(a.get(1))?])
}
pub fn colour(v: Option<&Value>) -> Option<[f32; 4]> {
let a = v?.as_array()?;
let mut c: Vec<f32> = a.iter().map(|x| f(Some(x))).collect::<Option<_>>()?;
if c.len() < 3 {
return None;
}
if c.iter().take(3).any(|x| *x > 1.0) {
for x in c.iter_mut().take(3) {
*x /= 255.0;
}
}
Some([c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0)])
}
fn bool_(v: Option<&Value>) -> Option<bool> {
v.and_then(|v| v.as_bool().or_else(|| v.as_f64().map(|x| x != 0.0)))
}
fn length(v: Option<&Value>, height: f32, fractions: bool) -> Option<f32> {
let x = f(v)?;
Some(if fractions && x > 0.0 && x <= 1.0 {
x * height
} else {
x
})
}
fn fade(m: &Map<String, Value>, dir: &str, flat: &str) -> Option<f64> {
let a = m.get("animation").and_then(|a| a.get(dir));
if let Some(a) = a {
return a.get("duration").and_then(Value::as_f64);
}
m.get(flat).and_then(Value::as_f64)
}
#[allow(clippy::too_many_lines)] pub fn resolve(m: &Map<String, Value>, frame: [f32; 2], name: Option<&str>) -> TextStyle {
let [_, fh] = frame;
let mut s = TextStyle::default_for(fh);
s.font = m.get("font").and_then(Value::as_str).map(str::to_owned);
s.face = m.get("face").and_then(Value::as_str).map(str::to_owned);
let style_name = m
.get("style")
.and_then(Value::as_str)
.or(name)
.unwrap_or("");
let legacy = match style_name {
_ if m.contains_key("font_file") || m.contains_key("font") => None,
"card" => m.get("serif_card").or_else(|| m.get("serif")),
"location" => m.get("serif_sc"),
"title" => m.get("serif"),
"endcard" | "endscreen" => m.get("serif_italic"),
"caption" => m.get("sans"),
_ => None,
};
s.font_file = m
.get("font_file")
.or(legacy)
.and_then(Value::as_str)
.map(|p| {
let dir = m.get("fonts_dir").and_then(Value::as_str).unwrap_or("");
Path::new(dir).join(p)
});
#[allow(clippy::cast_possible_truncation)]
let index = m
.get("font_index")
.and_then(Value::as_u64)
.map(|i| i as u32)
.or((style_name == "caption" && legacy.is_some()).then_some(14));
s.font_index = index.unwrap_or(0);
if let Some(x) = length(m.get("size"), fh, true) {
s.size = x;
}
if let Some(c) = colour(m.get("colour").or_else(|| m.get("color"))) {
s.colour = c;
}
s.highlight = colour(m.get("highlight"));
if let Some(o) = f(m.get("opacity")) {
s.colour[3] *= o;
}
let (lx, ly) = (f(m.get("x")), f(m.get("y")));
if lx.is_some() || ly.is_some() {
let y = ly.unwrap_or(0.9) - 0.5;
(s.position, s.anchor, s.alignment) = match style_name {
"location" => ([0.5 - lx.unwrap_or(0.05), y], [1.0, 1.0], Align::Right),
"caption" | "subs" | "subtitle" => ([0.0, y], [0.5, 1.0], Align::Center),
_ if lx.is_none() => ([0.0, y], [0.5, 1.0], Align::Center),
_ => ([lx.unwrap_or(0.05) - 0.5, y], [0.0, 1.0], Align::Left),
};
}
if let Some(p) = vec2(m.get("position")) {
s.position = p;
}
if let Some(a) = vec2(m.get("anchor")) {
s.anchor = a;
}
match m.get("alignment").and_then(Value::as_str) {
Some("left") => s.alignment = Align::Left,
Some("right") => s.alignment = Align::Right,
Some("center" | "centre") => s.alignment = Align::Center,
_ => {}
}
s.bold = bool_(m.get("bold")).unwrap_or(false);
s.italic = bool_(m.get("italic")).unwrap_or(false);
s.uppercase = bool_(m.get("uppercase")).unwrap_or(false);
if let Some(x) = f(m.get("line_height").or_else(|| m.get("line_spacing"))) {
s.line_height = x;
}
if let Some(x) = f(m.get("max_width")) {
s.max_width = x;
}
if let Some(x) = f(m.get("tracking")) {
s.tracking = x;
}
s.shadow = match m.get("shadow") {
Some(Value::Object(o)) => Some(Shadow {
colour: colour(o.get("colour").or_else(|| o.get("color")))
.unwrap_or([0.0, 0.0, 0.0, 0.5]),
offset: vec2(o.get("offset")).unwrap_or([0.0, 0.002 * fh]),
blur: f(o.get("blur")).unwrap_or(0.006 * fh),
}),
Some(Value::Bool(false) | Value::Null) => None,
_ => f(m.get("shadow_opacity"))
.filter(|o| *o > 0.0)
.map(|o| Shadow {
colour: [0.0, 0.0, 0.0, o],
offset: [0.0, 0.002 * fh],
blur: length(m.get("shadow_blur"), fh, true).unwrap_or(0.006 * fh),
}),
};
s.outline = match m.get("outline") {
Some(Value::Object(o)) => Some(Outline {
colour: colour(o.get("colour").or_else(|| o.get("color")))
.unwrap_or([0.0, 0.0, 0.0, 1.0]),
width: f(o.get("width")).unwrap_or(0.002 * fh),
}),
_ => None,
};
s.box_ = match m.get("box") {
Some(Value::Object(o)) => Some(BoxStyle {
colour: colour(o.get("colour").or_else(|| o.get("color")))
.unwrap_or([0.0, 0.0, 0.0, 0.4]),
padding: f(o.get("padding")).unwrap_or(0.25 * s.size),
}),
_ => f(m.get("pill_opacity").or_else(|| m.get("scrim")))
.filter(|o| *o > 0.0)
.map(|o| BoxStyle {
colour: [0.0, 0.0, 0.0, o],
padding: 0.25 * s.size,
}),
};
s.delay = m.get("delay").and_then(Value::as_f64).unwrap_or(0.0);
s.fade_in = fade(m, "in", "fade_in").unwrap_or(0.0);
s.fade_out = fade(m, "out", "fade_out").unwrap_or(0.0);
s
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[allow(clippy::needless_pass_by_value)]
fn obj(v: Value) -> Map<String, Value> {
v.as_object().cloned().unwrap_or_default()
}
#[test]
fn cascade_order_and_based_on() {
let styles = obj(json!({
"base": {"font": "Inter", "colour": [1, 1, 1, 1], "size": 40, "shadow": {"blur": 3}},
"card": {"based_on": "place", "size": 80},
"place": {"alignment": "left", "font": "Cormorant", "shadow": {"offset": [0, 2]}},
}));
let m = cascade(
&styles,
&obj(json!({"style": "card", "text": "Arrival", "bold": true})),
);
assert_eq!(m["font"], "Cormorant");
assert_eq!(m["size"], 80);
assert_eq!(m["alignment"], "left");
assert_eq!(m["shadow"], json!({"blur": 3, "offset": [0, 2]}));
let s = resolve(&m, [3840.0, 2160.0], None);
assert!(s.bold);
assert!((s.size - 80.0).abs() < 1e-6);
assert_eq!(s.alignment, Align::Left);
assert_eq!(s.shadow.map(|s| s.offset), Some([0.0, 2.0]));
}
#[test]
fn prototype_keys() {
let m = obj(json!({
"style": "card", "size": 0.10, "x": 0.055, "y": 0.175, "fade_in": 0.7,
"fade_out": 0.6, "color": [250, 246, 238], "shadow_opacity": 0.55, "scrim": 0.34,
}));
let s = resolve(&m, [1920.0, 1080.0], None);
assert!((s.size - 108.0).abs() < 1e-3);
assert_eq!(s.anchor, [0.0, 1.0]);
assert!((s.position[0] - (0.055 - 0.5)).abs() < 1e-6);
assert!((s.colour[0] - 250.0 / 255.0).abs() < 1e-6);
assert!(s.shadow.is_some() && s.box_.is_some());
assert!((s.fade_in - 0.7).abs() < 1e-9);
}
#[test]
fn animation_fade_wins() {
let m =
obj(json!({"fade_in": 1.0, "animation": {"in": {"kind": "fade", "duration": 0.25}}}));
let s = resolve(&m, [1920.0, 1080.0], None);
assert!((s.fade_in - 0.25).abs() < 1e-9);
}
}