Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 31e523a0f4 feat(styles): import and export .asl layer style libraries (#1051)
The Styles panel's Import Styles… / Export Styles… read and write
Photoshop version-3 style libraries (8BSL): embedded patterns, each
style's Lefx effects tree and its blending options.

- photocraft-psd: asl reader/writer (panic-free, hostile input → Err)
- photocraft-io: asl_map onto effects via the shared lfx2 descriptor code
- engine: style.presets.importAsl / style.presets.exportAsl; StylePreset
  keeps opacity; apply copies stroke/bevel texture patterns
- UI: Styles panel menu, .asl through File › Open and drag and drop
- automation: import/export paths follow the filesystem policy
- asl fuzz target

Refs #1693

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 11:52:08 -04:00
39 changed files with 1236 additions and 27 deletions

No files matched your search

+1
View File
@@ -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 }
+2 -2
View File
@@ -15,11 +15,11 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// 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";
+2 -2
View File
@@ -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"];
+9 -2
View File
@@ -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, &params).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, &params).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"}})),
] {
+1
View File
@@ -1200,6 +1200,7 @@ fn build() -> Vec<CommandSpec> {
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());
+1
View File
@@ -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;
+17 -3
View File
@@ -25,6 +25,9 @@ pub struct StylePreset {
pub blend: Option<BlendMode>,
#[serde(default)]
pub fill_opacity: Option<f32>,
/// Layer opacity saved with the blending options (0..1).
#[serde(default)]
pub opacity: Option<f32>,
}
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::<Vec<_>>(),
"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<Value> {
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<Value> {
} 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<Value> {
},
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<CommandSpec> {
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<CommandSpec> {
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,
+1
View File
@@ -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"));
+310
View File
@@ -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<String>) -> 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<Value> {
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<Value> {
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<StylePreset> = 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::<Result<_>>()?
}
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<photocraft_doc::Effect> = chosen.iter().flat_map(|st| st.effects.iter().cloned()).collect();
let mut patterns: Vec<photocraft_doc::Pattern> = 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<AslStyle> = 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<CommandSpec> {
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());
}
}
+372
View File
@@ -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<Effect>,
/// Blending options, when the style was saved with them.
pub blend: Option<BlendMode>,
/// 0..1.
pub opacity: Option<f32>,
/// 0..1.
pub fill_opacity: Option<f32>,
}
/// The result of reading a library.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AslImport {
pub styles: Vec<AslStyle>,
/// Patterns embedded in the file.
pub patterns: Vec<Pattern>,
/// What was left out, for the user.
pub warnings: Vec<String>,
}
/// 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<f32> {
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<AslImport, String> {
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<String> = Vec::new();
let mut missing: BTreeSet<String> = 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<String> = 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<String> = 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<Vec<u8>, 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<Effect> {
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<AslStyle> {
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<Descriptor>, names: &[&str]) -> Vec<u8> {
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"));
}
}
+24 -7
View File
@@ -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<Effect>)> {
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<Effect>) {
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<Effect>)> {
}
}
_ => {
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<Effect>)> {
}
}
}
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<u8> {
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<Descriptor> = 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<u8> {
_ => 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 {
+1
View File
@@ -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;
+7
View File
@@ -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"
+15
View File
@@ -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());
}
});
+276
View File
@@ -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<PsdPattern>,
/// Why the pattern section was skipped, if it didn't decode (read only).
pub pattern_error: Option<String>,
/// Styles in file order.
pub styles: Vec<AslStyle>,
}
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<u16> {
let b = self.take(2)?;
Ok(u16::from_be_bytes([b[0], b[1]]))
}
fn u32(&mut self) -> Result<u32> {
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<AslStyle> {
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<AslFile> {
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<Vec<u8>> {
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);
}
}
}
+1
View File
@@ -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;
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 1000 and column 18.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 4 and column 47.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 4 and column 47.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 2591 and column 53.
+6
View File
@@ -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} 個を書き出しました
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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}(으)로 내보냈습니다
Can't render this file because it contains an unexpected character in line 3100 and column 53.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 8 and column 34.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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)
Can't render this file because it contains an unexpected character in line 8 and column 34.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 1356 and column 18.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 2533 and column 53.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12 -6
View File
@@ -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<Result<
let (cmd, what) = match ext(name).as_str() {
"abr" => ("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<Result<
let n = r.get("count").and_then(Value::as_u64).unwrap_or(0);
let warnings: Vec<String> = 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<Result<
app.ui.panels.brush_settings = true;
app.ui.brush_tab = 1;
}
if what == "styles" {
app.ui.presets_ui.styles = true;
}
}))
}
@@ -89,6 +94,7 @@ mod tests {
fn recognises_preset_files() {
assert!(is_preset_file("/a/b/Set.ABR"));
assert!(is_preset_file("sunsets.grd"));
assert!(is_preset_file("Comic Styles.ASL"));
assert!(is_preset_file("Brand.ACO"));
assert!(is_preset_file("exchange.ase"));
assert!(!is_preset_file("photo.psd"));
+63
View File
@@ -756,6 +756,14 @@ fn float_window(
if crate::icons::button(ui, "x", 20.0, false, tl!("Close")).clicked() {
close = true;
}
if key == "styles" {
let resp = crate::icons::button(ui, "ellipsis", 20.0, false, tl!("Panel menu"));
egui::Popup::menu(&resp).show(|ui| {
crate::widgets::style_spectrum_popup_menu(ui);
ui.set_min_width(180.0);
styles_menu(app, ui);
});
}
});
});
crate::widgets::hairline(ui);
@@ -830,6 +838,61 @@ pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
}
/// The Styles panel menu ("…"): Import Styles… / Export Selected Styles… / Export Styles…
/// (Photoshop layer style libraries, `.asl`).
fn styles_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let any = app.session.presets.styles.iter().any(|g| !g.items.is_empty());
let selected = app.ui.presets_ui.selected.get("styles").cloned();
let mut act: Option<&str> = 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<Value, String> {
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) {
+3 -2
View File
@@ -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` |
+4 -3
View File
@@ -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 |