Skip to main content

tellers_timeline_core/
text_styles.rs

1//! Shared CSS classes declared by a timeline for its rich-text clips.
2//!
3//! A Rich Text clip carries its look inline in its `Title HTML`, so a run of
4//! subtitles repeats the same `style="font-family:...;font-size:...;color:..."`
5//! on every clip. That makes the timeline large, and an agent writing
6//! subtitles has to emit (and pay for) the same declarations over and over.
7//!
8//! Text styles move those declarations out of the items: the timeline declares
9//! named CSS classes once, and a clip's HTML refers to them by class
10//! (`<div class="subtitle">Hello</div>`). The player (`video-player-js`)
11//! turns each entry into a `.name { declarations }` rule and injects it into
12//! the SVG it rasterizes the clip's HTML in, and embeds any custom font the
13//! declarations reference exactly as it does for inline `font-family`.
14//!
15//! They live at `metadata["tellers.ai"]["textStyles"]`, as an object mapping
16//! a class name to its CSS declaration block, at two levels:
17//!
18//! - on the timeline, for every text clip;
19//! - on a track, for that track's clips only. Track styles merge over the
20//!   timeline's by class name: a track entry replaces the timeline entry with
21//!   the same name, and timeline classes the track does not redefine still
22//!   apply. [`Timeline::get_track_text_styles`] returns that merged list.
23//!
24//! Fonts are not declared here: a class selects a family by name
25//! (`font-family:'Brand Sans'`) and the file itself stays in the timeline's
26//! `availableFonts` (see [`crate::fonts`]).
27//!
28//!
29//! ```json
30//! {
31//!   "subtitle": "font-family:'Brand Sans';font-size:56px;color:#fff",
32//!   "subtitle-accent": "color:#ffd400"
33//! }
34//! ```
35//!
36//! Reads drop entries the player itself would drop (a name that is not a
37//! plain CSS identifier, or a declaration block that is empty or contains a
38//! brace), and writes store the normalized (trimmed) form, so anything this
39//! module returns is a style the player will apply.
40
41use serde::{Deserialize, Serialize};
42
43use crate::{Timeline, Track};
44
45/// Metadata key, under the `tellers.ai` namespace, holding the style object.
46pub const TEXT_STYLES_KEY: &str = "textStyles";
47
48/// A CSS class declared by a timeline for use by its rich-text clips.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct TextStyle {
51    /// Class name, as referenced by `class="..."` in a clip's Title HTML.
52    /// A plain CSS identifier: `[A-Za-z_-][A-Za-z0-9_-]*`, not starting with
53    /// a digit or with `-` followed by a digit. Case-sensitive, like CSS.
54    pub name: String,
55    /// The CSS declaration block of the class, without the surrounding
56    /// braces: `font-size:56px;color:#fff`.
57    pub declarations: String,
58}
59
60impl TextStyle {
61    /// A style with the given class name and declaration block.
62    pub fn new(name: impl Into<String>, declarations: impl Into<String>) -> Self {
63        TextStyle {
64            name: name.into(),
65            declarations: declarations.into(),
66        }
67    }
68
69    /// This style with name and declarations trimmed. `None` when the player
70    /// would ignore the style: a name that is not a plain CSS identifier, or
71    /// a declaration block that is empty or contains `{` or `}`.
72    pub fn normalized(&self) -> Option<TextStyle> {
73        let name = self.name.trim();
74        let declarations = self.declarations.trim();
75        if !is_valid_text_style_name(name) || !is_valid_text_style_declarations(declarations) {
76            return None;
77        }
78        Some(TextStyle {
79            name: name.to_string(),
80            declarations: declarations.to_string(),
81        })
82    }
83
84    /// Whether this style would be kept by [`TextStyle::normalized`].
85    pub fn is_valid(&self) -> bool {
86        self.normalized().is_some()
87    }
88
89    /// The CSS rule the player injects for this style: `.name { declarations }`.
90    pub fn to_css_rule(&self) -> String {
91        format!(".{} {{ {} }}", self.name, self.declarations)
92    }
93}
94
95/// Whether `name` can be used unescaped in a class selector: an ASCII CSS
96/// identifier (`[A-Za-z_-][A-Za-z0-9_-]*`) that does not start with a digit
97/// or with `-` followed by a digit.
98pub fn is_valid_text_style_name(name: &str) -> bool {
99    let mut chars = name.chars().peekable();
100    let first = match chars.next() {
101        Some(c) => c,
102        None => return false,
103    };
104    let valid_first = match first {
105        '_' => true,
106        '-' => matches!(chars.peek(), Some(c) if c.is_ascii_alphabetic() || *c == '_' || *c == '-'),
107        c => c.is_ascii_alphabetic(),
108    };
109    valid_first && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
110}
111
112/// Whether `declarations` is a usable declaration block: non-empty, and free
113/// of the braces that would let it close the rule and inject other rules.
114pub fn is_valid_text_style_declarations(declarations: &str) -> bool {
115    !declarations.is_empty() && !declarations.contains('{') && !declarations.contains('}')
116}
117
118/// The stylesheet the player builds from `styles`: one `.name { ... }` rule per
119/// line, in the given order.
120pub fn text_styles_css(styles: &[TextStyle]) -> String {
121    styles
122        .iter()
123        .map(TextStyle::to_css_rule)
124        .collect::<Vec<_>>()
125        .join("\n")
126}
127
128/// The styles declared in `metadata["tellers.ai"]["textStyles"]`, normalized
129/// and with unusable entries skipped, in the object's key order.
130pub fn resolve_text_styles(metadata: &serde_json::Value) -> Vec<TextStyle> {
131    raw_text_styles(metadata)
132        .map(|entries| {
133            entries
134                .iter()
135                .filter_map(|(name, raw)| {
136                    let declarations = raw.as_str()?;
137                    TextStyle::new(name.as_str(), declarations).normalized()
138                })
139                .collect()
140        })
141        .unwrap_or_default()
142}
143
144/// Add `style` to `metadata["tellers.ai"]["textStyles"]`, creating the
145/// `tellers.ai` object and the style object as needed. An existing entry with
146/// the same name is replaced. Returns `false` — leaving the metadata untouched
147/// — when the style is one the player could not use (invalid name, or an
148/// empty or brace-containing declaration block).
149pub fn set_text_style(metadata: &mut serde_json::Value, style: TextStyle) -> bool {
150    let Some(style) = style.normalized() else {
151        return false;
152    };
153    text_styles_object_mut(metadata)
154        .insert(style.name, serde_json::Value::String(style.declarations));
155    true
156}
157
158/// Remove the style named `name` (trimmed, case-sensitive) from
159/// `metadata["tellers.ai"]["textStyles"]`, returning whether it was present.
160/// The object itself is dropped once it is empty.
161pub fn remove_text_style(metadata: &mut serde_json::Value, name: &str) -> bool {
162    let Some(ai) = metadata
163        .get_mut("tellers.ai")
164        .and_then(|value| value.as_object_mut())
165    else {
166        return false;
167    };
168    let Some(styles) = ai.get_mut(TEXT_STYLES_KEY).and_then(|v| v.as_object_mut()) else {
169        return false;
170    };
171
172    let removed = styles.remove(name.trim()).is_some();
173    if styles.is_empty() {
174        ai.remove(TEXT_STYLES_KEY);
175    }
176    removed
177}
178
179impl Timeline {
180    /// The CSS classes this timeline declares for its rich-text clips. Styles
181    /// the player would ignore are skipped.
182    pub fn get_text_styles(&self) -> Vec<TextStyle> {
183        resolve_text_styles(&self.metadata)
184    }
185
186    /// The declaration block of the class named `name`, if declared and usable.
187    pub fn get_text_style(&self, name: &str) -> Option<String> {
188        let wanted = name.trim();
189        self.get_text_styles()
190            .into_iter()
191            .find(|style| style.name == wanted)
192            .map(|style| style.declarations)
193    }
194
195    /// Declare (or redefine) the class `name` with `declarations` on this
196    /// timeline. Returns `false`, and changes nothing, when the name is not a
197    /// plain CSS identifier or the declarations are empty or contain a brace.
198    pub fn set_text_style(
199        &mut self,
200        name: impl Into<String>,
201        declarations: impl Into<String>,
202    ) -> bool {
203        set_text_style(&mut self.metadata, TextStyle::new(name, declarations))
204    }
205
206    /// Remove the declared class `name`, returning whether it was present.
207    pub fn remove_text_style(&mut self, name: &str) -> bool {
208        remove_text_style(&mut self.metadata, name)
209    }
210
211    /// The stylesheet the player injects for this timeline's text clips: one
212    /// `.name { declarations }` rule per declared style.
213    pub fn text_styles_css(&self) -> String {
214        text_styles_css(&self.get_text_styles())
215    }
216}
217
218/// `base` with `overrides` merged over it by class name: an override replaces
219/// the base entry in place, and override-only classes are appended in their
220/// own order. This is how a track's styles combine with the timeline's.
221pub fn merge_text_styles(base: &[TextStyle], overrides: &[TextStyle]) -> Vec<TextStyle> {
222    let mut merged: Vec<TextStyle> = base.to_vec();
223    for style in overrides {
224        match merged
225            .iter_mut()
226            .find(|existing| existing.name == style.name)
227        {
228            Some(existing) => existing.declarations = style.declarations.clone(),
229            None => merged.push(style.clone()),
230        }
231    }
232    merged
233}
234
235impl Track {
236    /// The CSS classes declared on this track only (not merged with the
237    /// timeline's). Styles the player would ignore are skipped.
238    pub fn get_text_styles(&self) -> Vec<TextStyle> {
239        resolve_text_styles(&self.metadata)
240    }
241
242    /// The declaration block of the class `name` declared on this track only.
243    pub fn get_text_style(&self, name: &str) -> Option<String> {
244        let wanted = name.trim();
245        self.get_text_styles()
246            .into_iter()
247            .find(|style| style.name == wanted)
248            .map(|style| style.declarations)
249    }
250
251    /// Declare (or redefine) the class `name` on this track, overriding a
252    /// timeline class of the same name for this track's clips. Returns
253    /// `false`, and changes nothing, when the style is unusable.
254    pub fn set_text_style(
255        &mut self,
256        name: impl Into<String>,
257        declarations: impl Into<String>,
258    ) -> bool {
259        set_text_style(&mut self.metadata, TextStyle::new(name, declarations))
260    }
261
262    /// Remove the class `name` from this track, returning whether it was
263    /// present. A timeline class of the same name applies again afterwards.
264    pub fn remove_text_style(&mut self, name: &str) -> bool {
265        remove_text_style(&mut self.metadata, name)
266    }
267}
268
269impl Timeline {
270    /// The styles that apply to the clips of the track `track_id`: the
271    /// timeline's styles with the track's merged over them by class name.
272    /// `None` when no track has this id.
273    pub fn get_track_text_styles(&self, track_id: &str) -> Option<Vec<TextStyle>> {
274        let (_, track) = self.tracks.get_track_by_id(track_id)?;
275        Some(merge_text_styles(
276            &self.get_text_styles(),
277            &track.get_text_styles(),
278        ))
279    }
280
281    /// The stylesheet the player injects for the clips of the track
282    /// `track_id` (merged timeline and track styles). `None` when no track has
283    /// this id.
284    pub fn track_text_styles_css(&self, track_id: &str) -> Option<String> {
285        self.get_track_text_styles(track_id)
286            .map(|styles| text_styles_css(&styles))
287    }
288
289    /// Declare (or redefine) the class `name` on the track `track_id`.
290    /// Returns `false`, changing nothing, when no track has this id or the
291    /// style is unusable.
292    pub fn set_track_text_style(
293        &mut self,
294        track_id: &str,
295        name: impl Into<String>,
296        declarations: impl Into<String>,
297    ) -> bool {
298        match self.track_mut_by_id(track_id) {
299            Some(track) => track.set_text_style(name, declarations),
300            None => false,
301        }
302    }
303
304    /// Remove the class `name` from the track `track_id`, returning whether it
305    /// was present there.
306    pub fn remove_track_text_style(&mut self, track_id: &str, name: &str) -> bool {
307        match self.track_mut_by_id(track_id) {
308            Some(track) => track.remove_text_style(name),
309            None => false,
310        }
311    }
312
313    fn track_mut_by_id(&mut self, track_id: &str) -> Option<&mut Track> {
314        let (index, _) = self.tracks.get_track_by_id(track_id)?;
315        self.tracks.children.get_mut(index)
316    }
317}
318
319fn raw_text_styles(
320    metadata: &serde_json::Value,
321) -> Option<&serde_json::Map<String, serde_json::Value>> {
322    metadata
323        .get("tellers.ai")?
324        .get(TEXT_STYLES_KEY)?
325        .as_object()
326}
327
328/// The style object at `metadata["tellers.ai"]["textStyles"]`, creating the
329/// `tellers.ai` object and the style object when they are missing or the
330/// wrong type.
331fn text_styles_object_mut(
332    metadata: &mut serde_json::Value,
333) -> &mut serde_json::Map<String, serde_json::Value> {
334    if metadata.as_object().is_none() {
335        *metadata = serde_json::Value::Object(serde_json::Map::new());
336    }
337    let map = metadata.as_object_mut().unwrap();
338    let ai = map
339        .entry("tellers.ai".to_string())
340        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
341    if ai.as_object().is_none() {
342        *ai = serde_json::Value::Object(serde_json::Map::new());
343    }
344    let ai_map = ai.as_object_mut().unwrap();
345    let styles = ai_map
346        .entry(TEXT_STYLES_KEY.to_string())
347        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
348    if styles.as_object().is_none() {
349        *styles = serde_json::Value::Object(serde_json::Map::new());
350    }
351    styles.as_object_mut().unwrap()
352}