use serde::{Deserialize, Serialize};
use crate::Timeline;
pub const AVAILABLE_FONTS_KEY: &str = "availableFonts";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FontFace {
pub family: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weight: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
}
impl FontFace {
pub fn new(family: impl Into<String>, url: impl Into<String>) -> Self {
FontFace {
family: family.into(),
url: url.into(),
weight: None,
style: None,
}
}
pub fn with_weight(mut self, weight: impl Into<String>) -> Self {
self.weight = Some(weight.into());
self
}
pub fn with_style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
pub fn normalized(&self) -> Option<FontFace> {
let family = self.family.trim();
let url = self.url.trim();
if family.is_empty() || !is_supported_font_url(url) {
return None;
}
Some(FontFace {
family: family.to_string(),
url: url.to_string(),
weight: self
.weight
.as_deref()
.and_then(|w| normalize_font_weight(&serde_json::Value::String(w.to_string()))),
style: self.style.as_deref().and_then(normalize_font_style),
})
}
pub fn is_valid(&self) -> bool {
self.normalized().is_some()
}
}
pub fn resolve_available_fonts(metadata: &serde_json::Value) -> Vec<FontFace> {
raw_available_fonts(metadata)
.map(|entries| entries.iter().filter_map(font_from_json).collect())
.unwrap_or_default()
}
pub fn add_available_font(metadata: &mut serde_json::Value, font: FontFace) -> bool {
let Some(font) = font.normalized() else {
return false;
};
let entry = serde_json::to_value(&font).expect("FontFace serializes to JSON");
let fonts = available_fonts_array_mut(metadata);
let existing = fonts
.iter()
.position(|raw| font_from_json(raw).is_some_and(|other| same_font_face(&other, &font)));
match existing {
Some(index) => fonts[index] = entry,
None => fonts.push(entry),
}
true
}
pub fn remove_available_font(metadata: &mut serde_json::Value, family: &str) -> bool {
let Some(ai) = metadata
.get_mut("tellers.ai")
.and_then(|value| value.as_object_mut())
else {
return false;
};
let Some(fonts) = ai
.get_mut(AVAILABLE_FONTS_KEY)
.and_then(|v| v.as_array_mut())
else {
return false;
};
let wanted = family.trim().to_lowercase();
let before = fonts.len();
fonts.retain(|raw| {
raw.get("family")
.and_then(|value| value.as_str())
.map(|value| value.trim().to_lowercase() != wanted)
.unwrap_or(true)
});
let removed = fonts.len() != before;
if fonts.is_empty() {
ai.remove(AVAILABLE_FONTS_KEY);
}
removed
}
impl Timeline {
pub fn get_fonts(&self) -> Vec<FontFace> {
resolve_available_fonts(&self.metadata)
}
pub fn add_font(&mut self, font: FontFace) -> bool {
add_available_font(&mut self.metadata, font)
}
pub fn remove_font(&mut self, family: &str) -> bool {
remove_available_font(&mut self.metadata, family)
}
}
fn same_font_face(left: &FontFace, right: &FontFace) -> bool {
left.family.eq_ignore_ascii_case(&right.family)
&& left.weight == right.weight
&& left.style == right.style
}
fn raw_available_fonts(metadata: &serde_json::Value) -> Option<&Vec<serde_json::Value>> {
metadata
.get("tellers.ai")?
.get(AVAILABLE_FONTS_KEY)?
.as_array()
}
fn available_fonts_array_mut(metadata: &mut serde_json::Value) -> &mut Vec<serde_json::Value> {
if metadata.as_object().is_none() {
*metadata = serde_json::Value::Object(serde_json::Map::new());
}
let map = metadata.as_object_mut().unwrap();
let ai = map
.entry("tellers.ai".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if ai.as_object().is_none() {
*ai = serde_json::Value::Object(serde_json::Map::new());
}
let ai_map = ai.as_object_mut().unwrap();
let fonts = ai_map
.entry(AVAILABLE_FONTS_KEY.to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if fonts.as_array().is_none() {
*fonts = serde_json::Value::Array(Vec::new());
}
fonts.as_array_mut().unwrap()
}
fn font_from_json(raw: &serde_json::Value) -> Option<FontFace> {
let family = raw.get("family")?.as_str()?.trim();
let url = raw.get("url")?.as_str()?.trim();
if family.is_empty() || !is_supported_font_url(url) {
return None;
}
Some(FontFace {
family: family.to_string(),
url: url.to_string(),
weight: raw.get("weight").and_then(normalize_font_weight),
style: raw
.get("style")
.and_then(|value| value.as_str())
.and_then(normalize_font_style),
})
}
fn is_supported_font_url(url: &str) -> bool {
let lower = url.to_ascii_lowercase();
lower.starts_with("https://") || lower.starts_with("http://") || lower.starts_with("data:")
}
fn normalize_font_weight(value: &serde_json::Value) -> Option<String> {
let raw = match value {
serde_json::Value::String(text) => text.trim().to_string(),
serde_json::Value::Number(number) => {
let as_f64 = number.as_f64()?;
if as_f64.fract() != 0.0 {
return None;
}
(as_f64 as i64).to_string()
}
_ => return None,
};
if raw == "normal" || raw == "bold" {
return Some(raw);
}
let steps: Vec<&str> = raw.split_whitespace().collect();
if steps.is_empty() || steps.len() > 2 || !steps.iter().all(|step| is_weight_step(step)) {
return None;
}
Some(steps.join(" "))
}
fn is_weight_step(step: &str) -> bool {
let bytes = step.as_bytes();
bytes.len() == 3 && bytes[0].is_ascii_digit() && bytes[0] != b'0' && &bytes[1..] == b"00"
}
fn normalize_font_style(style: &str) -> Option<String> {
match style.trim() {
style @ ("normal" | "italic" | "oblique") => Some(style.to_string()),
_ => None,
}
}