diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 094c1f3893..4541f09df4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -265,6 +265,7 @@ jobs: - { crate: psd, name: metadata } - { crate: psd, name: tagged } - { crate: psd, name: resources } + - { crate: psd, name: asl } - { crate: codecs, name: decode_any } - { crate: codecs, name: decode_png } - { crate: codecs, name: decode_jpeg } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 6c9939607d..fcb43c0a65 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -15,11 +15,11 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and -/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. +/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", - "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "asl", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index f6695717be..3289e63070 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -17,12 +17,12 @@ use std::sync::Arc; mod recovery_tests; /// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and -/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets +/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl), swatches (.aco, .ase) and keyboard shortcut sets /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", - "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "abr", "grd", "asl", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 76a14c6e34..1bbc3e6da9 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -362,12 +362,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool { fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { + "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" | "style.presets.importAsl" => { // `data` wins over `path` in these commands; any `path` without it reads the filesystem. params.get("data").is_none() && params.get("path").is_some() } // With a `path` the file is written there; without one the bytes come back as `data`. - "swatches.export" => params.get("path").is_some(), + "swatches.export" | "style.presets.exportAsl" => params.get("path").is_some(), "plugin.reload" => true, _ => false, } @@ -762,6 +762,9 @@ mod tests { ("plugin.install", serde_json::json!({"path": " "})), ("swatches.import", serde_json::json!({"path": "/outside/set.aco"})), ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), + ("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})), + ("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})), + ("style.presets.exportAsl", serde_json::json!({"path": 5})), ] { assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}"); } @@ -771,6 +774,8 @@ mod tests { ("plugin.install", serde_json::json!({"data": "QUJD"})), ("swatches.import", serde_json::json!({"data": "QUJD"})), ("swatches.export", serde_json::json!({"format": "ase"})), + ("style.presets.importAsl", serde_json::json!({"data": "QUJD"})), + ("style.presets.exportAsl", serde_json::json!({"group": "Basics"})), ] { assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}"); } @@ -896,6 +901,8 @@ mod tests { ("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})), ("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})), ("plugin.reload", serde_json::json!({"path": "/outside/plugins"})), + ("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})), + ("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})), ("prefs.set", serde_json::json!({"path": "historyLog.filePath", "value": "/outside/log"})), ("prefs.set", serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})), ] { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5524388c71..ae505ce8db 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1200,6 +1200,7 @@ fn build() -> Vec { v.extend(crate::brush_preset_cmds::specs()); v.extend(crate::eraser_cmds::specs()); v.extend(crate::preset_import_cmds::specs()); + v.extend(crate::style_asl_cmds::specs()); v.extend(crate::swatch_cmds::specs()); v.extend(crate::retouch_cmds::specs()); v.extend(crate::redeye_cmds::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9cb725518..6134ef29ea 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -102,6 +102,7 @@ pub mod smartselect_cmds; pub mod snap; pub mod solid_fill_cmds; pub mod stamp_cmds; +pub mod style_asl_cmds; pub mod swatch_cmds; pub mod symmetry_cmds; mod timeline_cmds; diff --git a/crates/engine/src/presets/styles.rs b/crates/engine/src/presets/styles.rs index ee4607cf86..04e4c831ce 100644 --- a/crates/engine/src/presets/styles.rs +++ b/crates/engine/src/presets/styles.rs @@ -25,6 +25,9 @@ pub struct StylePreset { pub blend: Option, #[serde(default)] pub fill_opacity: Option, + /// Layer opacity saved with the blending options (0..1). + #[serde(default)] + pub opacity: Option, } impl Named for StylePreset { @@ -43,7 +46,7 @@ impl StylePreset { .as_array() .map(|a| a.iter().filter_map(|e| effect_from_params(e.get(0)?.as_str()?, e.get(1).unwrap_or(&Value::Null))).collect()) .unwrap_or_default(); - StylePreset { name: name.into(), effects, blend: None, fill_opacity: fill } + StylePreset { name: name.into(), effects, blend: None, fill_opacity: fill, opacity: None } } pub fn to_json(&self) -> Value { @@ -52,6 +55,7 @@ impl StylePreset { "effects": self.effects.iter().map(|e| e.label()).collect::>(), "blend": self.blend.map(|b| b.label()), "fillOpacity": self.fill_opacity.map(|f| (f * 100.0).round()), + "opacity": self.opacity.map(|f| (f * 100.0).round()), }) } } @@ -182,6 +186,12 @@ fn apply(s: &mut Session, p: &Value) -> Result { effects.push(fx); pats.extend(pat); } + // Pattern strokes and bevel textures name their pattern too: copy it when the library has it. + for (name, id) in photocraft_io::asl_map::referenced_patterns(&effects) { + if let Some(pat) = crate::pattern_cmds::resolve(s, if id.is_empty() { &name } else { &id }).filter(|_| !(id.is_empty() && name.is_empty())) { + pats.push(pat); + } + } let label = format!("Apply Style: {}", style.name); s.edit(&label, |doc, _| { for pat in &pats { @@ -211,6 +221,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { } else if !add { l.fill_opacity = 1.0; } + if let Some(o) = style.opacity { + l.opacity = o; + } } } Ok(()) @@ -256,6 +269,7 @@ fn new_preset(s: &mut Session, p: &Value) -> Result { }, blend: p.get("blend").and_then(Value::as_str).and_then(crate::commands::blend_from_str).or_else(|| with_blend.then_some(l.blend)), fill_opacity: p.get("fillOpacity").and_then(Value::as_f64).map(|v| (v / 100.0).clamp(0.0, 1.0) as f32).or_else(|| with_blend.then_some(l.fill_opacity)), + opacity: p.get("opacity").and_then(Value::as_f64).map(|v| (v / 100.0).clamp(0.0, 1.0) as f32).or_else(|| with_blend.then_some(l.opacity)), }; let gi = group_index(&mut s.presets.styles, str_param(p, "group")); let out = json!({"name": style.name, "group": s.presets.styles[gi].name, "effects": style.effects.len()}); @@ -336,7 +350,7 @@ pub fn specs() -> Vec { label: "Style Presets", menu: &[], shortcut: None, - params: "{} → {groups:[{name,presets:[{name,effects,blend,fillOpacity}]}]}", + params: "{} → {groups:[{name,presets:[{name,effects,blend,fillOpacity,opacity}]}]}", enabled: always, run: list, journal: false, @@ -356,7 +370,7 @@ pub fn specs() -> Vec { label: "New Style…", menu: &[], shortcut: None, - params: r##"{"name":str="Style","group":name?,"layer":id?,"includeEffects":bool=true,"includeBlending":bool=true,"effects":[[kind, params]]? (explicit list, e.g. the Layer Style dialog's pending state; overrides includeEffects),"blend":str?,"fillOpacity":0..100? (override the layer's blending options)}"##, + params: r##"{"name":str="Style","group":name?,"layer":id?,"includeEffects":bool=true,"includeBlending":bool=true,"effects":[[kind, params]]? (explicit list, e.g. the Layer Style dialog's pending state; overrides includeEffects),"blend":str?,"fillOpacity":0..100?,"opacity":0..100? (override the layer's blending options)}"##, enabled: has_layer, run: new_preset, journal: true, diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index 7ebf2145b1..6643bd24af 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -433,6 +433,7 @@ fn styles_with_pattern_overlay_copy_the_pattern() { effects: vec![crate::layer_style::effect_from_params("patternOverlay", &json!({"pattern": "Bricks"})).unwrap()], blend: None, fill_opacity: None, + opacity: None, }); s.execute("style.presets.apply", json!({"preset": "Bricky", "layer": id.0})).unwrap(); assert!(s.active().unwrap().doc.patterns.iter().any(|p| p.name == "Bricks")); diff --git a/crates/engine/src/style_asl_cmds.rs b/crates/engine/src/style_asl_cmds.rs new file mode 100644 index 0000000000..e942df7d35 --- /dev/null +++ b/crates/engine/src/style_asl_cmds.rs @@ -0,0 +1,310 @@ +//! Styles panel › Import Styles… / Export Styles…: Photoshop layer style libraries (`.asl`). +//! +//! - `style.presets.importAsl` adds every style of an `.asl` file to the Styles library as one +//! group (named after the file), and the patterns embedded in the file to the pattern library so +//! the styles' Pattern Overlays, pattern strokes and bevel textures render. A malformed file +//! changes nothing. +//! - `style.presets.exportAsl` writes styles (all, one group or named ones) as an `.asl`, with the +//! patterns their effects use. +//! +//! The container lives in `photocraft-psd` (`asl`), the mapping onto effects in `photocraft-io` +//! (`asl_map`). + +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::presets::styles::StylePreset; +use crate::presets::{Group, find, unique_name}; +use crate::{EngineError, Result, Session}; +use photocraft_io::asl_map::{self, AslStyle}; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +fn has_styles(s: &Session) -> std::result::Result<(), String> { + if s.presets.styles.iter().any(|g| !g.items.is_empty()) { Ok(()) } else { Err("no styles".into()) } +} + +const IMPORT: &str = "style.presets.importAsl"; +const EXPORT: &str = "style.presets.exportAsl"; + +fn import(s: &mut Session, p: &Value) -> Result { + let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, IMPORT, ".asl file")?; + let group = match p.get("group") { + Some(v) => v.as_str().map(str::trim).filter(|g| !g.is_empty()).ok_or_else(|| bad(IMPORT, "`group` must be a non-empty string"))?.to_string(), + None if !stem.trim().is_empty() => stem.trim().to_string(), + None => "Imported Styles".to_string(), + }; + let replace = match p.get("replace") { + None => true, + Some(v) => v.as_bool().ok_or_else(|| bad(IMPORT, "`replace` must be a boolean"))?, + }; + let imp = asl_map::read_asl(&bytes).map_err(|e| bad(IMPORT, e))?; + if imp.styles.is_empty() { + return Err(bad(IMPORT, "the file holds no styles")); + } + // Everything is checked: change the library. + let mut pattern_ids = Vec::new(); + for pat in imp.patterns { + pattern_ids.push(pat.id.clone()); + match s.patterns.items.iter_mut().find(|q| q.id == pat.id) { + Some(q) => *q = pat, + None => s.patterns.items.push(pat), + } + } + let styles = &mut s.presets.styles; + if replace { + styles.retain(|g| g.name != group); + } + let gi = match styles.iter().position(|g| g.name == group) { + Some(i) => i, + None => { + styles.push(Group::new(&group, Vec::new())); + styles.len() - 1 + } + }; + let mut names = Vec::with_capacity(imp.styles.len()); + for st in imp.styles { + // Agents address styles by name: keep names unique across the library. + let name = unique_name(styles, &st.name); + names.push(name.clone()); + let preset = StylePreset { name, effects: st.effects, blend: st.blend, fill_opacity: st.fill_opacity, opacity: st.opacity }; + if let Some(g) = styles.get_mut(gi) { + g.items.push(preset); + } + } + s.presets_changed(); + Ok(json!({"group": group, "imported": names, "count": names.len(), "patterns": pattern_ids, "warnings": imp.warnings})) +} + +fn export(s: &mut Session, p: &Value) -> Result { + let group = match p.get("group") { + None => None, + Some(v) => Some(v.as_str().ok_or_else(|| bad(EXPORT, "`group` must be a string"))?), + }; + let chosen: Vec = match p.get("styles") { + Some(v) => { + let list = v.as_array().ok_or_else(|| bad(EXPORT, "`styles` must be a list of style names"))?; + list.iter() + .map(|n| { + let name = n.as_str().ok_or_else(|| bad(EXPORT, "`styles` must be a list of style names"))?; + let (gi, ii) = find(&s.presets.styles, name, group).ok_or_else(|| bad(EXPORT, format!("no style \"{name}\" (see style.presets.list)")))?; + s.presets.styles.get(gi).and_then(|g| g.items.get(ii)).cloned().ok_or_else(|| bad(EXPORT, format!("no style \"{name}\""))) + }) + .collect::>()? + } + None => match group { + Some(g) => s.presets.styles.iter().find(|x| x.name == g).ok_or_else(|| bad(EXPORT, format!("no style group \"{g}\"")))?.items.clone(), + None => s.presets.styles.iter().flat_map(|g| g.items.iter().cloned()).collect(), + }, + }; + if chosen.is_empty() { + return Err(bad(EXPORT, "no styles to export")); + } + // The patterns the styles use, from the open document or the library. + let all_fx: Vec = chosen.iter().flat_map(|st| st.effects.iter().cloned()).collect(); + let mut patterns: Vec = Vec::new(); + let mut warnings = Vec::new(); + for (name, id) in asl_map::referenced_patterns(&all_fx) { + let key = if id.is_empty() { &name } else { &id }; + match crate::pattern_cmds::resolve(s, key).filter(|_| !key.is_empty()) { + Some(pat) if !patterns.iter().any(|q| q.id == pat.id) => patterns.push(pat), + Some(_) => {} + None => warnings.push(format!("pattern \"{}\" was not found and is not included", if name.is_empty() { &id } else { &name })), + } + } + let styles: Vec = chosen + .iter() + .map(|st| AslStyle { name: st.name.clone(), effects: st.effects.clone(), blend: st.blend, opacity: st.opacity, fill_opacity: st.fill_opacity }) + .collect(); + let bytes = asl_map::write_asl(&styles, &patterns).map_err(|e| bad(EXPORT, e))?; + let mut out = json!({"count": styles.len(), "bytes": bytes.len(), "patterns": patterns.len(), "warnings": warnings}); + match p.get("path") { + Some(v) => { + let path = v.as_str().filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(EXPORT, "`path` must be a non-empty string"))?; + crate::file_cmds::write_file(path, &bytes)?; + out["path"] = json!(path); + } + None => out["data"] = json!(photocraft_paint::tile::b64_encode(&bytes)), + } + Ok(out) +} + +/// `.asl` command specs. +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: IMPORT, + label: "Import Styles…", + menu: &[], + shortcut: None, + params: r##"{"path":".asl file"?,"data":base64 bytes?,"group":string?=file name,"replace":bool=true (replace a group of the same name)} → {group, imported, count, patterns (ids added to the pattern library), warnings}"##, + enabled: always, + run: import, + journal: true, + }, + CommandSpec { + id: EXPORT, + label: "Export Styles…", + menu: &[], + shortcut: None, + params: r##"{"group":name? (only that group),"styles":[names]? (only these),"path":".asl file"? (else the bytes come back as base64 `data`)} → {count, bytes, patterns, warnings, path|data}"##, + enabled: has_styles, + run: export, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_paint::tile::{b64_decode, b64_encode}; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s + } + + #[test] + fn export_then_import_round_trips_a_group() { + let mut s = session(); + let before = s.presets.styles.clone(); + let r = s.execute(EXPORT, json!({"group": "Text Effects"})).unwrap(); + let n = before.iter().find(|g| g.name == "Text Effects").unwrap().items.len(); + assert_eq!(r["count"], n); + let data = r["data"].as_str().unwrap().to_string(); + let r = s.execute(IMPORT, json!({"data": data, "group": "Back"})).unwrap(); + assert_eq!(r["count"], n); + let back = s.presets.styles.iter().find(|g| g.name == "Back").unwrap().items.clone(); + let orig = &before.iter().find(|g| g.name == "Text Effects").unwrap().items; + for (a, b) in back.iter().zip(orig) { + // Names stay unique across the library: "Gold" comes back as "Gold 2". + assert!(a.name.starts_with(&b.name), "{} vs {}", a.name, b.name); + assert_eq!(a.effects.len(), b.effects.len(), "{}", b.name); + assert_eq!(a.fill_opacity, b.fill_opacity, "{}", b.name); + } + // The imported copy applies like the original. + s.execute("style.presets.apply", json!({"preset": back[0].name})).unwrap(); + let fx = &s.active().unwrap().doc.layers.last().unwrap().effects.items; + assert_eq!(fx.len(), orig[0].effects.len()); + // Re-import replaces the group instead of adding another. + let groups = s.presets.styles.len(); + s.execute(IMPORT, json!({"data": data, "group": "Back"})).unwrap(); + assert_eq!(s.presets.styles.len(), groups); + } + + #[test] + fn blending_options_survive_and_apply() { + let mut s = session(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": id.0, "opacity": 40, "blend": "multiply", "fillOpacity": 30})).unwrap(); + let l = s.active().unwrap().doc.layer(id).unwrap().clone(); + assert_eq!(l.blend, photocraft_color::BlendMode::Multiply); + s.execute("style.presets.new", json!({"name": "Blended", "group": "Mine", "layer": id.0})).unwrap(); + let data = s.execute(EXPORT, json!({"styles": ["Blended"]})).unwrap()["data"].as_str().unwrap().to_string(); + s.execute(IMPORT, json!({"data": data, "group": "Again"})).unwrap(); + let st = s.presets.styles.iter().find(|g| g.name == "Again").unwrap().items[0].clone(); + assert_eq!(st.blend, Some(l.blend)); + assert!((st.opacity.unwrap() - l.opacity).abs() < 1e-4); + assert!((st.fill_opacity.unwrap() - l.fill_opacity).abs() < 1e-4); + // Applying it to a fresh layer sets the blending options. + s.execute("layer.new.layer", json!({})).unwrap(); + let fresh = s.active().unwrap().active_layer.unwrap(); + s.execute("style.presets.apply", json!({"preset": st.name, "layer": fresh.0})).unwrap(); + let f = s.active().unwrap().doc.layer(fresh).unwrap(); + assert_eq!(f.blend, l.blend); + assert!((f.opacity - l.opacity).abs() < 1e-4); + } + + #[test] + fn embedded_patterns_reach_the_library_and_the_document() { + let mut s = session(); + let pat = s.patterns.items[0].clone(); + let fx = crate::layer_style::effect_from_params("patternOverlay", &json!({"pattern": pat.id})).unwrap(); + let (fx, _) = crate::pattern_cmds::resolve_effect(&s, fx).unwrap(); + let mut renamed = pat.clone(); + renamed.id = "asl-test-pattern".into(); + renamed.name = "Asl Test".into(); + let fx = match fx { + photocraft_doc::Effect::PatternOverlay { common, scale, angle, link, phase, .. } => { + photocraft_doc::Effect::PatternOverlay { common, name: renamed.name.clone(), id: renamed.id.clone(), scale, angle, link, phase } + } + other => other, + }; + let style = AslStyle { name: "Pat".into(), effects: vec![fx], blend: None, opacity: None, fill_opacity: None }; + let bytes = asl_map::write_asl(&[style], std::slice::from_ref(&renamed)).unwrap(); + let r = s.execute(IMPORT, json!({"data": b64_encode(&bytes), "group": "Pats"})).unwrap(); + assert_eq!(r["patterns"], json!(["asl-test-pattern"])); + assert!(s.patterns.items.iter().any(|p| p.id == "asl-test-pattern")); + s.execute("style.presets.apply", json!({"preset": "Pat", "group": "Pats"})).unwrap(); + assert!(s.active().unwrap().doc.patterns.iter().any(|p| p.id == "asl-test-pattern")); + // Export embeds it again. + let r = s.execute(EXPORT, json!({"group": "Pats"})).unwrap(); + assert_eq!(r["patterns"], 1); + let back = asl_map::read_asl(&b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap(); + assert_eq!(back.patterns.len(), 1); + } + + #[test] + fn files_on_disk() { + let mut s = session(); + let dir = std::env::temp_dir().join(format!("pc-asl-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("My Styles.asl"); + let r = s.execute(EXPORT, json!({"group": "Basics", "path": path.to_string_lossy()})).unwrap(); + assert!(r.get("data").is_none()); + let r = s.execute(IMPORT, json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["group"], "My Styles"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bad_params_fail_gracefully_and_change_nothing() { + let mut s = session(); + let good = s.execute(EXPORT, json!({"group": "Basics"})).unwrap()["data"].as_str().unwrap().to_string(); + let raw = b64_decode(&good).unwrap(); + let styles = s.presets.styles.clone(); + let pats = s.patterns.items.len(); + let empty = b64_encode(&asl_map::write_asl(&[], &[]).unwrap()); + for p in [ + json!({}), + json!({"path": ""}), + json!({"path": "/nonexistent/styles.asl"}), + json!({"data": 42}), + json!({"data": "%%%not base64"}), + json!({"data": ""}), + json!({"data": b64_encode(b"8BPS\0\x01junk")}), + json!({"data": b64_encode(&raw[..raw.len() / 2])}), + json!({"data": empty}), + json!({"data": good, "group": 5}), + json!({"data": good, "group": " "}), + json!({"data": good, "replace": "yes"}), + ] { + assert!(s.execute(IMPORT, p.clone()).is_err(), "{p}"); + } + assert_eq!(s.presets.styles, styles); + assert_eq!(s.patterns.items.len(), pats); + for p in [ + json!({"group": "No Such Group"}), + json!({"group": 7}), + json!({"styles": "Gold"}), + json!({"styles": [3]}), + json!({"styles": ["Nope"]}), + json!({"styles": []}), + json!({"path": 9}), + json!({"path": " "}), + ] { + assert!(s.execute(EXPORT, p.clone()).is_err(), "{p}"); + } + s.presets.styles.clear(); + assert!(s.execute(EXPORT, json!({})).is_err()); + } +} diff --git a/crates/io/src/asl_map.rs b/crates/io/src/asl_map.rs new file mode 100644 index 0000000000..7c254099a9 --- /dev/null +++ b/crates/io/src/asl_map.rs @@ -0,0 +1,372 @@ +//! Layer style libraries (`.asl`) ⇄ PhotoCraft styles. +//! +//! The container is parsed by [`photocraft_psd::asl`]. Each style's `Lefx` is the same effects +//! tree as a layer's `lfx2` block ([`crate::effects_map`]); `blendOptions` ("Include Layer +//! Blending Options") carries the blend mode, opacity and fill opacity; the file's patterns feed +//! the styles' Pattern Overlays, pattern strokes and bevel textures. +//! +//! What PhotoCraft doesn't model (an effect kind it doesn't know, advanced blending keys, an +//! effects scale other than 100 %) is left out with a warning naming the style, and the rest of +//! the file still imports. + +use std::collections::BTreeSet; + +use photocraft_color::BlendMode; +use photocraft_doc::{Effect, FxPaint, Pattern}; +use photocraft_psd::asl::{self, AslFile}; +use photocraft_psd::descriptor::{Descriptor, Value}; + +use crate::blocks::{enum_of, get_desc, num}; +use crate::effects_map::{blend_from_id, blend_value, effects_descriptor, effects_from_descriptor, is_known_effects_key}; +use crate::pattern_map::{from_psd, to_psd}; + +/// One style as PhotoCraft keeps it. +#[derive(Clone, Debug, PartialEq)] +pub struct AslStyle { + /// Unique within the file (empty names get "Style N", repeats get "Name 2"). + pub name: String, + /// The effects (switched off when the style's effects were). + pub effects: Vec, + /// Blending options, when the style was saved with them. + pub blend: Option, + /// 0..1. + pub opacity: Option, + /// 0..1. + pub fill_opacity: Option, +} + +/// The result of reading a library. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AslImport { + pub styles: Vec, + /// Patterns embedded in the file. + pub patterns: Vec, + /// What was left out, for the user. + pub warnings: Vec, +} + +/// Patterns an effect list refers to: `(name, id)` in first-use order, without repeats. +pub fn referenced_patterns(effects: &[Effect]) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + let mut push = |name: &str, id: &str| { + if !out.iter().any(|(n, i)| if id.is_empty() { n == name } else { i == id }) { + out.push((name.to_string(), id.to_string())); + } + }; + for e in effects { + match e { + Effect::PatternOverlay { name, id, .. } => push(name, id), + Effect::Stroke(s) => { + if let FxPaint::Pattern { name, id, .. } = &s.paint { + push(name, id); + } + } + Effect::BevelEmboss(b) => { + if let Some(t) = &b.texture { + push(&t.name, &t.id); + } + } + _ => {} + } + } + out +} + +fn pct(d: &Descriptor, key: &str) -> Option { + num(d.get(key)).filter(|v| v.is_finite()).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)) +} + +fn unique(name: &str, taken: &[String]) -> String { + let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n)); + if free(name) { + return name.to_string(); + } + (2..).map(|i| format!("{name} {i}")).find(|n| free(n)).unwrap_or_else(|| name.to_string()) +} + +/// Reads an `.asl` style library. +pub fn read_asl(bytes: &[u8]) -> Result { + let file = asl::parse(bytes).map_err(|e| format!("not a readable style library (.asl): {e}"))?; + let mut out = AslImport { patterns: file.patterns.iter().filter_map(from_psd).collect(), ..Default::default() }; + if let Some(e) = &file.pattern_error { + out.warnings.push(format!("the file's patterns could not be read and were skipped ({e})")); + } + let skipped_patterns = file.patterns.len().saturating_sub(out.patterns.len()); + if skipped_patterns > 0 { + out.warnings.push(format!("{skipped_patterns} empty or unreadable patterns were skipped")); + } + let mut taken: Vec = Vec::new(); + let mut missing: BTreeSet = BTreeSet::new(); + for (i, s) in file.styles.iter().enumerate() { + let base = if s.name.trim().is_empty() { format!("Style {}", i + 1) } else { s.name.trim().to_string() }; + let name = unique(&base, &taken); + taken.push(name.clone()); + let mut style = AslStyle { name: name.clone(), effects: Vec::new(), blend: None, opacity: None, fill_opacity: None }; + let mut left_out: Vec = Vec::new(); + if let Some(fx) = get_desc(&s.style, "Lefx") { + let (master, mut effects) = effects_from_descriptor(fx); + if !master { + effects.iter_mut().for_each(|e| e.set_enabled(false)); + } + for (k, _) in &fx.items { + if !is_known_effects_key(k.as_bytes()) { + left_out.push(String::from_utf8_lossy(k.as_bytes()).trim().to_string()); + } + } + if let Some(scale) = num(fx.get("Scl ")).filter(|v| v.is_finite() && (v - 100.0).abs() > 0.01) { + left_out.push(format!("effects scale {scale:.0} %")); + } + style.effects = effects; + } + if let Some(b) = get_desc(&s.style, "blendOptions") { + style.blend = enum_of(b, "Md ").and_then(blend_from_id); + style.opacity = pct(b, "Opct"); + style.fill_opacity = pct(b, "fillOpacity"); + for (k, _) in &b.items { + if !matches!(k.as_bytes(), b"Md " | b"Opct" | b"fillOpacity") { + left_out.push(format!("blending option {}", String::from_utf8_lossy(k.as_bytes()).trim())); + } + } + } + for (pname, pid) in referenced_patterns(&style.effects) { + if !out.patterns.iter().any(|p| if pid.is_empty() { p.name == pname } else { p.id == pid }) { + missing.insert(if pname.is_empty() { pid } else { pname }); + } + } + if !left_out.is_empty() { + out.warnings.push(format!("\"{name}\": left out {}", left_out.join(", "))); + } + out.styles.push(style); + } + if !missing.is_empty() { + let list: Vec = missing.into_iter().collect(); + out.warnings.push(format!("patterns not in the file (they must be in the pattern library to render): {}", list.join(", "))); + } + Ok(out) +} + +/// A stable UUID-shaped id for a style (Photoshop gives every style one). +fn style_id(index: usize, name: &str) -> String { + // FNV-1a over the name, mixed with the index: deterministic output for deterministic tests. + let mut h: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d; + for b in name.bytes().chain(index.to_le_bytes()) { + h ^= u128::from(b); + h = h.wrapping_mul(0x0000_0000_0100_0000_0000_0000_0000_013b); + } + let x = format!("{h:032x}"); + let p = |a: usize, b: usize| x.get(a..b).unwrap_or("0"); + format!("{}-{}-{}-{}-{}", p(0, 8), p(8, 12), p(12, 16), p(16, 20), p(20, 32)) +} + +/// Writes styles (and the patterns their effects use) as an `.asl` style library. +pub fn write_asl(styles: &[AslStyle], patterns: &[Pattern]) -> Result, String> { + let pct = |v: f32| Value::UnitFloat { unit: *b"#Prc", value: f64::from(v.clamp(0.0, 1.0)) * 100.0 }; + let file = AslFile { + patterns: patterns.iter().map(to_psd).collect(), + pattern_error: None, + styles: styles + .iter() + .enumerate() + .map(|(i, s)| { + let mut d = Descriptor::new("Styl") + .with("documentMode", Value::Descriptor(Descriptor::new("documentMode"))) + .with("Lefx", Value::Descriptor(effects_descriptor(true, &s.effects))); + if s.blend.is_some() || s.opacity.is_some() || s.fill_opacity.is_some() { + d = d.with( + "blendOptions", + Value::Descriptor( + Descriptor::new("blendOptions") + .with("Md ", blend_value(s.blend.unwrap_or(BlendMode::Normal))) + .with("Opct", pct(s.opacity.unwrap_or(1.0))) + .with("fillOpacity", pct(s.fill_opacity.unwrap_or(1.0))), + ), + ); + } + asl::AslStyle { name: s.name.clone(), id: style_id(i, &s.name), style: d } + }) + .collect(), + }; + asl::write(&file).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; + use photocraft_doc::{BevelTexture, FxCommon, StrokePosition}; + use photocraft_psd::descriptor::{Id, UnicodeString}; + use photocraft_raster::Surface; + + fn pattern(id: &str, s: SampleType) -> Pattern { + let fmt = PixelFormat::new(ColorMode::Rgb, s, true); + let mut surf = Surface::new(fmt); + for y in 0..3 { + for x in 0..4 { + surf.write_pixel(x, y, &[x as f32 / 4.0, y as f32 / 3.0, 0.5, 1.0]); + } + } + let mut p = Pattern::new("Grid", surf, 4, 3); + p.id = id.into(); + p + } + + /// Every effect kind with its descriptor defaults (the reader fills in what's absent). + fn defaults() -> Vec { + let d = |k: &str| Value::Descriptor(Descriptor::new(k)); + let lefx = Descriptor::new("null") + .with("DrSh", d("DrSh")) + .with("IrSh", d("IrSh")) + .with("OrGl", d("OrGl")) + .with("IrGl", d("IrGl")) + .with("ebbl", d("ebbl")) + .with("ChFX", d("ChFX")) + .with("SoFi", d("SoFi")) + .with("GrFl", d("GrFl")) + .with("FrFX", d("FrFX")); + effects_from_descriptor(&lefx).1 + } + + fn styles() -> Vec { + let mut fx = defaults(); + assert_eq!(fx.len(), 9); + for e in &mut fx { + match e { + Effect::DropShadow(s) => (s.distance, s.size, s.color) = (7.0, 9.0, Color::rgb(0.2, 0.1, 0.0)), + Effect::Stroke(s) => (s.size, s.position, s.paint) = (4.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(0.0, 0.0, 1.0))), + Effect::ColorOverlay { common, color } => (*common, *color) = (FxCommon::new(BlendMode::Multiply, 0.5), Color::rgb(1.0, 0.5, 0.0)), + _ => {} + } + } + let mut bevel = defaults().into_iter().find(|e| matches!(e, Effect::BevelEmboss(_))).unwrap(); + if let Effect::BevelEmboss(b) = &mut bevel { + b.size = 6.0; + b.texture = Some(BevelTexture { name: "Grid".into(), id: "pat-2".into(), scale: 0.5, depth: 1.0, invert: false, link: true, phase: (0.0, 0.0) }); + } + vec![ + AslStyle { name: "Gold".into(), effects: fx, blend: Some(BlendMode::Screen), opacity: Some(0.8), fill_opacity: Some(0.25) }, + AslStyle { + name: "Textured".into(), + effects: vec![ + bevel, + Effect::PatternOverlay { + common: FxCommon { enabled: true, blend: BlendMode::Normal, opacity: 1.0 }, + name: "Grid".into(), + id: "pat-1".into(), + scale: 2.0, + angle: 0.0, + link: true, + phase: (0.0, 0.0), + }, + ], + blend: None, + opacity: None, + fill_opacity: None, + }, + ] + } + + #[test] + fn round_trip_keeps_effects_blending_and_patterns() { + let pats = vec![pattern("pat-1", SampleType::U8), pattern("pat-2", SampleType::U16)]; + let bytes = write_asl(&styles(), &pats).unwrap(); + let back = read_asl(&bytes).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + assert_eq!(back.patterns, pats); + assert_eq!(back.styles.len(), 2); + let (a, b) = (&back.styles[0], &styles()[0]); + assert_eq!((a.blend, a.opacity, a.fill_opacity), (b.blend, b.opacity, b.fill_opacity)); + // Effects go through the same descriptor writer and reader as PSD `lfx2`. + let via_lfx2 = |fx: &[Effect]| effects_from_descriptor(&effects_descriptor(true, fx)).1; + for (x, y) in back.styles.iter().zip(styles()) { + assert_eq!(x.name, y.name); + assert_eq!(x.effects, via_lfx2(&y.effects)); + assert_eq!(x.effects.len(), y.effects.len()); + } + assert_eq!(referenced_patterns(&back.styles[1].effects), vec![("Grid".to_string(), "pat-2".to_string()), ("Grid".to_string(), "pat-1".to_string())]); + } + + /// A library as Photoshop lays it out, built by hand from descriptors. + fn synthetic(lefx: Descriptor, blend: Option, names: &[&str]) -> Vec { + let mut style = Descriptor::new("Styl").with("documentMode", Value::Descriptor(Descriptor::new("documentMode"))).with("Lefx", Value::Descriptor(lefx)); + if let Some(b) = blend { + style = style.with("blendOptions", Value::Descriptor(b)); + } + let file = AslFile { + patterns: vec![], + pattern_error: None, + styles: names.iter().map(|n| asl::AslStyle { name: (*n).into(), id: "x".into(), style: style.clone() }).collect(), + }; + asl::write(&file).unwrap() + } + + #[test] + fn blend_options_names_and_warnings() { + let en = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let pc = |v: f64| Value::UnitFloat { unit: *b"#Prc", value: v }; + let lefx = Descriptor::new("null") + .with("Scl ", pc(100.0)) + .with("masterFXSwitch", Value::Boolean(false)) + .with( + "SoFi", + Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Md ", en("BlnM", "Nrml")).with("Opct", pc(100.0)).with( + "Clr ", + Value::Descriptor( + Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)), + ), + )), + ) + .with("futureFX", Value::Descriptor(Descriptor::new("futureFX"))); + let blend = Descriptor::new("blendOptions") + .with("Md ", en("BlnM", "multiply")) + .with("Opct", pc(40.0)) + .with("fillOpacity", pc(0.0)) + .with("knockout", Value::Integer(1)); + let bytes = synthetic(lefx, Some(blend), &["Red", "", "red", " "]); + let imp = read_asl(&bytes).unwrap(); + let names: Vec<&str> = imp.styles.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Red", "Style 2", "red 2", "Style 4"]); + let s = &imp.styles[0]; + assert_eq!((s.blend, s.opacity, s.fill_opacity), (Some(BlendMode::Multiply), Some(0.4), Some(0.0))); + assert_eq!(s.effects.len(), 1); + // The style's effects were switched off. + assert!(!s.effects[0].enabled()); + assert!(imp.warnings.iter().any(|w| w.contains("futureFX") && w.contains("knockout")), "{:?}", imp.warnings); + } + + #[test] + fn missing_patterns_are_reported() { + let po = Descriptor::new("patternFill").with("enab", Value::Boolean(true)).with( + "Ptrn", + Value::Descriptor( + Descriptor::new("Ptrn").with("Nm ", Value::Text(UnicodeString::new_nul("Bubbles"))).with("Idnt", Value::Text(UnicodeString::new_nul("b-1"))), + ), + ); + let bytes = synthetic(Descriptor::new("null").with("patternFill", Value::Descriptor(po)), None, &["P"]); + let imp = read_asl(&bytes).unwrap(); + assert_eq!(imp.styles[0].effects.len(), 1); + assert_eq!(imp.styles[0].blend, None); + assert!(imp.warnings.iter().any(|w| w.contains("Bubbles")), "{:?}", imp.warnings); + } + + #[test] + fn hostile_bytes_are_errors() { + let bytes = write_asl(&styles(), &[pattern("pat-1", SampleType::U8)]).unwrap(); + for cut in 0..bytes.len() { + assert!(read_asl(&bytes[..cut]).is_err(), "cut {cut}"); + } + for i in 0..bytes.len() { + let mut c = bytes.clone(); + c[i] ^= 0xa5; + let _ = read_asl(&c); + } + assert!(read_asl(b"").is_err()); + assert!(read_asl(&photocraft_psd::grd::write(&[])).is_err()); + } + + #[test] + fn style_ids_are_uuid_shaped() { + let id = style_id(3, "Gold"); + assert_eq!(id.len(), 36); + assert_ne!(id, style_id(4, "Gold")); + } +} diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 00815e2c83..bbeed5b5ee 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -85,7 +85,7 @@ pub(crate) fn blend_long_value(m: BlendMode) -> Value { Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(n) } } -fn blend_value(m: BlendMode) -> Value { +pub(crate) fn blend_value(m: BlendMode) -> Value { let n = BLEND_NAMES.iter().find(|(b, _)| *b == m).map_or("Nrml", |(_, n)| n); Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(n) } } @@ -301,7 +301,19 @@ const KINDS: [(&str, &str); 10] = [ /// Parses `lfx2` block data: `(master switch, effects)`. pub fn parse_lfx2(data: &[u8]) -> Option<(bool, Vec)> { let (vd, _) = VersionedDescriptor::parse_prefix(data.get(4..)?).ok()?; - let d = vd.descriptor; + Some(effects_from_descriptor(&vd.descriptor)) +} + +/// Keys of an effects descriptor (`lfx2`, a style library's `Lefx`) that +/// [`effects_from_descriptor`] reads, or that carry no effect of their own. +pub fn is_known_effects_key(key: &[u8]) -> bool { + KINDS.iter().any(|(single, multi)| single.as_bytes() == key || (!multi.is_empty() && multi.as_bytes() == key)) + || matches!(key, b"masterFXSwitch" | b"Scl " | b"numModifyingFX") +} + +/// The effects of an effects descriptor (the body of `lfx2`, a style library's `Lefx`): +/// `(master switch, effects)`. +pub fn effects_from_descriptor(d: &Descriptor) -> (bool, Vec) { let master = !matches!(d.get("masterFXSwitch"), Some(Value::Boolean(false))); let mut out = Vec::new(); for (single, multi) in KINDS { @@ -317,7 +329,7 @@ pub fn parse_lfx2(data: &[u8]) -> Option<(bool, Vec)> { } } _ => { - if let Some(x) = get_desc(&d, single) + if let Some(x) = get_desc(d, single) && let Some(e) = parse_one(single, x) { out.push(e); @@ -325,7 +337,7 @@ pub fn parse_lfx2(data: &[u8]) -> Option<(bool, Vec)> { } } } - Some((master, out)) + (master, out) } fn unit(u: &[u8; 4], v: f32) -> Value { @@ -511,6 +523,13 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) { /// Serializes effects as `lfx2` block data. pub fn write_lfx2(master: bool, items: &[Effect]) -> Vec { + let mut out = 0u32.to_be_bytes().to_vec(); + out.extend(VersionedDescriptor::new(effects_descriptor(master, items)).to_bytes()); + out +} + +/// Effects as an effects descriptor (the body of `lfx2`, a style library's `Lefx`). +pub fn effects_descriptor(master: bool, items: &[Effect]) -> Descriptor { let mut d = Descriptor::new("null").with("Scl ", unit(b"#Prc", 100.0)).with("masterFXSwitch", Value::Boolean(master)); for (single, multi) in KINDS { let of_kind: Vec = items.iter().map(write_one).filter(|(k, _)| *k == single).map(|(_, d)| d).collect(); @@ -522,9 +541,7 @@ pub fn write_lfx2(master: bool, items: &[Effect]) -> Vec { _ => d = d.with(single, Value::Descriptor(of_kind.into_iter().next().unwrap_or_default())), } } - let mut out = 0u32.to_be_bytes().to_vec(); - out.extend(VersionedDescriptor::new(d).to_bytes()); - out + d } fn fixed(v: u32) -> f32 { diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 0a9acbc19a..533fd75b47 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -35,6 +35,7 @@ pub mod abr_map; mod adjust_map; pub mod affinity; pub mod annotations_map; +pub mod asl_map; pub mod blocks; mod channel_map; pub mod comps_map; diff --git a/crates/psd/fuzz/Cargo.toml b/crates/psd/fuzz/Cargo.toml index 86f0f51c0c..260fb16c1c 100644 --- a/crates/psd/fuzz/Cargo.toml +++ b/crates/psd/fuzz/Cargo.toml @@ -36,6 +36,13 @@ test = false doc = false bench = false +[[bin]] +name = "asl" +path = "fuzz_targets/asl.rs" +test = false +doc = false +bench = false + [[bin]] name = "image_source_data" path = "fuzz_targets/image_source_data.rs" diff --git a/crates/psd/fuzz/fuzz_targets/asl.rs b/crates/psd/fuzz/fuzz_targets/asl.rs new file mode 100644 index 0000000000..cbf2c9169c --- /dev/null +++ b/crates/psd/fuzz/fuzz_targets/asl.rs @@ -0,0 +1,15 @@ +//! Fuzz the `.asl` style library parser: any input must return `Ok` or `Err`, never panic, and +//! anything that parses must write back and parse to the same number of styles. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_psd::asl; + +fuzz_target!(|data: &[u8]| { + if let Ok(f) = asl::parse(data) + && let Ok(bytes) = asl::write(&f) + { + let back = asl::parse(&bytes).expect("a written library parses"); + assert_eq!(back.styles.len(), f.styles.len()); + } +}); diff --git a/crates/psd/src/asl.rs b/crates/psd/src/asl.rs new file mode 100644 index 0000000000..26cd27b9bc --- /dev/null +++ b/crates/psd/src/asl.rs @@ -0,0 +1,276 @@ +//! Photoshop layer style libraries (`.asl`, the Styles panel's Import Styles… / Export Styles…). +//! +//! Layout of a version 3 style library: +//! +//! ```text +//! file := u16 2, "8BSL", u16 3 (sub-version), +//! u32 pattern section length, patterns (the `Patt` block record format), +//! u32 style count, style × count +//! style := u32 length, then that many bytes: identity, style descriptor, zero padding +//! identity:= versioned descriptor (class `null`): `Nm ` name, `Idnt` id (Unicode strings) +//! style descriptor := versioned descriptor (class `Styl`): `documentMode`, `Lefx` (the same +//! tree as a layer's `lfx2` block), optional `blendOptions` (`Md `, `Opct`, +//! `fillOpacity`) +//! ``` +//! +//! The reader skips up to three padding bytes after each style record whatever its length says, +//! so it reads files whose record length counts the padding and files whose length does not. +//! The writer counts the padding in the record length, which reads correctly either way. +//! +//! Malformed input is an error, never a panic. A pattern section that doesn't decode is reported +//! in [`AslFile::pattern_error`] and skipped (its length is known), so the styles still load. + +use crate::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor}; +use crate::error::{PsdError, Result}; +use crate::patterns::{PsdPattern, parse_pattern_block, write_pattern_block}; + +/// Most styles read from one file. +pub const MAX_STYLES: usize = 10_000; + +/// One style of a library. +#[derive(Debug, Clone, PartialEq)] +pub struct AslStyle { + /// The name shown in the Styles panel (may be empty or repeated). + pub name: String, + /// The style's unique id (usually a UUID). + pub id: String, + /// The style descriptor (class `Styl`): `Lefx`, `blendOptions`, `documentMode`. + pub style: Descriptor, +} + +/// A parsed style library. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AslFile { + /// Patterns embedded for the styles' Pattern Overlays, pattern strokes and bevel textures. + pub patterns: Vec, + /// Why the pattern section was skipped, if it didn't decode (read only). + pub pattern_error: Option, + /// Styles in file order. + pub styles: Vec, +} + +struct Rd<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Rd<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self.p.checked_add(n).filter(|&e| e <= self.b.len()).ok_or(PsdError::UnexpectedEof { offset: self.p, needed: n })?; + let s = self.b.get(self.p..end).ok_or(PsdError::UnexpectedEof { offset: self.p, needed: n })?; + self.p = end; + Ok(s) + } + fn u16(&mut self) -> Result { + let b = self.take(2)?; + Ok(u16::from_be_bytes([b[0], b[1]])) + } + fn u32(&mut self) -> Result { + let b = self.take(4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + fn remaining(&self) -> usize { + self.b.len().saturating_sub(self.p) + } +} + +fn text(d: &Descriptor, key: &str) -> String { + match d.get(key) { + Some(Value::Text(t)) => t.to_string_lossy(), + _ => String::new(), + } +} + +fn read_style(body: &[u8]) -> Result { + let (identity, used) = VersionedDescriptor::parse_prefix(body)?; + let rest = body.get(used..).ok_or(PsdError::UnexpectedEof { offset: used, needed: 4 })?; + let (style, _) = VersionedDescriptor::parse_prefix(rest)?; + let id = &identity.descriptor; + Ok(AslStyle { name: text(id, "Nm "), id: text(id, "Idnt"), style: style.descriptor }) +} + +/// Parses an `.asl` style library. +pub fn parse(data: &[u8]) -> Result { + let mut r = Rd { b: data, p: 0 }; + let version = r.u16()?; + let sig = r.take(4)?; + if sig != b"8BSL" { + return Err(PsdError::InvalidSignature { expected: "8BSL", found: [sig[0], sig[1], sig[2], sig[3]] }); + } + let sub = r.u16()?; + if version != 2 || sub != 3 { + return Err(PsdError::Unsupported(format!("style library version {version}.{sub} (only 2.3, Photoshop 6 and later)"))); + } + let plen = r.u32()? as usize; + let section = r.take(plen)?; + let (patterns, pattern_error) = match parse_pattern_block(section) { + Ok(p) => (p, None), + Err(e) => (Vec::new(), Some(e.to_string())), + }; + let count = r.u32()? as usize; + // Every style needs at least its own length word. + if count > r.remaining() / 4 { + return Err(PsdError::invalid(format!("{count} styles cannot fit in the remaining {} bytes", r.remaining()))); + } + if count > MAX_STYLES { + return Err(PsdError::LimitExceeded("too many styles in one library")); + } + let mut styles = Vec::with_capacity(count); + for _ in 0..count { + let len = r.u32()? as usize; + let body = r.take(len)?; + styles.push(read_style(body)?); + let pad = (4 - len % 4) % 4; + r.p = r.p.saturating_add(pad).min(data.len()); + } + Ok(AslFile { patterns, pattern_error: pattern_error.filter(|_| !section.is_empty()), styles }) +} + +/// Writes a version 3 `.asl` style library. +pub fn write(file: &AslFile) -> Result> { + let mut out = Vec::new(); + out.extend_from_slice(&2u16.to_be_bytes()); + out.extend_from_slice(b"8BSL"); + out.extend_from_slice(&3u16.to_be_bytes()); + let patterns = write_pattern_block(&file.patterns)?; + let plen = u32::try_from(patterns.len()).map_err(|_| PsdError::LimitExceeded("pattern section larger than 4 GB"))?; + out.extend_from_slice(&plen.to_be_bytes()); + out.extend_from_slice(&patterns); + let count = u32::try_from(file.styles.len()).map_err(|_| PsdError::LimitExceeded("too many styles"))?; + out.extend_from_slice(&count.to_be_bytes()); + for s in &file.styles { + let identity = + Descriptor::new("null").with("Nm ", Value::Text(UnicodeString::new_nul(&s.name))).with("Idnt", Value::Text(UnicodeString::new_nul(&s.id))); + let mut body = VersionedDescriptor::new(identity).to_bytes(); + body.extend(VersionedDescriptor::new(s.style.clone()).to_bytes()); + body.resize(body.len().div_ceil(4) * 4, 0); + let len = u32::try_from(body.len()).map_err(|_| PsdError::LimitExceeded("style larger than 4 GB"))?; + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&body); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::Id; + + fn pattern() -> PsdPattern { + PsdPattern { + mode: 3, + width: 3, + height: 2, + name: "Dots".into(), + id: "a1b2".into(), + palette: None, + depth: 8, + channels: vec![vec![1, 2, 3, 4, 5, 6], vec![9; 6], vec![200; 6]], + alpha: None, + } + } + + fn style(name: &str) -> AslStyle { + let lefx = Descriptor::new("null").with("masterFXSwitch", Value::Boolean(true)).with( + "SoFi", + Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 50.0 })), + ); + let blend = Descriptor::new("blendOptions") + .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) + .with("fillOpacity", Value::UnitFloat { unit: *b"#Prc", value: 25.0 }); + AslStyle { + name: name.into(), + id: format!("id-{name}"), + style: Descriptor::new("Styl") + .with("documentMode", Value::Descriptor(Descriptor::new("documentMode"))) + .with("Lefx", Value::Descriptor(lefx)) + .with("blendOptions", Value::Descriptor(blend)), + } + } + + fn sample() -> AslFile { + AslFile { patterns: vec![pattern()], pattern_error: None, styles: vec![style("Gold"), style(""), style("Ünïcode ✓")] } + } + + #[test] + fn round_trip() { + let f = sample(); + let b = write(&f).unwrap(); + assert_eq!(&b[..8], b"\0\x028BSL\0\x03"); + assert_eq!(b.len() % 4, 0); + assert_eq!(parse(&b).unwrap(), f); + let empty = AslFile::default(); + assert_eq!(parse(&write(&empty).unwrap()).unwrap(), empty); + } + + #[test] + fn record_lengths_without_padding_also_read() { + // A writer that leaves the padding out of each record length. + let f = sample(); + let mut out = write(&AslFile { styles: vec![], ..f.clone() }).unwrap(); + out.truncate(out.len() - 4); + out.extend_from_slice(&(f.styles.len() as u32).to_be_bytes()); + for s in &f.styles { + let identity = + Descriptor::new("null").with("Nm ", Value::Text(UnicodeString::new_nul(&s.name))).with("Idnt", Value::Text(UnicodeString::new_nul(&s.id))); + let mut body = VersionedDescriptor::new(identity).to_bytes(); + body.extend(VersionedDescriptor::new(s.style.clone()).to_bytes()); + body.push(7); // odd length + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + let pad = (4 - body.len() % 4) % 4; + out.extend_from_slice(&body); + out.extend(std::iter::repeat_n(0, pad)); + } + assert_eq!(parse(&out).unwrap().styles, f.styles); + } + + #[test] + fn bad_pattern_section_is_skipped() { + let mut b = write(&AslFile { patterns: vec![], pattern_error: None, styles: vec![style("A")] }).unwrap(); + // Replace the empty pattern section with 8 bytes of garbage. + let tail = b.split_off(12); + b.truncate(8); + b.extend_from_slice(&8u32.to_be_bytes()); + b.extend_from_slice(&[0, 0, 0, 4, 0xff, 0xff, 0xff, 0xff]); + b.extend(tail); + let f = parse(&b).unwrap(); + assert!(f.pattern_error.is_some()); + assert_eq!(f.styles.len(), 1); + } + + #[test] + fn hostile_input_errors() { + assert!(parse(b"").is_err()); + assert!(parse(b"\0\x028BSL\0\x02").is_err()); + assert!(parse(b"\0\x028BPS\0\x03").is_err()); + // A count far beyond the data. + let mut b = b"\0\x028BSL\0\x03\0\0\0\0".to_vec(); + b.extend_from_slice(&u32::MAX.to_be_bytes()); + assert!(parse(&b).is_err()); + // A pattern length beyond the data. + assert!(parse(b"\0\x028BSL\0\x03\xff\xff\xff\xff").is_err()); + let b = write(&sample()).unwrap(); + for cut in 0..b.len() { + assert!(parse(&b[..cut]).is_err(), "cut at {cut}"); + } + for i in 0..b.len() { + for x in [0x5a, 0xff, 0x01] { + let mut c = b.clone(); + c[i] ^= x; + let _ = parse(&c); + } + } + // Seeded random noise after a valid header. + let mut seed = 0x1234_5678u32; + for _ in 0..500 { + let mut c = b"\0\x028BSL\0\x03".to_vec(); + for _ in 0..(seed % 200) { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + c.push(seed as u8); + } + let _ = parse(&c); + } + } +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 16d539b948..70ee913e72 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -43,6 +43,7 @@ pub mod abr; pub mod aco; pub mod ase; +pub mod asl; pub mod blend; pub mod builder; pub mod compression; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e7de3409af..b1f141b4d5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3665,3 +3665,9 @@ navigator Zoom Zvětšení Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) + Import Styles… Importovat styly… + Export Selected Styles… Exportovat vybrané styly… + Export Styles… Exportovat styly… + {file} is not a style library (.asl) {file} není knihovna stylů (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Importován {n} styl ze souboru {file}|Importovány {n} styly ze souboru {file}|Importováno {n} stylů ze souboru {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Exportován {n} styl do souboru {file}|Exportovány {n} styly do souboru {file}|Exportováno {n} stylů do souboru {file} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ac313154db..5d5dc303de 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2691,3 +2691,9 @@ curves Smooth Glätten Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) + Import Styles… Stile importieren… + Export Selected Styles… Ausgewählte Stile exportieren… + Export Styles… Stile exportieren… + {file} is not a style library (.asl) {file} ist keine Stilbibliothek (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {n} Stil aus {file} importiert|{n} Stile aus {file} importiert +@plural Exported {n} style to {file}|Exported {n} styles to {file} {n} Stil nach {file} exportiert|{n} Stile nach {file} exportiert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e5be105597..4b84f79722 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3749,3 +3749,9 @@ curves Smooth Εξομάλυνση Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + Import Styles… Εισαγωγή στιλ… + Export Selected Styles… Εξαγωγή επιλεγμένων στιλ… + Export Styles… Εξαγωγή στιλ… + {file} is not a style library (.asl) Το {file} δεν είναι βιβλιοθήκη στιλ (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Εισήχθη {n} στιλ από το {file}|Εισήχθησαν {n} στιλ από το {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Εξήχθη {n} στιλ στο {file}|Εξήχθησαν {n} στιλ στο {file} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b986956544..843f0af201 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2732,3 +2732,9 @@ curves Smooth Suavizar Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) + Import Styles… Importar estilos… + Export Selected Styles… Exportar estilos seleccionados… + Export Styles… Exportar estilos… + {file} is not a style library (.asl) {file} no es una biblioteca de estilos (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Se importó {n} estilo de {file}|Se importaron {n} estilos de {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Se exportó {n} estilo a {file}|Se exportaron {n} estilos a {file} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f426b28c56..7c9ac011a8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3052,3 +3052,9 @@ curves Smooth Lisser Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + Import Styles… Importer des styles… + Export Selected Styles… Exporter les styles sélectionnés… + Export Styles… Exporter les styles… + {file} is not a style library (.asl) {file} n’est pas une bibliothèque de styles (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {n} style importé depuis {file}|{n} styles importés depuis {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} {n} style exporté vers {file}|{n} styles exportés vers {file} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 080e204aaa..0a130efdf8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -43,6 +43,8 @@ @plural {n} step|{n} steps {n} lépés @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} további figyelmeztetés az ecset-előbeállításokról) @plural {what} with {n} warning|{what} with {n} warnings {what} ({n} figyelmeztetés) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {n} stílus importálva. Forrás: {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} {n} stílus exportálva. Cél: {file} @id file.close Bezárás @id layer.layerMask.apply Alkalmazás @id edit.clear Kiürítés @@ -3970,3 +3972,7 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + Import Styles… Stílusok importálása… + Export Selected Styles… Kijelölt stílusok exportálása… + Export Styles… Stílusok exportálása… + {file} is not a style library (.asl) {file} nem stíluskönyvtár (.asl) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 420600cf3f..9cb9b3ed50 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3039,3 +3039,9 @@ curves Smooth Haluskan Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) + Import Styles… Impor Gaya… + Export Selected Styles… Ekspor Gaya Terpilih… + Export Styles… Ekspor Gaya… + {file} is not a style library (.asl) {file} bukan pustaka gaya (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {n} gaya diimpor dari {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} {n} gaya diekspor ke {file} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 410ab8f609..c527dc3735 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4015,3 +4015,9 @@ navigator Zoom Zoom Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) + Import Styles… Importa stili… + Export Selected Styles… Esporta stili selezionati… + Export Styles… Esporta stili… + {file} is not a style library (.asl) {file} non è una libreria di stili (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {n} stile importato da {file}|{n} stili importati da {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} {n} stile esportato in {file}|{n} stili esportati in {file} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7e5e65a639..67e03eebba 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3661,3 +3661,9 @@ curves Smooth スムーズ Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + Import Styles… スタイルを読み込み… + Export Selected Styles… 選択したスタイルを書き出し… + Export Styles… スタイルを書き出し… + {file} is not a style library (.asl) {file} はスタイルライブラリ (.asl) ではありません +@plural Imported {n} style from {file}|Imported {n} styles from {file} {file} からスタイル {n} 個を読み込みました +@plural Exported {n} style to {file}|Exported {n} styles to {file} {file} にスタイル {n} 個を書き出しました diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f48633c2cb..d3fe30587f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3221,3 +3221,9 @@ curves Smooth 매끄럽게 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + Import Styles… 스타일 가져오기… + Export Selected Styles… 선택한 스타일 내보내기… + Export Styles… 스타일 내보내기… + {file} is not a style library (.asl) {file}은(는) 스타일 라이브러리(.asl)가 아닙니다 +@plural Imported {n} style from {file}|Imported {n} styles from {file} {file}에서 스타일 {n}개를 가져왔습니다 +@plural Exported {n} style to {file}|Exported {n} styles to {file} 스타일 {n}개를 {file}(으)로 내보냈습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3778a8a1de..dfa30438e8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3811,3 +3811,9 @@ curves Smooth Vloeiend maken Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) + Import Styles… Stijlen importeren… + Export Selected Styles… Geselecteerde stijlen exporteren… + Export Styles… Stijlen exporteren… + {file} is not a style library (.asl) {file} is geen stijlbibliotheek (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {n} stijl geïmporteerd uit {file}|{n} stijlen geïmporteerd uit {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} {n} stijl geëxporteerd naar {file}|{n} stijlen geëxporteerd naar {file} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ce9387b623..d40e8d702a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3648,3 +3648,9 @@ curves Smooth Wygładź Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) + Import Styles… Importuj style… + Export Selected Styles… Eksportuj zaznaczone style… + Export Styles… Eksportuj style… + {file} is not a style library (.asl) {file} nie jest biblioteką stylów (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Zaimportowano {n} styl z {file}|Zaimportowano {n} style z {file}|Zaimportowano {n} stylów z {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Wyeksportowano {n} styl do {file}|Wyeksportowano {n} style do {file}|Wyeksportowano {n} stylów do {file} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 92e9c238b1..30bc901ec4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3841,3 +3841,9 @@ layerLabel No Color Sem cor Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) + Import Styles… Importar estilos… + Export Selected Styles… Exportar estilos selecionados… + Export Styles… Exportar estilos… + {file} is not a style library (.asl) {file} não é uma biblioteca de estilos (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} {n} estilo importado de {file}|{n} estilos importados de {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} {n} estilo exportado para {file}|{n} estilos exportados para {file} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7e1d12bbb1..6f8c318fd7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2996,3 +2996,9 @@ curves Smooth Сгладить Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + Import Styles… Импортировать стили… + Export Selected Styles… Экспортировать выбранные стили… + Export Styles… Экспортировать стили… + {file} is not a style library (.asl) {file} не является библиотекой стилей (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Импортирован {n} стиль из {file}|Импортировано {n} стиля из {file}|Импортировано {n} стилей из {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Экспортирован {n} стиль в {file}|Экспортировано {n} стиля в {file}|Экспортировано {n} стилей в {file} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 617ad81fd9..797f631f20 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3782,3 +3782,9 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile +@plural Imported {n} style from {file}|Imported {n} styles from {file} {file} dosyasından {n} stil içe aktarıldı +@plural Exported {n} style to {file}|Exported {n} styles to {file} {n} stil {file} dosyasına dışa aktarıldı + Import Styles… Stilleri İçe Aktar… + Export Selected Styles… Seçili Stilleri Dışa Aktar… + Export Styles… Stilleri Dışa Aktar… + {file} is not a style library (.asl) {file} bir stil kitaplığı değil (.asl) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 11a4caa9cf..dae143facc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3749,3 +3749,9 @@ curves Smooth Згладити Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + Import Styles… Імпортувати стилі… + Export Selected Styles… Експортувати вибрані стилі… + Export Styles… Експортувати стилі… + {file} is not a style library (.asl) {file} не є бібліотекою стилів (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Імпортовано {n} стиль з {file}|Імпортовано {n} стилі з {file}|Імпортовано {n} стилів з {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Експортовано {n} стиль до {file}|Експортовано {n} стилі до {file}|Експортовано {n} стилів до {file} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ca091c14d8..75d134ba16 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3535,3 +3535,9 @@ curves Smooth Làm mượt Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + Import Styles… Nhập kiểu… + Export Selected Styles… Xuất các kiểu đã chọn… + Export Styles… Xuất kiểu… + {file} is not a style library (.asl) {file} không phải là thư viện kiểu (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Đã nhập {n} kiểu từ {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Đã xuất {n} kiểu sang {file} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d0309166ce..91e10cdafc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2656,3 +2656,9 @@ curves Smooth 平滑 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + Import Styles… 导入样式… + Export Selected Styles… 导出所选样式… + Export Styles… 导出样式… + {file} is not a style library (.asl) {file} 不是样式库 (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} 已从 {file} 导入 {n} 个样式 +@plural Exported {n} style to {file}|Exported {n} styles to {file} 已将 {n} 个样式导出到 {file} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8698dfc078..35b53ee6be 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2641,3 +2641,9 @@ curves Smooth 平滑 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + Import Styles… 匯入樣式… + Export Selected Styles… 匯出所選樣式… + Export Styles… 匯出樣式… + {file} is not a style library (.asl) {file} 不是樣式庫 (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} 已從 {file} 匯入 {n} 個樣式 +@plural Exported {n} style to {file}|Exported {n} styles to {file} 已將 {n} 個樣式匯出至 {file} diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index e58961d9b5..58d72447a5 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -1,7 +1,8 @@ //! Preset files opened like documents (File › Open, drag-and-drop, the Brushes panel's //! Import Brushes…, the Preset Manager's Load…): Photoshop brushes (`.abr`) go to the brush //! library through `brush.presets.importAbr`, gradients (`.grd`) to the Gradients panel through -//! `gradient.presets.importGrd`, swatches (`.aco`, `.ase`) to the Swatches panel through +//! `gradient.presets.importGrd`, layer styles (`.asl`) to the Styles panel through +//! `style.presets.importAsl`, swatches (`.aco`, `.ase`) to the Swatches panel through //! `swatches.import`, keyboard shortcut sets (`.kys`) to Edit › Keyboard Shortcuts through //! [`crate::kys_import`], instead of opening as documents. @@ -13,7 +14,7 @@ use crate::PhotocraftApp; /// the Swatches panel through [`crate::swatches_ui::import_bytes`]). /// `.psp` is Photoshop's live `Keyboard Shortcuts.psp` (the same XML as a `.kys`); other `.psp` /// settings files are refused with a clear message. -pub const PRESET_EXTS: &[&str] = &["abr", "grd", "aco", "ase", "kys", "psp"]; +pub const PRESET_EXTS: &[&str] = &["abr", "grd", "asl", "aco", "ase", "kys", "psp"]; fn ext(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() @@ -39,6 +40,7 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option ("brush.presets.importAbr", "brushes"), "grd" => ("gradient.presets.importGrd", "gradients"), + "asl" => ("style.presets.importAsl", "styles"), "aco" | "ase" => return Some(crate::swatches_ui::import_bytes(app, name, bytes, false).map(|_| ())), "kys" | "psp" => return Some(crate::kys_import::open_bytes(app, name, bytes).map(|_| ())), _ => return None, @@ -60,10 +62,10 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option = r.get("warnings").and_then(|w| serde_json::from_value(w.clone()).ok()).unwrap_or_default(); app.ui.status_error = false; - let (one, other) = if what == "brushes" { - ("Imported {n} brush from {file}", "Imported {n} brushes from {file}") - } else { - ("Imported {n} gradient from {file}", "Imported {n} gradients from {file}") + let (one, other) = match what { + "brushes" => ("Imported {n} brush from {file}", "Imported {n} brushes from {file}"), + "styles" => ("Imported {n} style from {file}", "Imported {n} styles from {file}"), + _ => ("Imported {n} gradient from {file}", "Imported {n} gradients from {file}"), }; let imported = crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, one, other), &[("file", &stem)]); app.ui.status = match warnings.first() { @@ -78,6 +80,9 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option = None; + if ui.button(tl!("Import Styles…")).clicked() { + act = Some("import"); + } + if ui.add_enabled(selected.is_some(), egui::Button::new(tl!("Export Selected Styles…"))).clicked() { + act = Some("selected"); + } + if ui.add_enabled(any, egui::Button::new(tl!("Export Styles…"))).clicked() { + act = Some("all"); + } + let Some(a) = act else { return }; + ui.close(); + let r = match a { + "import" => app.pick_file_bytes_filtered(&["asl"], |app, name, bytes| match crate::preset_files_ui::open(app, &name, &bytes) { + Some(r) => r.map(|()| Value::Null), + None => Err(crate::i18n::fmt(tl!("{file} is not a style library (.asl)"), &[("file", &crate::file_open::display_name(&name))])), + }), + "selected" => match selected { + Some(name) => export_styles(app, json!({"styles": [name.clone()]}), &name), + None => Ok(Value::Null), + }, + _ => export_styles(app, json!({}), "Styles"), + }; + if let Err(e) = r + && e != "cancelled" + { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// Builds the `.asl` through `style.presets.exportAsl`, then asks where to save it. +fn export_styles(app: &mut PhotocraftApp, params: Value, suggested: &str) -> Result { + let r = app.run("style.presets.exportAsl", params)?; + let bytes = r["data"].as_str().and_then(photocraft_engine::paint::tile::b64_decode).ok_or("the export returned no data")?; + let n = r["count"].as_u64().unwrap_or(0); + let stem: String = suggested.chars().filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')).collect(); + let stem = if stem.trim().is_empty() { "Styles".to_string() } else { stem.trim().to_string() }; + app.pick_save(&format!("{stem}.asl"), move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + app.ui.status_error = false; + app.ui.status = crate::i18n::fmt( + &crate::i18n::trn(crate::i18n::current(), n, "Exported {n} style to {file}", "Exported {n} styles to {file}"), + &[("file", &crate::file_open::display_name(&path))], + ); + Ok(json!({"path": path, "count": n})) + }) +} + // ------------------------------------------------------------------ Shapes pub fn shapes_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 024d596f1e..402f0c702f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (indexed BMP palette mode; `.asl` layer style libraries read and written; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -131,7 +131,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | | ICC profiles | R | R | Own CMS | | Actions `.atn` | R/W | no | AUTO-219-2 | -| Styles `.asl` | R/W | no | #1693 | +| Styles `.asl` | R/W | R/W | `psd/src/asl.rs`, `io/src/asl_map.rs`; Styles panel Import/Export Styles…, `style.presets.importAsl`/`exportAsl` (#1051) | | Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | | C2PA Content Credentials | R/W | no | #1050 | @@ -141,6 +141,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) | +| 2026-10-12 | minor | `.asl` layer style libraries: import and export with embedded patterns and blending options | | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | | 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` | diff --git a/docs/gaps.md b/docs/gaps.md index 2ceba09952..528d984e23 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G10: `.asl` style libraries landed; G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -183,8 +183,8 @@ what an agent can't finish alone. - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), DPX/Cineon (#1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, - .shc, .csh presets (FILE-215-15); TGA/PNG alpha channels on open (#2225); C2PA (#1050). + Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, + .shc, .csh presets (.asl landed 2026-10-12) (FILE-215-15); TGA/PNG alpha channels on open (#2225); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). - **Estimate:** 120–220 h. @@ -225,6 +225,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G10: `.asl` layer style library import and export landed | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted |