From 79fd79c2ae818311ed93721d160779e8e5c32e9f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 16:43:22 -0400 Subject: [PATCH] Variables and Data Sets: data-driven graphics (Image > Variables, data-set import/export) Clean-room implementation of Photoshop's data-driven graphics, headless and scriptable for CLI/MCP batch generation: - doc: Variables model (visibility/text/pixel-replacement bindings + named data sets), threaded through .pcraft (manifest + convert). - engine variables_cmds.rs: image.variables.define, image.variables.dataSets, image.applyDataSet (visibility/text/pixel replace w/ fit/fill/conform/as-is + 9 alignments, one history step), file.import.variableDataSets (CSV), file.export.dataSetsAsFiles (apply each set and export), variables.list. - Pixel replacement loads+scales+aligns an image into the layer bounds via the existing resample helpers. - 7 tests (doc round-trip + lookups; engine define/apply, CSV import, export, pixel replace, bad params). Parity 600 -> 605; FLOOR 600. Co-Authored-By: Claude Opus 4.8 --- crates/doc/src/lib.rs | 5 + crates/doc/src/variables.rs | 192 ++++++++++ crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/variables_cmds.rs | 406 ++++++++++++++++++++++ crates/engine/src/variables_cmds/tests.rs | 154 ++++++++ crates/format/src/convert.rs | 2 + crates/format/src/manifest.rs | 3 + crates/ui-egui/src/parity.rs | 2 +- docs/parity.md | 15 +- 10 files changed, 770 insertions(+), 11 deletions(-) create mode 100644 crates/doc/src/variables.rs create mode 100644 crates/engine/src/variables_cmds.rs create mode 100644 crates/engine/src/variables_cmds/tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 7a61fe22..54f225dc 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -14,6 +14,7 @@ pub mod effects; pub mod mode; pub mod pattern; pub mod slices; +pub mod variables; pub mod text; pub mod text_styles; pub mod vector; @@ -33,6 +34,7 @@ pub use photocraft_geom::{Affine, Rect, Size}; pub use photocraft_raster::Surface; pub use mode::{ColorTable, Duotone, DuotoneInk, StackMode}; pub use pattern::Pattern; +pub use variables::{DataSet, DataValue, PixelAlign, PixelMethod, VarKind, VariableDef, Variables}; pub use slices::{Slice, SliceKind, SliceOrigin, Slices}; pub use text_styles::TextStyles; pub use vector::{ @@ -609,6 +611,8 @@ pub struct Document { pub text_styles: TextStyles, /// Web slices (Slice tool, layer-based slices; PSD resource 1050). Auto slices are derived. pub slices: Slices, + /// Image › Variables and Data Sets (data-driven graphics). + pub variables: Variables, } /// Where a layer lives in the tree: indices from the root down. @@ -644,6 +648,7 @@ impl Document { notes: Vec::new(), text_styles: TextStyles::default(), slices: Slices::default(), + variables: Variables::default(), } } diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs new file mode 100644 index 00000000..83f0e5f5 --- /dev/null +++ b/crates/doc/src/variables.rs @@ -0,0 +1,192 @@ +//! Data-driven graphics: Image › Variables and Data Sets. +//! +//! A *variable* binds a named data slot to one layer, driving its visibility, its text, or its +//! pixel content. A *data set* is a named row of values, one per variable. Applying a data set +//! mutates the document so a single template renders many variations — ideal for batch generation +//! (`file.export.dataSetsAsFiles`) and agents driving the CLI/MCP. +//! +//! Pure data (the doc crate takes no I/O): loading a replacement image for a Pixel Replacement +//! variable is the engine's job; here a data value just names the file. + +use serde::{Deserialize, Serialize}; + +use crate::LayerId; + +/// All variable definitions and data sets for a document. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Variables { + /// Variable definitions, each bound to a layer. + #[serde(default)] + pub defs: Vec, + /// Named data sets (rows of values). + #[serde(default)] + pub data_sets: Vec, + /// Index into `data_sets` of the data set applied last, if any. + #[serde(default)] + pub active: Option, +} + +/// One named variable bound to a layer. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct VariableDef { + /// Unique variable name (matched by data-set values and CSV headers). + pub name: String, + /// The layer this variable drives. + pub layer: LayerId, + /// What it controls. + pub kind: VarKind, +} + +/// The aspect of a layer a variable controls (Photoshop's three variable types). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum VarKind { + /// Show or hide the layer. + Visibility, + /// Replace a text layer's string. + TextReplacement, + /// Replace a pixel layer's content with an image file, scaled and aligned. + PixelReplacement { + #[serde(default)] + method: PixelMethod, + #[serde(default)] + align: PixelAlign, + /// Clip the replacement to the original layer's bounds. + #[serde(default)] + clip: bool, + }, +} + +/// How a replacement image is scaled into the layer bounds (Photoshop's "Method"). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PixelMethod { + /// Scale to fit inside the bounds, keeping aspect (default). + #[default] + Fit, + /// Scale to cover the bounds, keeping aspect. + Fill, + /// Place at original size. + AsIs, + /// Stretch to the bounds, ignoring aspect. + Conform, +} + +/// Where a replacement image sits within the layer bounds. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PixelAlign { + TopLeft, + TopCenter, + TopRight, + CenterLeft, + #[default] + Center, + CenterRight, + BottomLeft, + BottomCenter, + BottomRight, +} + +/// A named row of values, one per variable (keyed by variable name). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct DataSet { + pub name: String, + /// Variable name → value. A variable with no entry keeps the document's current value. + #[serde(default)] + pub values: Vec, +} + +/// One variable's value within a data set. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DataValue { + /// Which variable (by name). + pub variable: String, + #[serde(flatten)] + pub value: Value, +} + +/// The payload of a [`DataValue`], matched to the variable's [`VarKind`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "camelCase")] +pub enum Value { + /// For a Visibility variable. + Visibility(bool), + /// For a Text Replacement variable. + Text(String), + /// For a Pixel Replacement variable: a path to the replacement image. + Pixels(String), +} + +impl Variables { + /// True when there are no definitions or data sets. + pub fn is_empty(&self) -> bool { + self.defs.is_empty() && self.data_sets.is_empty() + } + + /// Find a definition by variable name. + pub fn def(&self, name: &str) -> Option<&VariableDef> { + self.defs.iter().find(|d| d.name == name) + } + + /// Find a data set by name. + pub fn data_set(&self, name: &str) -> Option<&DataSet> { + self.data_sets.iter().find(|s| s.name == name) + } + + /// Index of a data set by name. + pub fn data_set_index(&self, name: &str) -> Option { + self.data_sets.iter().position(|s| s.name == name) + } +} + +impl DataSet { + /// The value for a variable in this data set, if present. + pub fn get(&self, variable: &str) -> Option<&Value> { + self.values.iter().find(|v| v.variable == variable).map(|v| &v.value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_through_json() { + let v = Variables { + defs: vec![ + VariableDef { name: "show".into(), layer: LayerId(1), kind: VarKind::Visibility }, + VariableDef { name: "title".into(), layer: LayerId(2), kind: VarKind::TextReplacement }, + VariableDef { + name: "photo".into(), + layer: LayerId(3), + kind: VarKind::PixelReplacement { method: PixelMethod::Fill, align: PixelAlign::TopLeft, clip: true }, + }, + ], + data_sets: vec![DataSet { + name: "row1".into(), + values: vec![ + DataValue { variable: "show".into(), value: Value::Visibility(false) }, + DataValue { variable: "title".into(), value: Value::Text("Hello".into()) }, + DataValue { variable: "photo".into(), value: Value::Pixels("/tmp/a.png".into()) }, + ], + }], + active: Some(0), + }; + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), v); + } + + #[test] + fn lookups() { + let v = Variables { + defs: vec![VariableDef { name: "t".into(), layer: LayerId(9), kind: VarKind::TextReplacement }], + data_sets: vec![DataSet { name: "s".into(), values: vec![DataValue { variable: "t".into(), value: Value::Text("x".into()) }] }], + active: None, + }; + assert_eq!(v.def("t").unwrap().layer, LayerId(9)); + assert_eq!(v.data_set_index("s"), Some(0)); + assert!(matches!(v.data_set("s").unwrap().get("t"), Some(Value::Text(t)) if t == "x")); + assert!(v.def("missing").is_none()); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 584fe6ef..a8409203 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -836,6 +836,7 @@ fn build() -> Vec { v.extend(crate::automate_cmds::specs()); v.extend(crate::print_cmds::specs()); v.extend(crate::pick_cmds::specs()); + v.extend(crate::variables_cmds::specs()); v } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5c688233..29acc63d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -55,6 +55,7 @@ pub mod vp_cmds; pub mod artboard_cmds; pub mod comps_cmds; pub mod automate_cmds; +mod variables_cmds; pub mod pick_cmds; pub mod print_cmds; pub mod slice_cmds; diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs new file mode 100644 index 00000000..8a9af427 --- /dev/null +++ b/crates/engine/src/variables_cmds.rs @@ -0,0 +1,406 @@ +//! Image › Variables and Data Sets, and the File menu's data-set import/export — Photoshop's +//! data-driven graphics. Define variables that bind a layer's visibility, text or pixels to named +//! data slots, then apply a data set (a row of values) to render a variation. Everything is +//! headless and scriptable, so an agent can bind a template once and batch-render many files over +//! the CLI/MCP (`file.export.dataSetsAsFiles`). + +use std::sync::Arc; + +use photocraft_algo::resample::{Resample, resize_surface, translate_surface}; +use photocraft_doc::{ + DataSet, DataValue, Document, LayerContent, LayerId, PixelAlign, PixelMethod, VarKind, VariableDef, + Variables, +}; +use photocraft_doc::variables::Value as VarValue; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document".into()) +} + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +// --------------------------------------------------------------------------- parse helpers + +fn parse_kind(v: &Value) -> Option { + match v.get("type").and_then(Value::as_str)? { + "visibility" => Some(VarKind::Visibility), + "textReplacement" | "text" => Some(VarKind::TextReplacement), + "pixelReplacement" | "pixel" => { + let method = match v.get("method").and_then(Value::as_str) { + Some("fill") => PixelMethod::Fill, + Some("asIs") => PixelMethod::AsIs, + Some("conform") => PixelMethod::Conform, + _ => PixelMethod::Fit, + }; + let align = match v.get("align").and_then(Value::as_str) { + Some("topLeft") => PixelAlign::TopLeft, + Some("topCenter") => PixelAlign::TopCenter, + Some("topRight") => PixelAlign::TopRight, + Some("centerLeft") => PixelAlign::CenterLeft, + Some("centerRight") => PixelAlign::CenterRight, + Some("bottomLeft") => PixelAlign::BottomLeft, + Some("bottomCenter") => PixelAlign::BottomCenter, + Some("bottomRight") => PixelAlign::BottomRight, + _ => PixelAlign::Center, + }; + let clip = v.get("clip").and_then(Value::as_bool).unwrap_or(false); + Some(VarKind::PixelReplacement { method, align, clip }) + } + _ => None, + } +} + +fn kind_json(k: &VarKind) -> Value { + match k { + VarKind::Visibility => json!({"type": "visibility"}), + VarKind::TextReplacement => json!({"type": "textReplacement"}), + VarKind::PixelReplacement { method, align, clip } => { + let a = format!("{align:?}"); + let align = a[..1].to_lowercase() + &a[1..]; + json!({ + "type": "pixelReplacement", + "method": format!("{method:?}").to_lowercase(), + "align": align, + "clip": clip, + }) + } + } +} + +fn value_json(v: &VarValue) -> Value { + match v { + VarValue::Visibility(b) => json!({"kind": "visibility", "value": b}), + VarValue::Text(t) => json!({"kind": "text", "value": t}), + VarValue::Pixels(p) => json!({"kind": "pixels", "value": p}), + } +} + +fn parse_value(v: &Value) -> Option { + let var = v.get("variable").and_then(Value::as_str)?.to_string(); + let _ = var; // the caller keeps the name + match v.get("kind").and_then(Value::as_str) { + Some("visibility") => Some(VarValue::Visibility(v.get("value").and_then(Value::as_bool)?)), + Some("text") => Some(VarValue::Text(v.get("value").and_then(Value::as_str)?.to_string())), + Some("pixels") => Some(VarValue::Pixels(v.get("value").and_then(Value::as_str)?.to_string())), + _ => None, + } +} + +/// Mutate the document's variables without a history step (config, not a canvas edit). +fn set_vars(s: &mut Session, f: impl FnOnce(&mut Variables)) -> Result<()> { + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let mut doc = (*st.doc).clone(); + f(&mut doc.variables); + st.doc = Arc::new(doc); + st.revision += 1; + Ok(()) +} + +// --------------------------------------------------------------------------- commands + +fn define(s: &mut Session, p: &Value) -> Result { + let defs_in = p.get("defs").and_then(Value::as_array).ok_or_else(|| bad("image.variables.define", "need `defs`"))?; + let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let mut defs = Vec::with_capacity(defs_in.len()); + for d in defs_in { + let name = d.get("name").and_then(Value::as_str).ok_or_else(|| bad("image.variables.define", "a def needs `name`"))?; + let layer = LayerId(d.get("layer").and_then(Value::as_u64).ok_or_else(|| bad("image.variables.define", "a def needs `layer`"))?); + if doc.layer(layer).is_none() { + return Err(EngineError::NoLayer(layer)); + } + let kind = parse_kind(d).ok_or_else(|| bad("image.variables.define", "a def needs a valid `type`"))?; + if defs.iter().any(|e: &VariableDef| e.name == name) { + return Err(bad("image.variables.define", format!("duplicate variable `{name}`"))); + } + defs.push(VariableDef { name: name.to_string(), layer, kind }); + } + set_vars(s, |v| v.defs = defs)?; + list(s) +} + +fn data_sets(s: &mut Session, p: &Value) -> Result { + let rows = p.get("dataSets").and_then(Value::as_array).ok_or_else(|| bad("image.variables.dataSets", "need `dataSets`"))?; + let mut sets = Vec::with_capacity(rows.len()); + for r in rows { + let name = r.get("name").and_then(Value::as_str).ok_or_else(|| bad("image.variables.dataSets", "a data set needs `name`"))?; + let mut values = Vec::new(); + for v in r.get("values").and_then(Value::as_array).into_iter().flatten() { + let variable = v.get("variable").and_then(Value::as_str).ok_or_else(|| bad("image.variables.dataSets", "a value needs `variable`"))?; + let value = parse_value(v).ok_or_else(|| bad("image.variables.dataSets", "a value needs `kind` and `value`"))?; + values.push(DataValue { variable: variable.to_string(), value }); + } + sets.push(DataSet { name: name.to_string(), values }); + } + let append = p.get("append").and_then(Value::as_bool).unwrap_or(false); + set_vars(s, |v| { + if append { + v.data_sets.extend(sets); + } else { + v.data_sets = sets; + } + })?; + list(s) +} + +/// Apply one data set's values to the document (visibility, text, pixels). A history step. +fn apply_to_doc(doc: &mut Document, vars: &Variables, set: &DataSet) -> Result<()> { + for dv in &set.values { + let Some(def) = vars.def(&dv.variable) else { continue }; + match (&def.kind, &dv.value) { + (VarKind::Visibility, VarValue::Visibility(show)) => { + if let Some(l) = doc.layer_mut(def.layer) { + l.visible = *show; + } + } + (VarKind::TextReplacement, VarValue::Text(text)) => { + if let Some(l) = doc.layer_mut(def.layer) { + if let LayerContent::Text(t) = &mut l.content { + t.text = text.clone(); + t.cache = None; // force re-render + t.runs.clear(); // re-flow as one run from the summary style + t.paragraphs.clear(); + } + } + } + (VarKind::PixelReplacement { method, align, clip }, VarValue::Pixels(path)) => { + replace_pixels(doc, def.layer, path, *method, *align, *clip)?; + } + _ => {} // value type doesn't match the variable kind: skip + } + } + Ok(()) +} + +/// Replace a pixel layer's content with an image file, scaled (method) and aligned. +fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelMethod, align: PixelAlign, clip: bool) -> Result<()> { + let target = doc.layer(layer).and_then(|l| l.surface()).map(|s| s.content_bounds()).filter(|r| !r.is_empty()).unwrap_or_else(|| doc.bounds()); + if target.is_empty() { + return Ok(()); + } + let fmt = doc.pixel_format(); + let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; + let src = photocraft_io::import(path, &bytes).map_err(|e| EngineError::Other(format!("`{path}`: {e}")))?.document; + let img = crate::file_cmds::flattened(&src, fmt); + let (iw, ih) = (src.size.width.max(1) as f64, src.size.height.max(1) as f64); + let (tw, th) = (target.width() as f64, target.height() as f64); + let (sx, sy) = match method { + PixelMethod::Fit => { + let k = (tw / iw).min(th / ih); + (k, k) + } + PixelMethod::Fill => { + let k = (tw / iw).max(th / ih); + (k, k) + } + PixelMethod::Conform => (tw / iw, th / ih), + PixelMethod::AsIs => (1.0, 1.0), + }; + let scaled = if (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9 { + resize_surface(&img, sx, sy, Resample::Bicubic) + } else { + img + }; + let (sw, sh) = (iw * sx, ih * sy); + // Aligned placement within the target rect. + let fx = match align { + PixelAlign::TopLeft | PixelAlign::CenterLeft | PixelAlign::BottomLeft => 0.0, + PixelAlign::TopRight | PixelAlign::CenterRight | PixelAlign::BottomRight => 1.0, + _ => 0.5, + }; + let fy = match align { + PixelAlign::TopLeft | PixelAlign::TopCenter | PixelAlign::TopRight => 0.0, + PixelAlign::BottomLeft | PixelAlign::BottomCenter | PixelAlign::BottomRight => 1.0, + _ => 0.5, + }; + let dx = (target.x0 as f64 + (tw - sw) * fx).round() as i32; + let dy = (target.y0 as f64 + (th - sh) * fy).round() as i32; + let placed = translate_surface(&scaled, dx, dy); + let placed = if clip { photocraft_algo::resample::crop_surface(&placed, target) } else { placed }; + if let Some(l) = doc.layer_mut(layer) { + if let LayerContent::Raster(sfc) = &mut l.content { + *sfc = placed; + } + } + Ok(()) +} + +fn apply_data_set(s: &mut Session, p: &Value) -> Result { + let (set, idx) = resolve_set(s, p, "image.applyDataSet")?; + let vars = s.active().ok_or(EngineError::NoDocument)?.doc.variables.clone(); + s.edit(&format!("Apply Data Set \"{}\"", set.name), |doc, _| apply_to_doc(doc, &vars, &set))?; + set_vars(s, |v| v.active = Some(idx))?; + Ok(json!({"applied": set.name, "index": idx})) +} + +/// Resolve a data set from `{name}` or `{index}`. +fn resolve_set(s: &Session, p: &Value, cmd: &str) -> Result<(DataSet, usize)> { + let vars = &s.active().ok_or(EngineError::NoDocument)?.doc.variables; + if let Some(name) = p.get("name").and_then(Value::as_str) { + let i = vars.data_set_index(name).ok_or_else(|| bad(cmd, format!("no data set `{name}`")))?; + Ok((vars.data_sets[i].clone(), i)) + } else if let Some(i) = p.get("index").and_then(Value::as_u64) { + let i = i as usize; + vars.data_sets.get(i).map(|d| (d.clone(), i)).ok_or_else(|| bad(cmd, format!("no data set at index {i}"))) + } else { + Err(bad(cmd, "need `name` or `index`")) + } +} + +/// Import data sets from a CSV: the header row holds variable names, each later row is a data set. +/// A value is a boolean (true/false) for a Visibility variable, a path for a Pixel Replacement +/// variable, else text. The first column may be a data-set name (header empty or `DataSet`). +fn import_data_sets(s: &mut Session, p: &Value) -> Result { + let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad("file.import.variableDataSets", "need `path`"))?; + let delim = p.get("delimiter").and_then(Value::as_str).and_then(|d| d.bytes().next()).unwrap_or(b','); + let text = std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; + let vars = s.active().ok_or(EngineError::NoDocument)?.doc.variables.clone(); + let mut lines = text.lines().filter(|l| !l.trim().is_empty()); + let header: Vec = lines.next().map(|h| split_csv(h, delim)).unwrap_or_default(); + if header.is_empty() { + return Err(bad("file.import.variableDataSets", "empty CSV")); + } + let named = header[0].trim().is_empty() || header[0].eq_ignore_ascii_case("dataset"); + let mut sets = Vec::new(); + for (row, line) in lines.enumerate() { + let cells = split_csv(line, delim); + let name = if named { + cells.first().cloned().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| format!("Data Set {}", row + 1)) + } else { + format!("Data Set {}", row + 1) + }; + let start = if named { 1 } else { 0 }; + let mut values = Vec::new(); + for (ci, var) in header.iter().enumerate().skip(start) { + let Some(cell) = cells.get(ci) else { continue }; + let Some(def) = vars.def(var) else { continue }; + let value = match def.kind { + VarKind::Visibility => VarValue::Visibility(matches!(cell.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "visible" | "on")), + VarKind::PixelReplacement { .. } => VarValue::Pixels(cell.clone()), + VarKind::TextReplacement => VarValue::Text(cell.clone()), + }; + values.push(DataValue { variable: var.clone(), value }); + } + sets.push(DataSet { name, values }); + } + let n = sets.len(); + set_vars(s, |v| v.data_sets = sets)?; + Ok(json!({"imported": n})) +} + +/// Minimal CSV field splitter with double-quote support. +fn split_csv(line: &str, delim: u8) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_q = false; + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if in_q { + if c == b'"' { + if i + 1 < bytes.len() && bytes[i + 1] == b'"' { + cur.push('"'); + i += 1; + } else { + in_q = false; + } + } else { + cur.push(c as char); + } + } else if c == b'"' { + in_q = true; + } else if c == delim { + out.push(std::mem::take(&mut cur)); + } else { + cur.push(c as char); + } + i += 1; + } + out.push(cur); + out +} + +/// Apply each data set in turn and export the flattened document, restoring the original after. +/// `{dir, format?("png"), dataSets?[names]}` → `{files:[…]}`. +fn export_as_files(s: &mut Session, p: &Value) -> Result { + let dir = p.get("dir").and_then(Value::as_str).ok_or_else(|| bad("file.export.dataSetsAsFiles", "need `dir`"))?; + let format = p.get("format").and_then(Value::as_str).unwrap_or("png"); + std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("mkdir `{dir}`: {e}")))?; + let st = s.active().ok_or(EngineError::NoDocument)?; + let original = st.doc.clone(); + let vars = original.variables.clone(); + let wanted: Option> = p.get("dataSets").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); + let sets: Vec = vars.data_sets.iter().filter(|d| wanted.as_ref().is_none_or(|w| w.contains(&d.name))).cloned().collect(); + if sets.is_empty() { + return Err(bad("file.export.dataSetsAsFiles", "no data sets to export")); + } + let mut files = Vec::new(); + for set in &sets { + let mut doc = (*original).clone(); + apply_to_doc(&mut doc, &vars, set)?; + let safe: String = set.name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect(); + let path = format!("{dir}/{safe}.{format}"); + let opts = photocraft_io::ExportOptions::default(); + let bytes = photocraft_io::export(&doc, format, &opts).map(|r| r.bytes).map_err(|e| EngineError::Other(format!("export `{}`: {e}", set.name)))?; + std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("write `{path}`: {e}")))?; + files.push(path); + } + Ok(json!({"files": files, "count": files.len()})) +} + +fn list(s: &mut Session) -> Result { + let v = &s.active().ok_or(EngineError::NoDocument)?.doc.variables; + Ok(json!({ + "defs": v.defs.iter().map(|d| { + let mut o = json!({"name": d.name, "layer": d.layer.0}); + o.as_object_mut().unwrap().extend(kind_json(&d.kind).as_object().unwrap().clone()); + o + }).collect::>(), + "dataSets": v.data_sets.iter().map(|ds| json!({ + "name": ds.name, + "values": ds.values.iter().map(|dv| { + let mut o = json!({"variable": dv.variable}); + o.as_object_mut().unwrap().extend(value_json(&dv.value).as_object().unwrap().clone()); + o + }).collect::>(), + })).collect::>(), + "active": v.active, + })) +} + +macro_rules! spec { + ($id:expr, $label:expr, $menu:expr, $params:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: $menu, shortcut: None, params: $params, enabled: has_doc, journal: true, run: $run } + }; +} + +pub fn specs() -> Vec { + vec![ + spec!("image.variables.define", "Define…", &["Image", "Variables"], + "{defs:[{name, layer:id, type:visibility|textReplacement|pixelReplacement, method?:fit|fill|asIs|conform, align?, clip?}]} → {defs,dataSets,active}", + |s, p| define(s, &p)), + spec!("image.variables.dataSets", "Data Sets…", &["Image", "Variables"], + "{dataSets:[{name, values:[{variable, kind:visibility|text|pixels, value}]}], append?} → {defs,dataSets,active}", + |s, p| data_sets(s, &p)), + spec!("image.applyDataSet", "Apply Data Set…", &["Image"], + "{name|index} → {applied, index}: sets layer visibility/text/pixels from the data set (one history step)", + |s, p| apply_data_set(s, &p)), + spec!("file.import.variableDataSets", "Variable Data Sets…", &["File", "Import"], + "{path, delimiter?} → {imported}: CSV header = variable names, each row a data set (first column may be the data-set name)", + |s, p| import_data_sets(s, &p)), + spec!("file.export.dataSetsAsFiles", "Data Sets as Files…", &["File", "Export"], + "{dir, format?:png, dataSets?[names]} → {files,count}: apply each data set and export the flattened document", + |s, p| export_as_files(s, &p)), + CommandSpec { id: "variables.list", label: "List Variables", menu: &[], shortcut: None, journal: false, + params: "{} → {defs,dataSets,active}", enabled: has_doc, run: |s, _| list(s) }, + ] +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/variables_cmds/tests.rs b/crates/engine/src/variables_cmds/tests.rs new file mode 100644 index 00000000..cdddfd97 --- /dev/null +++ b/crates/engine/src/variables_cmds/tests.rs @@ -0,0 +1,154 @@ +use super::*; +use photocraft_doc::{Layer, TextLayer}; +use photocraft_geom::Rect; + +/// A document with a raster layer "photo", a togglable raster "badge" and a text layer "title". +fn session() -> (Session, LayerId, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let ids = s + .edit("setup", |doc, _| { + let fmt = doc.pixel_format(); + let mut photo = Layer::raster("photo", fmt); + photo.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 64, 48), &photocraft_raster::from_rgba(&fmt, [0.5, 0.5, 0.5, 1.0])); + let mut badge = Layer::raster("badge", fmt); + badge.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 10, 10), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + let title = Layer::new("title", LayerContent::Text(TextLayer { text: "Old".into(), ..Default::default() })); + let (p, b, t) = (photo.id, badge.id, title.id); + doc.layers.push(photo); + doc.layers.push(badge); + doc.layers.push(title); + Ok((p, b, t)) + }) + .unwrap(); + (s, ids.0, ids.1, ids.2) +} + +fn doc(s: &Session) -> &Document { + &s.active().unwrap().doc +} + +fn text_of(s: &Session, id: LayerId) -> String { + match &doc(s).layer(id).unwrap().content { + LayerContent::Text(t) => t.text.clone(), + _ => panic!("not a text layer"), + } +} + +fn tmp(name: &str) -> String { + let d = std::env::temp_dir().join(format!("pc-vars-{}-{name}", std::process::id())); + let _ = std::fs::create_dir_all(&d); + d.to_string_lossy().into_owned() +} + +#[test] +fn define_apply_visibility_and_text() { + let (mut s, _photo, badge, title) = session(); + s.execute("image.variables.define", json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + ]})).unwrap(); + s.execute("image.variables.dataSets", json!({"dataSets": [ + {"name": "A", "values": [ + {"variable": "showBadge", "kind": "visibility", "value": false}, + {"variable": "headline", "kind": "text", "value": "Hello"}, + ]}, + ]})).unwrap(); + assert!(doc(&s).layer(badge).unwrap().visible); + assert_eq!(text_of(&s, title), "Old"); + + let r = s.execute("image.applyDataSet", json!({"name": "A"})).unwrap(); + assert_eq!(r["applied"], "A"); + assert!(!doc(&s).layer(badge).unwrap().visible, "visibility applied"); + assert_eq!(text_of(&s, title), "Hello", "text applied"); + assert_eq!(doc(&s).variables.active, Some(0)); + + // One history step, undoable back to the original. + assert_eq!(s.active().unwrap().history.entries().last().map(|e| e.as_str()), Some("Apply Data Set \"A\"")); + assert!(s.undo()); + assert!(doc(&s).layer(badge).unwrap().visible); + assert_eq!(text_of(&s, title), "Old"); +} + +#[test] +fn bad_params_are_errors() { + let (mut s, _p, badge, _t) = session(); + assert!(s.execute("image.variables.define", json!({"defs": [{"name": "x", "layer": 999999, "type": "visibility"}]})).is_err()); + assert!(s.execute("image.variables.define", json!({"defs": [{"name": "x", "layer": badge.0, "type": "nope"}]})).is_err()); + assert!(s.execute("image.applyDataSet", json!({"name": "missing"})).is_err()); +} + +#[test] +fn csv_import_and_apply() { + let (mut s, _p, badge, title) = session(); + s.execute("image.variables.define", json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + ]})).unwrap(); + let dir = tmp("csv"); + let csv = format!("{dir}/sets.csv"); + std::fs::write(&csv, "DataSet,showBadge,headline\nrow-on,true,On Sale\nrow-off,false,Sold Out\n").unwrap(); + let r = s.execute("file.import.variableDataSets", json!({"path": csv})).unwrap(); + assert_eq!(r["imported"], 2); + + s.execute("image.applyDataSet", json!({"name": "row-off"})).unwrap(); + assert!(!doc(&s).layer(badge).unwrap().visible); + assert_eq!(text_of(&s, title), "Sold Out"); + + s.execute("image.applyDataSet", json!({"name": "row-on"})).unwrap(); + assert!(doc(&s).layer(badge).unwrap().visible); + assert_eq!(text_of(&s, title), "On Sale"); +} + +#[test] +fn export_data_sets_as_files() { + let (mut s, _p, badge, title) = session(); + s.execute("image.variables.define", json!({"defs": [ + {"name": "showBadge", "layer": badge.0, "type": "visibility"}, + {"name": "headline", "layer": title.0, "type": "textReplacement"}, + ]})).unwrap(); + s.execute("image.variables.dataSets", json!({"dataSets": [ + {"name": "one", "values": [{"variable": "headline", "kind": "text", "value": "1"}]}, + {"name": "two", "values": [{"variable": "showBadge", "kind": "visibility", "value": false}]}, + ]})).unwrap(); + let dir = tmp("out"); + let r = s.execute("file.export.dataSetsAsFiles", json!({"dir": dir, "format": "png"})).unwrap(); + assert_eq!(r["count"], 2); + assert!(std::path::Path::new(&format!("{dir}/one.png")).exists()); + assert!(std::path::Path::new(&format!("{dir}/two.png")).exists()); + // Exporting doesn't mutate the live document. + assert!(doc(&s).layer(badge).unwrap().visible); + assert_eq!(text_of(&s, title), "Old"); +} + +#[test] +fn pixel_replacement_changes_the_layer() { + let (mut s, photo, _b, _t) = session(); + // Make a 8x8 solid-blue PNG to drop in. + let dir = tmp("px"); + let png = format!("{dir}/blue.png"); + { + let mut src = Session::new(); + src.execute("file.new", json!({"width": 8, "height": 8, "background": "#0000ff"})).unwrap(); + let bytes = photocraft_io::export(&src.active().unwrap().doc, "png", &photocraft_io::ExportOptions::default()).unwrap().bytes; + std::fs::write(&png, bytes).unwrap(); + } + s.execute("image.variables.define", json!({"defs": [ + {"name": "img", "layer": photo.0, "type": "pixelReplacement", "method": "conform"}, + ]})).unwrap(); + s.execute("image.variables.dataSets", json!({"dataSets": [ + {"name": "blue", "values": [{"variable": "img", "kind": "pixels", "value": png}]}, + ]})).unwrap(); + // Before: grey. + let before = s.execute("document.pixel", json!({"x": 32, "y": 24})).unwrap(); + s.execute("image.applyDataSet", json!({"name": "blue"})).unwrap(); + let after = s.execute("document.pixel", json!({"x": 32, "y": 24})).unwrap(); + assert_ne!(before, after, "pixel layer content changed"); + // Blue dominates. + let rgba = after["rgba"].as_array().cloned().unwrap_or_default(); + if rgba.len() == 4 { + let b = rgba[2].as_f64().unwrap(); + let r = rgba[0].as_f64().unwrap(); + assert!(b > r, "replacement is blue-ish: {rgba:?}"); + } +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 86289cc9..f0f07dd1 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -273,6 +273,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM { .collect(), color_table: d.color_table.clone(), duotone: d.duotone.clone(), + variables: d.variables.clone(), layer_comps: d.layer_comps.iter().map(|c| comp_m(c, sink)).collect(), last_applied_comp: d.last_applied_comp, last_document_state: d.last_document_state.as_ref().map(|c| comp_m(c, sink)), @@ -635,6 +636,7 @@ impl Loader<'_> { patterns, color_table: m.color_table.clone(), duotone: m.duotone.clone(), + variables: m.variables.clone(), layer_comps, last_applied_comp: m.last_applied_comp, last_document_state, diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 37e360a3..fc84b259 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -76,6 +76,9 @@ pub struct DocM { pub last_applied_comp: Option, #[serde(default)] pub last_document_state: Option, + /// Image › Variables and Data Sets. + #[serde(default)] + pub variables: photocraft_doc::Variables, /// Image › Analysis: measurement scale, count groups, ruler. #[serde(default)] pub measurement: photocraft_doc::Measurement, diff --git a/crates/ui-egui/src/parity.rs b/crates/ui-egui/src/parity.rs index d485aa5e..72aa7629 100644 --- a/crates/ui-egui/src/parity.rs +++ b/crates/ui-egui/src/parity.rs @@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG; use crate::menus::is_live; /// Minimum number of live catalog items. Raise it when parity grows; never lower it. -pub const FLOOR: usize = 595; +pub const FLOOR: usize = 600; /// One top-level menu's coverage. #[derive(Clone, Debug, serde::Serialize)] diff --git a/docs/parity.md b/docs/parity.md index 01cf0313..de393dca 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,13 +2,13 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**600 of 625 menu items live (96.0%).** +**605 of 625 menu items live (96.8%).** | Menu | Live | Total | % | |---|---:|---:|---:| -| File | 45 | 50 | 90% | +| File | 47 | 50 | 94% | | Edit | 74 | 75 | 99% | -| Image | 57 | 61 | 93% | +| Image | 60 | 61 | 98% | | Layer | 146 | 160 | 91% | | Type | 44 | 44 | 100% | | Select | 25 | 25 | 100% | @@ -18,11 +18,9 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho ## Not yet implemented -### File (5) +### File (3) -- Export › Data Sets as Files… (`file.export.dataSetsAsFiles`) - Export › Render Video… (`file.export.renderVideo`) -- Import › Variable Data Sets… (`file.import.variableDataSets`) - Import › Video Frames to Layers… (`file.import.videoFramesToLayers`) - Import › WIA Support… (`file.import.wiaSupport`) @@ -30,11 +28,8 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho - Presets › Migrate Presets (`edit.presets.migratePresets`) -### Image (4) +### Image (1) -- Variables › Define… (`image.variables.define`) -- Variables › Data Sets… (`image.variables.dataSets`) -- Apply Data Set… (`image.applyDataSet`) - Trap… (`image.trap`) ### Layer (14)