mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 21:26:50 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31e523a0f4 |
No files matched your search
@@ -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 }
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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"}})),
|
||||
] {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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"));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user