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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 16:43:22 -04:00
parent 0c2d4bfd1a
commit 79fd79c2ae
10 changed files with 770 additions and 11 deletions

View File

@ -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(),
}
}

192
crates/doc/src/variables.rs Normal file
View File

@ -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<VariableDef>,
/// Named data sets (rows of values).
#[serde(default)]
pub data_sets: Vec<DataSet>,
/// Index into `data_sets` of the data set applied last, if any.
#[serde(default)]
pub active: Option<usize>,
}
/// 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<DataValue>,
}
/// 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<usize> {
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::<Variables>(&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());
}
}

View File

@ -836,6 +836,7 @@ fn build() -> Vec<CommandSpec> {
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
}

View File

@ -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;

View File

@ -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<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
// --------------------------------------------------------------------------- parse helpers
fn parse_kind(v: &Value) -> Option<VarKind> {
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<VarValue> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<String> = 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<String> {
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<Value> {
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<Vec<String>> = 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<DataSet> = 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<Value> {
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::<Vec<_>>(),
"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::<Vec<_>>(),
})).collect::<Vec<_>>(),
"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<CommandSpec> {
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;

View File

@ -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:?}");
}
}

View File

@ -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,

View File

@ -76,6 +76,9 @@ pub struct DocM {
pub last_applied_comp: Option<u32>,
#[serde(default)]
pub last_document_state: Option<LayerCompM>,
/// 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,

View File

@ -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)]

View File

@ -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)