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}