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:
parent
0c2d4bfd1a
commit
79fd79c2ae
|
|
@ -14,6 +14,7 @@ pub mod effects;
|
||||||
pub mod mode;
|
pub mod mode;
|
||||||
pub mod pattern;
|
pub mod pattern;
|
||||||
pub mod slices;
|
pub mod slices;
|
||||||
|
pub mod variables;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
pub mod text_styles;
|
pub mod text_styles;
|
||||||
pub mod vector;
|
pub mod vector;
|
||||||
|
|
@ -33,6 +34,7 @@ pub use photocraft_geom::{Affine, Rect, Size};
|
||||||
pub use photocraft_raster::Surface;
|
pub use photocraft_raster::Surface;
|
||||||
pub use mode::{ColorTable, Duotone, DuotoneInk, StackMode};
|
pub use mode::{ColorTable, Duotone, DuotoneInk, StackMode};
|
||||||
pub use pattern::Pattern;
|
pub use pattern::Pattern;
|
||||||
|
pub use variables::{DataSet, DataValue, PixelAlign, PixelMethod, VarKind, VariableDef, Variables};
|
||||||
pub use slices::{Slice, SliceKind, SliceOrigin, Slices};
|
pub use slices::{Slice, SliceKind, SliceOrigin, Slices};
|
||||||
pub use text_styles::TextStyles;
|
pub use text_styles::TextStyles;
|
||||||
pub use vector::{
|
pub use vector::{
|
||||||
|
|
@ -609,6 +611,8 @@ pub struct Document {
|
||||||
pub text_styles: TextStyles,
|
pub text_styles: TextStyles,
|
||||||
/// Web slices (Slice tool, layer-based slices; PSD resource 1050). Auto slices are derived.
|
/// Web slices (Slice tool, layer-based slices; PSD resource 1050). Auto slices are derived.
|
||||||
pub slices: Slices,
|
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.
|
/// Where a layer lives in the tree: indices from the root down.
|
||||||
|
|
@ -644,6 +648,7 @@ impl Document {
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
text_styles: TextStyles::default(),
|
text_styles: TextStyles::default(),
|
||||||
slices: Slices::default(),
|
slices: Slices::default(),
|
||||||
|
variables: Variables::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -836,6 +836,7 @@ fn build() -> Vec<CommandSpec> {
|
||||||
v.extend(crate::automate_cmds::specs());
|
v.extend(crate::automate_cmds::specs());
|
||||||
v.extend(crate::print_cmds::specs());
|
v.extend(crate::print_cmds::specs());
|
||||||
v.extend(crate::pick_cmds::specs());
|
v.extend(crate::pick_cmds::specs());
|
||||||
|
v.extend(crate::variables_cmds::specs());
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ pub mod vp_cmds;
|
||||||
pub mod artboard_cmds;
|
pub mod artboard_cmds;
|
||||||
pub mod comps_cmds;
|
pub mod comps_cmds;
|
||||||
pub mod automate_cmds;
|
pub mod automate_cmds;
|
||||||
|
mod variables_cmds;
|
||||||
pub mod pick_cmds;
|
pub mod pick_cmds;
|
||||||
pub mod print_cmds;
|
pub mod print_cmds;
|
||||||
pub mod slice_cmds;
|
pub mod slice_cmds;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -273,6 +273,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM {
|
||||||
.collect(),
|
.collect(),
|
||||||
color_table: d.color_table.clone(),
|
color_table: d.color_table.clone(),
|
||||||
duotone: d.duotone.clone(),
|
duotone: d.duotone.clone(),
|
||||||
|
variables: d.variables.clone(),
|
||||||
layer_comps: d.layer_comps.iter().map(|c| comp_m(c, sink)).collect(),
|
layer_comps: d.layer_comps.iter().map(|c| comp_m(c, sink)).collect(),
|
||||||
last_applied_comp: d.last_applied_comp,
|
last_applied_comp: d.last_applied_comp,
|
||||||
last_document_state: d.last_document_state.as_ref().map(|c| comp_m(c, sink)),
|
last_document_state: d.last_document_state.as_ref().map(|c| comp_m(c, sink)),
|
||||||
|
|
@ -635,6 +636,7 @@ impl Loader<'_> {
|
||||||
patterns,
|
patterns,
|
||||||
color_table: m.color_table.clone(),
|
color_table: m.color_table.clone(),
|
||||||
duotone: m.duotone.clone(),
|
duotone: m.duotone.clone(),
|
||||||
|
variables: m.variables.clone(),
|
||||||
layer_comps,
|
layer_comps,
|
||||||
last_applied_comp: m.last_applied_comp,
|
last_applied_comp: m.last_applied_comp,
|
||||||
last_document_state,
|
last_document_state,
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ pub struct DocM {
|
||||||
pub last_applied_comp: Option<u32>,
|
pub last_applied_comp: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_document_state: Option<LayerCompM>,
|
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.
|
/// Image › Analysis: measurement scale, count groups, ruler.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub measurement: photocraft_doc::Measurement,
|
pub measurement: photocraft_doc::Measurement,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG;
|
||||||
use crate::menus::is_live;
|
use crate::menus::is_live;
|
||||||
|
|
||||||
/// Minimum number of live catalog items. Raise it when parity grows; never lower it.
|
/// 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.
|
/// One top-level menu's coverage.
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
#[derive(Clone, Debug, serde::Serialize)]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 | % |
|
| Menu | Live | Total | % |
|
||||||
|---|---:|---:|---:|
|
|---|---:|---:|---:|
|
||||||
| File | 45 | 50 | 90% |
|
| File | 47 | 50 | 94% |
|
||||||
| Edit | 74 | 75 | 99% |
|
| Edit | 74 | 75 | 99% |
|
||||||
| Image | 57 | 61 | 93% |
|
| Image | 60 | 61 | 98% |
|
||||||
| Layer | 146 | 160 | 91% |
|
| Layer | 146 | 160 | 91% |
|
||||||
| Type | 44 | 44 | 100% |
|
| Type | 44 | 44 | 100% |
|
||||||
| Select | 25 | 25 | 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
|
## Not yet implemented
|
||||||
|
|
||||||
### File (5)
|
### File (3)
|
||||||
|
|
||||||
- Export › Data Sets as Files… (`file.export.dataSetsAsFiles`)
|
|
||||||
- Export › Render Video… (`file.export.renderVideo`)
|
- Export › Render Video… (`file.export.renderVideo`)
|
||||||
- Import › Variable Data Sets… (`file.import.variableDataSets`)
|
|
||||||
- Import › Video Frames to Layers… (`file.import.videoFramesToLayers`)
|
- Import › Video Frames to Layers… (`file.import.videoFramesToLayers`)
|
||||||
- Import › WIA Support… (`file.import.wiaSupport`)
|
- 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`)
|
- 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`)
|
- Trap… (`image.trap`)
|
||||||
|
|
||||||
### Layer (14)
|
### Layer (14)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue