mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 17:51:59 +01:00
Integration: Lefx class, linear style names, keep built-in groups, dialog opacity (#4049)
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Brandon Thomas
Claude Opus 5.5
parent
53d780f566
commit
8317acae04
@@ -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()
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<String> = 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<CommandSpec> {
|
||||
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 "<name> 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();
|
||||
|
||||
+52
-14
@@ -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<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();
|
||||
/// 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<String>,
|
||||
next: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
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<AslImport, String> {
|
||||
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 taken = Taken::default();
|
||||
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 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<String> = Vec::new();
|
||||
if let Some(fx) = get_desc(&s.style, "Lefx") {
|
||||
@@ -168,9 +182,11 @@ pub fn write_asl(styles: &[AslStyle], patterns: &[Pattern]) -> Result<Vec<u8>, 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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -716,6 +716,9 @@ fn apply_style_preset(app: &PhotocraftApp, f: &mut Map<String, Value>, 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<String, Value>) {
|
||||
"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();
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user