//! Variables (data merge): named values bound to objects, with datasets that apply //! them all at once. One template document produces many variants (badges, price //! lists, localized copies) by selecting a dataset instead of editing each object. use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; use super::{Document, LockedArtboards, NodeId, NodeKind, TextKind}; /// What a variable drives. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum VariableKind { /// A type object's characters. #[default] Text, /// Whether bound objects show. Visibility, /// A linked image's file path (the pixels swap; the object's bounds stay). Image, /// A graph object's data (CSV of categories and series, as Graph Data uses). Graph, /// A variable a file declares without a kind (or with one VectorCraft doesn't know): it drives /// nothing, no value fits it, and nothing can be bound to it. Unknown, } impl VariableKind { /// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted /// as the same thing, as the panel and the docs call it both), `image` or `graph`. A variable /// of [`Self::Unknown`] kind is never defined this way. pub fn parse(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { "text" => Some(Self::Text), "visibility" | "visible" => Some(Self::Visibility), "image" => Some(Self::Image), "graph" => Some(Self::Graph), _ => None, } } /// The kind's own name, as `variable.define` takes it and `variable.list` reports it. pub fn label(self) -> &'static str { match self { Self::Text => "text", Self::Visibility => "visibility", Self::Image => "image", Self::Graph => "graph", Self::Unknown => "unknown", } } } /// One named variable. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Variable { pub name: String, pub kind: VariableKind, } /// One value a dataset gives a variable. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DataValue { Text(String), Visible(bool), /// Absolute path of the image file an image variable should show. Image(String), /// CSV of a graph's categories and series (same shape as Graph Data / `graph.setData`). Graph(String), } impl DataValue { /// Whether this value fits the variable's kind (a dataset may carry values for /// variables of either kind; each binding takes what fits it). pub fn fits(&self, kind: VariableKind) -> bool { matches!( (self, kind), (Self::Text(_), VariableKind::Text) | (Self::Visible(_), VariableKind::Visibility) | (Self::Image(_), VariableKind::Image) | (Self::Graph(_), VariableKind::Graph) ) } } /// One named row of values, applied with `dataset.select`. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct DataSet { pub name: String, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub values: BTreeMap, } impl DataSet { /// Whether the art, as [`Document::captured_values`] read it, still holds what this row /// says. A value the art can't report (its object is gone, or is locked or in a locked layer /// or group and the variable isn't a visibility variable) isn't drift: the row keeps it, and /// applying it again would skip it too. pub fn matches(&self, current: &BTreeMap) -> bool { self.values.iter().all(|(k, v)| current.get(k).is_none_or(|c| c == v)) } } /// The variable state of a document: definitions, datasets, bindings /// (object id → the variables bound to it) and which dataset is active. /// /// An object may be driven by several variables, so a binding is a set. `variable.bind` and Make /// Text Dynamic refuse a second text variable on one type object, but a document saved before /// they did may hold two: `dataset.select` applies the text values in the order the variables /// are defined, so the last one wins, and `applied` counts both. Of two visibility variables on /// one object, the last one defined wins too. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Variables { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub variables: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub datasets: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub bindings: BTreeMap>, #[serde(default, skip_serializing_if = "Option::is_none")] pub active_dataset: Option, /// Lock Variables: while set, variables can't be defined, deleted or renamed. Objects can /// still be bound to them and unbound, and datasets still change and apply, except that an /// import whose header would define a variable is refused. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub locked: bool, } impl Variables { /// The variable `name`, if the document defines it. pub fn variable(&self, name: &str) -> Option<&Variable> { self.variables.iter().find(|v| v.name == name) } /// The dataset `name`, if the document has it. pub fn dataset(&self, name: &str) -> Option<&DataSet> { self.datasets.iter().find(|d| d.name == name) } /// Bind `id` to the variable `name`. True when it was not bound to it already. pub fn bind(&mut self, id: NodeId, name: &str) -> bool { self.bindings.entry(id).or_default().insert(name.to_string()) } /// Drop `id`'s binding to `name` (and the binding itself when that was its last variable). /// True when there was one. pub fn unbind(&mut self, id: NodeId, name: &str) -> bool { let Some(bound) = self.bindings.get_mut(&id) else { return false }; let dropped = bound.remove(name); if bound.is_empty() { self.bindings.remove(&id); } dropped } /// The objects bound to the variable `name`, in document order. pub fn objects_of(&self, name: &str) -> Vec { let mut ids: Vec = self.bindings.iter().filter(|(_, v)| v.contains(name)).map(|(id, _)| *id).collect(); ids.sort_unstable(); ids } /// Drop the variables `names`, the dataset values that name them and the bindings to /// them. Datasets named here are left alone: their names are their own namespace (the /// active one is dropped by whoever deletes the dataset). pub fn prune(&mut self, names: &[String]) -> usize { // A set, so a long list of names costs a lookup per value rather than a scan. let names: BTreeSet<&str> = names.iter().map(String::as_str).collect(); let before = self.variables.len(); self.variables.retain(|v| !names.contains(v.name.as_str())); for dataset in &mut self.datasets { dataset.values.retain(|k, _| !names.contains(k.as_str())); } for bound in self.bindings.values_mut() { bound.retain(|v| !names.contains(v.as_str())); } self.bindings.retain(|_, v| !v.is_empty()); before - self.variables.len() } } impl Document { /// Forget the bindings of objects that are no longer in the document (after every edit), /// like [`Document::prune_assets`] does for Asset Export's. A binding outliving its object /// would count as a skip in every dataset application and grow the saved file forever. pub fn prune_variable_bindings(&mut self) { if self.variables.bindings.keys().all(|id| self.node(*id).is_some()) { return; } let mut bindings = std::mem::take(&mut self.variables.bindings); bindings.retain(|id, _| self.node(*id).is_some()); self.variables.bindings = bindings; } /// Whether a variable of `kind` may be bound to the object `id`: it is in the document, /// nothing above it is hidden, it is not on a locked artboard ([`Document::on_locked_artboard`]), /// and, unless `kind` is visibility, neither it nor anything above it is locked. Its own /// visibility doesn't count, as that is what a visibility variable drives. pub fn variable_target(&self, id: NodeId, kind: VariableKind) -> bool { self.variable_target_with(&self.locked_artboards(), id, kind) } /// [`Document::variable_target`] with the locked artboards `locks` of this document, measured /// once for many ids. pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool { let locking = kind != VariableKind::Visibility; self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked) && (n.visible || *i == id)))) && !locks.holds_id(id) } /// Whether a data set reads and rewrites the bound object `id` for a variable of `kind`: it /// is in the document, it is not on a locked artboard ([`Document::on_locked_artboard`]) and, /// unless `kind` is visibility, neither it nor anything above it is locked. Hidden objects /// count, and so do objects in a hidden layer or group. pub fn dataset_target(&self, id: NodeId, kind: VariableKind) -> bool { self.dataset_target_with(&self.locked_artboards(), id, kind) } /// [`Document::dataset_target`] with the locked artboards `locks` of this document, measured /// once for many ids. pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> bool { let locking = kind != VariableKind::Visibility; self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !(locking && n.locked)))) && !locks.holds_id(id) } /// The type objects of the story that the type object `id` belongs to, in thread order: the /// frames of its threaded text (area type and type on a path), else `id` alone. pub fn story_of(&self, id: NodeId) -> Vec { let frame = |f: &NodeId| matches!(self.node(*f).map(|n| &n.kind), Some(NodeKind::Text(t)) if !matches!(t.kind, TextKind::Point)); match self.text_threads.iter().find(|thread| thread.contains(&id)) { Some(thread) if frame(&id) => thread.iter().filter(|f| frame(f)).copied().collect(), _ => vec![id], } } /// The characters of the story that the type object `id` belongs to ([`Document::story_of`]): /// every frame's text joined in thread order. pub fn story_text(&self, id: NodeId) -> String { self.story_of(id) .into_iter() .filter_map(|f| match self.node(f).map(|n| &n.kind) { Some(NodeKind::Text(t)) => Some(t.plain_text()), _ => None, }) .collect() } /// Whether a data set reads and rewrites the text of the type object `id`: every frame of its /// story ([`Document::story_of`]) is a [`Document::dataset_target`]. pub fn story_target(&self, id: NodeId) -> bool { self.story_target_with(&self.locked_artboards(), id) } /// [`Document::story_target`] with the locked artboards `locks` of this document, measured /// once for many ids. pub fn story_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f, VariableKind::Text)) } /// What the object `id` holds for the variable of `kind`: its characters (the whole story /// for a frame of threaded text), whether it shows, or a linked image's path. `None` when the /// object is gone, is not of that kind, has no link path yet (an embedded image), or, unless /// `kind` is visibility, is locked or in a locked layer or group, or is on a locked artboard, /// or for text when a frame of its story is (the document's value is whatever it was left at). pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option { self.variable_value_with(&self.locked_artboards(), id, kind) } /// [`Document::variable_value`] with the locked artboards `locks` of this document. fn variable_value_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> Option { if !self.dataset_target_with(locks, id, kind) { return None; } let node = self.node(id)?; match (kind, &node.kind) { (VariableKind::Text, NodeKind::Text(_)) if self.story_target_with(locks, id) => Some(DataValue::Text(self.story_text(id))), (VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)), (VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())), (VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())), (VariableKind::Text | VariableKind::Image | VariableKind::Unknown, _) => None, } } /// The values a dataset captured right now: what each bound object holds, in the order the /// variables are defined. `dataset.capture` writes these into a new row and /// `dataset.update` into the active one, for the variables that row holds (`all: true` adds /// the rest); the Variables panel compares the active row with them to tell whether the art /// still matches what it says. pub fn captured_values(&self) -> BTreeMap { let mut out = BTreeMap::new(); let locks = self.locked_artboards(); for v in &self.variables.variables { // An object driven by several variables of the same kind reads from the first one // bound, so a capture is one value per variable and applies back the same way. let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value_with(&locks, id, v.kind)); if let Some(value) = value { out.insert(v.name.clone(), value); } } out } }