//! Slideshow templates (Lightroom Classic's Template Browser): a named snapshot of how a //! slideshow looks and plays — the Playback settings, the slide's Layout / Options / Backdrop, //! the Overlays and the Titles — as one JSON document with a format version. //! //! Built-in templates are our own designs ([`builtins`], JSON files beside this module). User //! templates are files in the settings folder, `/slideshow-templates/.json` //! ([`Session::slide_templates_dir`]); they are shared by every library on the machine, like //! Lightroom's templates. Every section is read through the settings' own `merged()` (so a //! hand-edited or hostile file can never set a value the panels couldn't), files are capped in //! size and number, a file that can't be used is reported and skipped, and file names are made //! from the template's name so they can never leave the templates folder. //! //! Applying a template replaces the four settings in one step and saves them with the library. //! Library preferences have no undo, so the settings it replaced are kept for the session as //! "Previous settings" ([`PREVIOUS`]): applying that puts them back (and applying it again goes //! forward). "Prepare previews" is a machine preference, not part of a look: templates neither //! save nor change it. use std::path::{Path, PathBuf}; use std::sync::Arc; use serde_json::{Map, Value, json}; use crate::Session; use crate::slide::SlideSettings; use crate::slide_overlay::OverlaySettings; use crate::slide_title::TitleSettings; use crate::slideshow::PlaybackSettings; /// The `format` of a template document. pub const FORMAT: &str = "lightcraft.slideshowTemplate"; /// The newest document version this build reads (and the one it writes). pub const VERSION: u64 = 1; /// Largest template file read (bytes). pub const MAX_FILE: u64 = 256 * 1024; /// Most user templates listed (the rest of a folder that holds more are reported, not read). pub const MAX_TEMPLATES: usize = 1000; /// Directory entries looked at before giving up on a huge folder. const MAX_SCAN: usize = 20_000; /// Longest template name (characters). pub const NAME_MAX: usize = 80; /// Longest description (characters). pub const DESCRIPTION_MAX: usize = 500; /// Longest file stem made from a name (characters). const STEM_MAX: usize = 64; /// The folder under the settings folder user templates live in. pub const DIR_NAME: &str = "slideshow-templates"; /// Id prefix of built-in templates. pub const BUILTIN: &str = "builtin:"; /// Id prefix of user templates (followed by the file stem). pub const USER: &str = "user:"; /// Id of the settings before the last template was applied. pub const PREVIOUS: &str = "previous"; /// Everything a template sets. #[derive(Clone, Debug, Default, PartialEq)] pub struct SlideLook { pub playback: PlaybackSettings, pub layout: SlideSettings, pub overlays: OverlaySettings, pub titles: TitleSettings, } impl SlideLook { /// The library's current slideshow settings. pub fn of(s: &Session) -> SlideLook { SlideLook { playback: s.slideshow.clone(), layout: s.slide_layout.clone(), overlays: s.slide_overlays.clone(), titles: s.slide_titles.clone(), } } /// The four sections as JSON (playback without `preparePreviews`, which isn't part of a look). pub fn sections(&self) -> Map { let mut playback = self.playback.to_json(); if let Some(o) = playback.as_object_mut() { o.remove("preparePreviews"); } let mut m = Map::new(); m.insert("playback".into(), playback); m.insert("layout".into(), self.layout.to_json()); m.insert("overlays".into(), self.overlays.to_json()); m.insert("titles".into(), self.titles.to_json()); m } /// Read the sections of a template document: each one merged onto the defaults by the /// settings' own validation (a missing section is the defaults; a wrong type, an unknown key /// or a non-finite number is an error). fn from_sections(doc: &Map) -> Result { fn section(doc: &Map, key: &str, merge: impl Fn(&Value) -> Result, default: T) -> Result { match doc.get(key) { None | Some(Value::Null) => Ok(default), Some(v @ Value::Object(_)) => merge(v).map_err(|e| format!("{key}: {e}")), Some(v) => Err(format!("`{key}` must be an object, not {v}")), } } let mut playback = section(doc, "playback", |v| PlaybackSettings::default().merged(v), PlaybackSettings::default())?; playback.prepare_previews = PlaybackSettings::default().prepare_previews; Ok(SlideLook { playback, layout: section(doc, "layout", |v| SlideSettings::default().merged(v), SlideSettings::default())?, overlays: section(doc, "overlays", |v| OverlaySettings::default().merged(v), OverlaySettings::default())?, titles: section(doc, "titles", |v| TitleSettings::default().merged(v), TitleSettings::default())?, }) } /// What doesn't work with this look in `s`'s library: a background photo that isn't in it, /// an identity plate or watermark image file that can't be read, unknown tokens, a title /// screen with nothing to show. The look still applies; these draw nothing. pub fn warnings(&self, s: &Session) -> Vec { let mut out = Vec::new(); if let Some(id) = self.layout.background_image && s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none() { out.push(format!("the background image (photo {id}) isn't in this library; the backdrop colour shows instead")); } out.extend(self.overlays.warnings()); let w = &self.overlays.watermark; if self.overlays.show_watermark && !w.image.trim().is_empty() && crate::export::watermark_logo(w.image.trim()).is_none() { out.push(format!("watermark image {} can't be read; nothing is drawn", w.image.trim())); } for t in self.titles.warnings(&self.overlays) { if !out.contains(&t) { out.push(t); } } out } } /// A template: built in or a user's file. #[derive(Clone, Debug, PartialEq)] pub struct Template { /// `builtin:` or `user:`. pub id: String, pub name: String, pub description: String, pub builtin: bool, /// The file a user template is read from. pub path: Option, pub look: SlideLook, } impl Template { /// The template as a document (what a user template's file holds, and what export writes). pub fn to_doc(&self) -> Value { doc(&self.name, &self.description, &self.look) } /// The template for listings (no settings). pub fn info(&self) -> Value { let mut v = json!({ "id": self.id, "name": self.name, "builtin": self.builtin, "group": if self.builtin { "builtin" } else { "user" }, }); if !self.description.is_empty() { v["description"] = json!(self.description); } if let Some(p) = &self.path { v["path"] = json!(p.to_string_lossy()); } v } } /// A template document for `look`. pub fn doc(name: &str, description: &str, look: &SlideLook) -> Value { let mut m = Map::new(); m.insert("format".into(), json!(FORMAT)); m.insert("version".into(), json!(VERSION)); m.insert("name".into(), json!(name)); if !description.is_empty() { m.insert("description".into(), json!(description)); } m.extend(look.sections()); Value::Object(m) } /// A template document read and checked: its name (`None` when it has none), description and look. #[derive(Clone, Debug, PartialEq)] pub struct Parsed { pub name: Option, pub description: String, pub look: SlideLook, } /// Read a template document. Refused: more than [`MAX_FILE`] bytes, not JSON, another format, a /// version from a newer LightCraft, a bad name, unknown keys and any setting its section's /// validation refuses. pub fn parse(bytes: &[u8]) -> Result { if bytes.len() as u64 > MAX_FILE { return Err(format!("{} bytes is too large for a slideshow template (at most {} KB)", bytes.len(), MAX_FILE / 1024)); } let text = std::str::from_utf8(bytes).map_err(|_| "not a slideshow template (not UTF-8 text)".to_string())?; let text = text.trim_start_matches('\u{feff}'); let v: Value = serde_json::from_str(text).map_err(|e| format!("not a slideshow template: {e}"))?; let Value::Object(o) = v else { return Err("not a slideshow template (not a JSON object)".into()) }; match o.get("format").and_then(Value::as_str) { Some(FORMAT) => {} Some(other) => return Err(format!("not a slideshow template (format `{other}`, expected `{FORMAT}`)")), None => return Err(format!("not a slideshow template (no `format`: \"{FORMAT}\")")), } match o.get("version") { Some(v) => match v.as_u64() { Some(n) if n > VERSION => { return Err(format!( "made by a newer LightCraft (template version {n}; this one reads version {VERSION}): update LightCraft to use it" )); } Some(n) if n >= 1 => {} _ => return Err(format!("`version` must be a whole number from 1, not {v}")), }, None => return Err("no `version`".into()), } for k in o.keys() { if !matches!(k.as_str(), "format" | "version" | "name" | "description" | "playback" | "layout" | "overlays" | "titles") { return Err(format!("unknown key `{k}` (format, version, name, description, playback, layout, overlays, titles)")); } } let name = match o.get("name") { None | Some(Value::Null) => None, Some(Value::String(n)) if n.trim().is_empty() => None, Some(Value::String(n)) => Some(valid_name(n)?), Some(v) => return Err(format!("`name` must be text, not {v}")), }; let description = match o.get("description") { None | Some(Value::Null) => String::new(), Some(Value::String(d)) => clean_text(d, DESCRIPTION_MAX), Some(v) => return Err(format!("`description` must be text, not {v}")), }; Ok(Parsed { name, description, look: SlideLook::from_sections(&o)? }) } /// `s` without control characters, trimmed, at most `max` characters. fn clean_text(s: &str, max: usize) -> String { s.chars().filter(|c| !c.is_control()).take(max).collect::().trim().to_string() } /// A template name as stored: trimmed, 1–[`NAME_MAX`] characters, no control characters. pub fn valid_name(name: &str) -> Result { let n = name.trim(); if n.is_empty() { return Err("a template needs a name".into()); } if n.chars().any(char::is_control) { return Err("a template name can't contain control characters (line breaks, tabs…)".into()); } if n.chars().count() > NAME_MAX { return Err(format!("a template name has at most {NAME_MAX} characters")); } Ok(n.to_string()) } /// The file stem a template called `name` is saved as: letters, digits, spaces, `-`, `_`, `(` /// and `)` kept, anything else (path separators, dots, `:`, `*`…) `_`; trimmed; at most 64 /// characters; Windows' reserved device names get a `_`. `None` when nothing usable is left. pub fn file_stem(name: &str) -> Option { let s: String = name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '(' | ')') { c } else { '_' }).take(STEM_MAX).collect(); let s = s.trim().to_string(); if s.is_empty() || s.chars().all(|c| c == '_') { return None; } let upper = s.to_ascii_uppercase(); let reserved = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") || ((upper.starts_with("COM") || upper.starts_with("LPT")) && upper.len() == 4 && upper.ends_with(|c: char| c.is_ascii_digit())); Some(if reserved { format!("{s}_") } else { s }) } /// The stem of a user template id, when it is one this module could have made (so it can't /// name a file outside the templates folder). fn user_stem(id: &str) -> Option<&str> { let stem = id.strip_prefix(USER)?; (file_stem(stem).as_deref() == Some(stem)).then_some(stem) } /// The built-in templates: our own designs (no Lightroom template, name or design copied). const BUILTINS: &[(&str, &str)] = &[ ("plain-black", include_str!("slideshow_templates/plain-black.json")), ("gallery-wall", include_str!("slideshow_templates/gallery-wall.json")), ("edge-to-edge", include_str!("slideshow_templates/edge-to-edge.json")), ("cinema-wide", include_str!("slideshow_templates/cinema-wide.json")), ("shooting-data", include_str!("slideshow_templates/shooting-data.json")), ]; /// The built-in templates (one that doesn't parse — a bug the tests catch — is left out). pub fn builtins() -> Vec