From 8317acae04b80dbe985f24cb2e6eca60c1e65e34 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 20 Oct 2026 22:50:58 +0200 Subject: [PATCH] Integration: Lefx class, linear style names, keep built-in groups, dialog opacity (#4049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export wrote each style's effects object with class `null` (a layer's `lfx2` class). Style libraries use class `Lefx` (Krita's reader takes a `null` descriptor as the start of the next style). Test: exported_effects_object_has_class_lefx. - Repeated style names were numbered by rescanning every taken name for every candidate: an .asl of 10 000 styles all named "A" (about 0.7 MB) froze the import for minutes. Now a set plus a next number per name, as the .abr import does. Test: many_identical_names_import_quickly. - Importing "Basics.asl" (or any file named like a built-in group) replaced and deleted that built-in group, with no undo. It now goes to "Basics 2". - The Layer Style dialog's New Style… saved the layer's opacity instead of the dialog's pending one, and picking a style swatch there ignored the style's opacity. - Serbian strings for the Styles panel menu (sr.tsv came with main), and a rustfmt line in the merged workspace.rs. Co-authored-by: Brandon Thomas <39263+echelon@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/workspace.rs | 8 +++- crates/engine/src/style_asl_cmds.rs | 19 ++++++++- crates/io/src/asl_map.rs | 66 +++++++++++++++++++++++------ crates/ui-egui/src/i18n/sr.tsv | 7 +++ crates/ui-egui/src/layer_style.rs | 7 +++ docs/file-format-parity.md | 2 +- 6 files changed, 92 insertions(+), 17 deletions(-) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 658cd8bd50..fca762a9c3 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -380,7 +380,13 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool { fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" | "style.presets.importAsl" => { + "actions.load" + | "brush.presets.importAbr" + | "brush.presets.importImage" + | "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() } diff --git a/crates/engine/src/style_asl_cmds.rs b/crates/engine/src/style_asl_cmds.rs index e942df7d35..8d80c816a1 100644 --- a/crates/engine/src/style_asl_cmds.rs +++ b/crates/engine/src/style_asl_cmds.rs @@ -44,6 +44,9 @@ fn import(s: &mut Session, p: &Value) -> Result { None => true, Some(v) => v.as_bool().ok_or_else(|| bad(IMPORT, "`replace` must be a boolean"))?, }; + // A file named like a built-in group ("Basics.asl") goes to "Basics 2": built-ins are never replaced. + let builtin: Vec = crate::presets::styles::builtin().into_iter().map(|g| g.name).collect(); + let group = if builtin.contains(&group) { (2..).map(|i| format!("{group} {i}")).find(|g| !builtin.contains(g)).unwrap_or(group) } else { group }; 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")); @@ -143,7 +146,7 @@ pub fn specs() -> Vec { 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}"##, + params: r##"{"path":".asl file"?,"data":base64 bytes?,"group":string?=file name (a built-in group's name becomes " 2": built-ins are never replaced),"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, @@ -201,6 +204,20 @@ mod tests { assert_eq!(s.presets.styles.len(), groups); } + #[test] + fn a_file_named_like_a_built_in_group_keeps_that_group() { + let mut s = session(); + let basics = s.presets.styles.iter().find(|g| g.name == "Basics").unwrap().items.clone(); + let data = s.execute(EXPORT, json!({"group": "Text Effects"})).unwrap()["data"].as_str().unwrap().to_string(); + for _ in 0..2 { + let r = s.execute(IMPORT, json!({"data": data, "group": "Basics"})).unwrap(); + assert_eq!(r["group"], "Basics 2"); + } + assert_eq!(s.presets.styles.iter().find(|g| g.name == "Basics").unwrap().items, basics); + // Re-importing replaces the copy instead of adding another. + assert_eq!(s.presets.styles.iter().filter(|g| g.name.starts_with("Basics")).count(), 2); + } + #[test] fn blending_options_survive_and_apply() { let mut s = session(); diff --git a/crates/io/src/asl_map.rs b/crates/io/src/asl_map.rs index 7c254099a9..76e9e11757 100644 --- a/crates/io/src/asl_map.rs +++ b/crates/io/src/asl_map.rs @@ -9,12 +9,12 @@ //! 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 std::collections::{BTreeSet, HashMap, HashSet}; use photocraft_color::BlendMode; use photocraft_doc::{Effect, FxPaint, Pattern}; use photocraft_psd::asl::{self, AslFile}; -use photocraft_psd::descriptor::{Descriptor, Value}; +use photocraft_psd::descriptor::{Descriptor, Id, 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}; @@ -76,12 +76,27 @@ fn pct(d: &Descriptor, key: &str) -> Option { num(d.get(key)).filter(|v| v.is_finite()).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)) } -fn unique(name: &str, taken: &[String]) -> String { - let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n)); - if free(name) { - return name.to_string(); +/// Style names in use within one file (ASCII-lowercased), with the next number to try for each +/// repeated name, so a file of identical names stays linear. +#[derive(Default)] +struct Taken { + names: HashSet, + next: HashMap, +} + +impl Taken { + /// `name`, else `name 2`, `name 3`… (case-insensitive); in use from then on. + fn unique(&mut self, name: &str) -> String { + let key = name.to_ascii_lowercase(); + if self.names.insert(key.clone()) { + return name.to_string(); + } + let from = self.next.get(&key).copied().unwrap_or(2); + let i = (from..).find(|i| !self.names.contains(&format!("{key} {i}"))).unwrap_or(from); + self.names.insert(format!("{key} {i}")); + self.next.insert(key, i + 1); + format!("{name} {i}") } - (2..).map(|i| format!("{name} {i}")).find(|n| free(n)).unwrap_or_else(|| name.to_string()) } /// Reads an `.asl` style library. @@ -95,12 +110,11 @@ pub fn read_asl(bytes: &[u8]) -> Result { if skipped_patterns > 0 { out.warnings.push(format!("{skipped_patterns} empty or unreadable patterns were skipped")); } - let mut taken: Vec = Vec::new(); + let mut taken = Taken::default(); let mut missing: BTreeSet = BTreeSet::new(); for (i, s) in file.styles.iter().enumerate() { let base = if s.name.trim().is_empty() { format!("Style {}", i + 1) } else { s.name.trim().to_string() }; - let name = unique(&base, &taken); - taken.push(name.clone()); + let name = taken.unique(&base); let mut style = AslStyle { name: name.clone(), effects: Vec::new(), blend: None, opacity: None, fill_opacity: None }; let mut left_out: Vec = Vec::new(); if let Some(fx) = get_desc(&s.style, "Lefx") { @@ -168,9 +182,11 @@ pub fn write_asl(styles: &[AslStyle], patterns: &[Pattern]) -> Result, S .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))); + // A style library's effects object has class `Lefx` (a layer's `lfx2` uses `null`). + let mut lefx = effects_descriptor(true, &s.effects); + lefx.class_id = Id::new("Lefx"); + let mut d = + Descriptor::new("Styl").with("documentMode", Value::Descriptor(Descriptor::new("documentMode"))).with("Lefx", Value::Descriptor(lefx)); if s.blend.is_some() || s.opacity.is_some() || s.fill_opacity.is_some() { d = d.with( "blendOptions", @@ -194,7 +210,7 @@ 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_psd::descriptor::UnicodeString; use photocraft_raster::Surface; fn pattern(id: &str, s: SampleType) -> Pattern { @@ -363,6 +379,28 @@ mod tests { assert!(read_asl(&photocraft_psd::grd::write(&[])).is_err()); } + #[test] + fn exported_effects_object_has_class_lefx() { + let bytes = write_asl(&styles(), &[]).unwrap(); + let file = asl::parse(&bytes).unwrap(); + for s in &file.styles { + let lefx = get_desc(&s.style, "Lefx").unwrap(); + assert_eq!(lefx.class_id, Id::new("Lefx"), "{}", s.name); + } + } + + #[test] + fn many_identical_names_import_quickly() { + let names = vec!["A"; asl::MAX_STYLES]; + let bytes = synthetic(Descriptor::new("Lefx"), None, &names); + let start = std::time::Instant::now(); + let imp = read_asl(&bytes).unwrap(); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + assert_eq!(imp.styles.len(), asl::MAX_STYLES); + assert_eq!(imp.styles[1].name, "A 2"); + assert_eq!(imp.styles.last().unwrap().name, format!("A {}", asl::MAX_STYLES)); + } + #[test] fn style_ids_are_uuid_shaped() { let id = style_id(3, "Gold"); diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 5530f81ce6..09113a7879 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4321,3 +4321,10 @@ shape Appearance Izgled # DDS Options (#1327) DDS Options DDS opcije Generate Mipmaps Generiši mipmape +# Styles panel: Import / Export Styles (.asl) + Import Styles… Uvezi stilove… + Export Selected Styles… Izvezi izabrane stilove… + Export Styles… Izvezi stilove… + {file} is not a style library (.asl) {file} nije biblioteka stilova (.asl) +@plural Imported {n} style from {file}|Imported {n} styles from {file} Uvezen je {n} stil iz {file}|Uvezena su {n} stila iz {file}|Uvezeno je {n} stilova iz {file} +@plural Exported {n} style to {file}|Exported {n} styles to {file} Izvezen je {n} stil u {file}|Izvezena su {n} stila u {file}|Izvezeno je {n} stilova u {file} diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index d876c18278..108327ed7d 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -716,6 +716,9 @@ fn apply_style_preset(app: &PhotocraftApp, f: &mut Map, name: &st if let Some(fo) = style.fill_opacity { set_param(f, BLENDING, "fillOpacity", json!((fo * 100.0).round())); } + if let Some(o) = style.opacity { + set_param(f, BLENDING, "opacity", json!((o * 100.0).round())); + } } /// Saves the dialog's pending state (enabled instances + blending page) as a style preset. @@ -742,6 +745,7 @@ fn save_new_style(app: &mut PhotocraftApp, f: &Map) { "effects": entries, "blend": bo.get("blend").cloned().unwrap_or(Value::Null), "fillOpacity": bo.get("fillOpacity").cloned().unwrap_or(Value::Null), + "opacity": bo.get("opacity").cloned().unwrap_or(Value::Null), }), ) { app.ui.status = crate::i18n::fmt(tl!("Style {name} saved"), &[("name", v["name"].as_str().unwrap_or("Style"))]); @@ -2155,9 +2159,11 @@ mod tests { let id = app.session.active().unwrap().active_layer.unwrap(); let mut f = initial_fields(app.session.active().unwrap().doc.layer(id).unwrap(), Some("stroke"), 120.0); set_param(&mut f, "fx1", "size", json!(9)); + set_param(&mut f, BLENDING, "opacity", json!(40)); save_new_style(&mut app, &f); let preset = app.session.presets.styles.iter().flat_map(|g| g.items.iter()).find(|st| st.name == "Style").expect("the preset is created"); assert_eq!(preset.effects.len(), 1, "only the enabled instance is saved"); + assert_eq!(preset.opacity, Some(0.4), "the pending opacity is saved, not the layer's"); // A second layer gets the pending style back through the swatch path. app.run("layer.new.layer", json!({})).unwrap(); let mut f2 = initial_fields(app.session.active().unwrap().doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap(), None, 120.0); @@ -2166,6 +2172,7 @@ mod tests { // Swatch clicks update the dialog fields only; document is untouched until confirm. assert_eq!(effects_of(&f2).len(), 1); assert_eq!(effects_of(&f2)[0]["params"]["size"], json!(9.0)); + assert_eq!(f2["p:blendingOptions"]["opacity"], json!(40.0), "the swatch brings the style's opacity"); // Confirming applies the style to the layer. confirm(&mut app, &f2).unwrap(); let st = app.session.active().unwrap(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 435bfa80b6..2d8b946e83 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -172,7 +172,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | | 2026-10-12 | minor | Presets table: Contours `.shc` read and write through the Contour Editor (FILE-215-15); `.csh` and `.tpl` stay missing | | 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-20 | 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 | PSD smart filters: Filter Gallery (`GEfc`) maps to `filter.filterGallery` when every effect is modelled (Chrome so far), editable from the Layers panel | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning |