Files
photocraft/crates/io/src/effects_map.rs
T
David MannandClaude Opus 5.5 30c420761d Merge remote-tracking branch 'origin/main' into integration-batch-5
Conflicts with main's smart-filter and layer-style work, both sides kept:
- smart_map: main's 27 more filters and this batch's Clouds, Difference
  Clouds, Fibers and Lens Flare (45 known); the item colours are written for
  Pointillize, Tiles and the three coloured render filters.
- layer_style: main's new Stroke default (1 px inside black) with this
  batch's Overprint.
- psd_export: main's verbatim unknown adjustments with this batch's unpadded
  Gradient Map check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:50:16 +02:00

968 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Layer effects ↔ PSD `lfx2` (object-based effects descriptor) and legacy `lrFX`.
//!
//! Key names follow the descriptors Photoshop writes (documented by
//! reverse-engineering in MIT-licensed psd-tools / ag-psd).
use photocraft_color::{BlendMode, Color};
use photocraft_doc::adjust::CurvePoint;
use photocraft_doc::{
Bevel, BevelContour, BevelStyle, BevelTechnique, BevelTexture, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, Satin,
Shadow, StrokeFx, StrokePosition,
};
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
use crate::blocks::{
bool_of, color_from_desc, color_to_desc, enum_of, get_desc, gradient_desc, gradient_style, gradient_style_value, num, pattern_placement, pattern_ref,
pattern_ref_desc, with_pattern_placement,
};
const BLEND_NAMES: [(BlendMode, &str); 28] = [
(BlendMode::Normal, "Nrml"),
(BlendMode::Dissolve, "Dslv"),
(BlendMode::Darken, "Drkn"),
(BlendMode::Multiply, "Mltp"),
(BlendMode::ColorBurn, "CBrn"),
(BlendMode::LinearBurn, "linearBurn"),
(BlendMode::DarkerColor, "darkerColor"),
(BlendMode::Lighten, "Lghn"),
(BlendMode::Screen, "Scrn"),
(BlendMode::ColorDodge, "CDdg"),
(BlendMode::LinearDodge, "linearDodge"),
(BlendMode::LighterColor, "lighterColor"),
(BlendMode::Overlay, "Ovrl"),
(BlendMode::SoftLight, "SftL"),
(BlendMode::HardLight, "HrdL"),
(BlendMode::VividLight, "vividLight"),
(BlendMode::LinearLight, "linearLight"),
(BlendMode::PinLight, "pinLight"),
(BlendMode::HardMix, "hardMix"),
(BlendMode::Difference, "Dfrn"),
(BlendMode::Exclusion, "Xclu"),
(BlendMode::Subtract, "blendSubtraction"),
(BlendMode::Divide, "blendDivide"),
(BlendMode::Hue, "H "),
(BlendMode::Saturation, "Strt"),
(BlendMode::Color, "Clr "),
(BlendMode::Luminosity, "Lmns"),
(BlendMode::PassThrough, "passThrough"),
];
/// Long (string-ID) names some writers use instead of the four-character codes, for the modes
/// whose code is a short ID (`BlnM` `multiply` = `Mltp`). The others already use their long name.
const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [
(BlendMode::Normal, "normal"),
(BlendMode::Dissolve, "dissolve"),
(BlendMode::Darken, "darken"),
(BlendMode::Multiply, "multiply"),
(BlendMode::ColorBurn, "colorBurn"),
(BlendMode::Lighten, "lighten"),
(BlendMode::Screen, "screen"),
(BlendMode::ColorDodge, "colorDodge"),
(BlendMode::Overlay, "overlay"),
(BlendMode::SoftLight, "softLight"),
(BlendMode::HardLight, "hardLight"),
(BlendMode::Difference, "difference"),
(BlendMode::Exclusion, "exclusion"),
(BlendMode::Hue, "hue"),
(BlendMode::Saturation, "saturation"),
(BlendMode::Color, "color"),
(BlendMode::Luminosity, "luminosity"),
];
/// A blend-mode enum from either its four-character code or its long string ID.
pub(crate) fn blend_from_id(v: &[u8]) -> Option<BlendMode> {
BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)
}
fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode {
enum_of(d, key).and_then(blend_from_id).unwrap_or(default)
}
/// A `BlnM` enum with the long string ID where the mode has one (`multiply`, `overlay`), as
/// Photoshop writes smart-filter blend options; the four-character code otherwise.
pub(crate) fn blend_long_value(m: BlendMode) -> Value {
let n = BLEND_LONG_NAMES.iter().chain(&BLEND_NAMES).find(|(b, _)| *b == m).map_or("normal", |(_, n)| n);
Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(n) }
}
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) }
}
fn f(d: &Descriptor, key: &str, default: f32) -> f32 {
num(d.get(key)).map_or(default, |v| v as f32)
}
fn pct(d: &Descriptor, key: &str, default: f32) -> f32 {
f(d, key, default * 100.0) / 100.0
}
fn color(d: &Descriptor, key: &str) -> Color {
get_desc(d, key).and_then(color_from_desc).unwrap_or(Color::BLACK)
}
fn contour(d: &Descriptor, key: &str) -> Contour {
let Some(c) = get_desc(d, key) else { return Contour::Linear };
let name = match c.get("Nm ") {
Some(Value::Text(t)) => t.to_string_lossy(),
_ => String::new(),
};
let mut points = Vec::new();
let mut corners = Vec::new();
if let Some(Value::List(items)) = c.get("Crv ") {
for it in items {
if let Value::Descriptor(p) = it {
points.push(CurvePoint { input: f(p, "Hrzn", 0.0) / 255.0, output: f(p, "Vrtc", 0.0) / 255.0 });
// `Cnty` (continuity): false marks a corner point; absent means smooth.
corners.push(matches!(p.get("Cnty"), Some(Value::Boolean(false))));
}
}
}
if !corners.contains(&true) {
corners.clear();
}
let linear = points.len() == 2 && points[0].input == 0.0 && points[0].output == 0.0 && points[1].input == 1.0 && points[1].output == 1.0;
if points.is_empty() || (linear && (name.is_empty() || name == "Linear")) { Contour::Linear } else { Contour::Custom { name, points, corners } }
}
fn contour_value(c: &Contour) -> Value {
let (name, points) = match c {
Contour::Linear => ("Linear".to_string(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]),
Contour::Custom { name, points, .. } => (name.clone(), points.clone()),
};
let pts = points
.iter()
.enumerate()
.map(|(i, p)| {
let d = Descriptor::new("CrPt").with("Hrzn", Value::Double(f64::from(p.input * 255.0))).with("Vrtc", Value::Double(f64::from(p.output * 255.0)));
// Photoshop writes no `Cnty` (continuity) on smooth points; a corner carries `Cnty` false.
Value::Descriptor(if c.is_corner(i) { d.with("Cnty", Value::Boolean(false)) } else { d })
})
.collect();
Value::Descriptor(Descriptor::new("ShpC").with("Nm ", Value::Text(UnicodeString::new_nul(&name))).with("Crv ", Value::List(pts)))
}
fn common(d: &Descriptor, blend: BlendMode, opacity: f32) -> FxCommon {
FxCommon { enabled: !matches!(d.get("enab"), Some(Value::Boolean(false))), blend: blend_of(d, "Md ", blend), opacity: pct(d, "Opct", opacity) }
}
fn gradient_from(d: &Descriptor) -> Gradient {
let (stops, opacity_stops) = get_desc(d, "Grad").map(|g| crate::blocks::gradient_stops_with(g, crate::blocks::gradient_method(d))).unwrap_or_default();
let off = get_desc(d, "Ofst");
Gradient {
stops: if stops.is_empty() { Gradient::default().stops } else { stops },
opacity_stops,
style: gradient_style(d),
angle: f(d, "Angl", 90.0),
scale: pct(d, "Scl ", 1.0),
reverse: bool_of(d, "Rvrs"),
align: !matches!(d.get("Algn"), Some(Value::Boolean(false))),
offset: off.map_or((0.0, 0.0), |o| (pct(o, "Hrzn", 0.0), pct(o, "Vrtc", 0.0))),
}
}
fn parse_shadow(d: &Descriptor, inner: bool) -> Shadow {
Shadow {
common: common(d, BlendMode::Multiply, 0.75),
color: color(d, "Clr "),
angle: f(d, "lagl", 120.0),
use_global_light: !matches!(d.get("uglg"), Some(Value::Boolean(false))),
distance: f(d, "Dstn", 5.0),
spread: pct(d, "Ckmt", 0.0),
size: f(d, "blur", 5.0),
contour: contour(d, "TrnS"),
anti_alias: bool_of(d, "AntA"),
noise: pct(d, "Nose", 0.0),
knocks_out: !inner && !matches!(d.get("layerConceals"), Some(Value::Boolean(false))),
}
}
fn parse_glow(d: &Descriptor, inner: bool) -> Glow {
let paint = if d.get("Grad").is_some() { FxPaint::Gradient(gradient_from(d)) } else { FxPaint::Color(color(d, "Clr ")) };
Glow {
common: common(d, BlendMode::Screen, 0.75),
paint,
technique: if enum_of(d, "GlwT") == Some(b"PrBL") { GlowTechnique::Precise } else { GlowTechnique::Softer },
spread: pct(d, "Ckmt", 0.0),
size: f(d, "blur", 5.0),
contour: contour(d, "TrnS"),
anti_alias: bool_of(d, "AntA"),
range: pct(d, "Inpr", 0.5),
jitter: pct(d, "ShdN", 0.0),
noise: pct(d, "Nose", 0.0),
source: if inner && enum_of(d, "glwS") == Some(b"SrcC") { GlowSource::Center } else { GlowSource::Edge },
}
}
fn parse_stroke(d: &Descriptor) -> StrokeFx {
let paint = match enum_of(d, "PntT") {
Some(b"GrFl") => FxPaint::Gradient(gradient_from(d)),
Some(b"Ptrn") => {
let p = get_desc(d, "Ptrn");
let text = |k: &str| match p.and_then(|p| p.get(k)) {
Some(Value::Text(t)) => t.to_string_lossy(),
_ => String::new(),
};
FxPaint::Pattern { name: text("Nm "), id: text("Idnt"), scale: pct(d, "Scl ", 1.0) }
}
_ => FxPaint::Color(color(d, "Clr ")),
};
StrokeFx {
common: common(d, BlendMode::Normal, 1.0),
size: f(d, "Sz ", 3.0),
position: match enum_of(d, "Styl") {
Some(b"InsF") => StrokePosition::Inside,
Some(b"CtrF") => StrokePosition::Center,
_ => StrokePosition::Outside,
},
paint,
overprint: bool_of(d, "overprint"),
}
}
fn parse_bevel(d: &Descriptor) -> Bevel {
Bevel {
enabled: !matches!(d.get("enab"), Some(Value::Boolean(false))),
style: match enum_of(d, "bvlS") {
Some(b"OtrB") => BevelStyle::OuterBevel,
Some(b"Embs") => BevelStyle::Emboss,
Some(b"PlEb") => BevelStyle::PillowEmboss,
Some(b"strokeEmboss") => BevelStyle::StrokeEmboss,
_ => BevelStyle::InnerBevel,
},
technique: match enum_of(d, "bvlT") {
Some(b"PrBL") => BevelTechnique::ChiselHard,
Some(b"Slmt") => BevelTechnique::ChiselSoft,
_ => BevelTechnique::Smooth,
},
depth: pct(d, "srgR", 1.0),
up: enum_of(d, "bvlD") != Some(b"Out "),
size: f(d, "blur", 5.0),
soften: f(d, "Sftn", 0.0),
angle: f(d, "lagl", 120.0),
altitude: f(d, "Lald", 30.0),
use_global_light: !matches!(d.get("uglg"), Some(Value::Boolean(false))),
gloss_contour: contour(d, "TrnS"),
gloss_anti_alias: bool_of(d, "antialiasGloss"),
highlight: FxCommon { enabled: true, blend: blend_of(d, "hglM", BlendMode::Screen), opacity: pct(d, "hglO", 0.75) },
highlight_color: get_desc(d, "hglC").and_then(color_from_desc).unwrap_or(Color::WHITE),
shadow: FxCommon { enabled: true, blend: blend_of(d, "sdwM", BlendMode::Multiply), opacity: pct(d, "sdwO", 0.75) },
shadow_color: color(d, "sdwC"),
contour: bool_of(d, "useShape").then(|| BevelContour { contour: contour(d, "MpgS"), range: pct(d, "Inpr", 0.5), anti_alias: bool_of(d, "AntA") }),
texture: bool_of(d, "useTexture").then(|| {
let (name, id) = pattern_ref(d);
let (_, link, phase) = pattern_placement(d);
BevelTexture { name, id, scale: pct(d, "Scl ", 1.0), depth: pct(d, "textureDepth", 1.0), invert: bool_of(d, "InvT"), link, phase }
}),
}
}
fn parse_one(key: &str, d: &Descriptor) -> Option<Effect> {
if matches!(d.get("present"), Some(Value::Boolean(false))) {
return None;
}
Some(match key {
"DrSh" => Effect::DropShadow(parse_shadow(d, false)),
"IrSh" => Effect::InnerShadow(parse_shadow(d, true)),
"OrGl" => Effect::OuterGlow(parse_glow(d, false)),
"IrGl" => Effect::InnerGlow(parse_glow(d, true)),
"FrFX" => Effect::Stroke(parse_stroke(d)),
"SoFi" => Effect::ColorOverlay { common: common(d, BlendMode::Normal, 1.0), color: color(d, "Clr ") },
"GrFl" => Effect::GradientOverlay { common: common(d, BlendMode::Normal, 1.0), gradient: gradient_from(d), dither: bool_of(d, "Dthr") },
"patternFill" => {
let (name, id) = pattern_ref(d);
let (angle, link, phase) = pattern_placement(d);
Effect::PatternOverlay { common: common(d, BlendMode::Normal, 1.0), name, id, scale: pct(d, "Scl ", 1.0), angle, link, phase }
}
"ChFX" => Effect::Satin(Satin {
common: common(d, BlendMode::Multiply, 0.5),
color: color(d, "Clr "),
angle: f(d, "lagl", 19.0),
distance: f(d, "Dstn", 11.0),
size: f(d, "blur", 14.0),
contour: contour(d, "MpgS"),
anti_alias: bool_of(d, "AntA"),
invert: bool_of(d, "Invr"),
}),
"ebbl" => Effect::BevelEmboss(parse_bevel(d)),
_ => return None,
})
}
/// Single-instance key and multi-instance list key for each kind.
const KINDS: [(&str, &str); 10] = [
("DrSh", "dropShadowMulti"),
("IrSh", "innerShadowMulti"),
("OrGl", ""),
("IrGl", ""),
("ebbl", ""),
("ChFX", ""),
("SoFi", "solidFillMulti"),
("GrFl", "gradientFillMulti"),
("patternFill", ""),
("FrFX", "frameFXMulti"),
];
/// 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;
let master = !matches!(d.get("masterFXSwitch"), Some(Value::Boolean(false)));
let mut out = Vec::new();
for (single, multi) in KINDS {
let from_multi = (!multi.is_empty()).then(|| d.get(multi)).flatten();
match from_multi {
Some(Value::List(items)) => {
for it in items {
if let Value::Descriptor(x) = it
&& let Some(e) = parse_one(single, x)
{
out.push(e);
}
}
}
_ => {
if let Some(x) = get_desc(&d, single)
&& let Some(e) = parse_one(single, x)
{
out.push(e);
}
}
}
}
Some((master, out))
}
fn unit(u: &[u8; 4], v: f32) -> Value {
Value::UnitFloat { unit: *u, value: f64::from(v) }
}
fn with_common(d: Descriptor, c: &FxCommon) -> Descriptor {
d.with("enab", Value::Boolean(c.enabled))
.with("present", Value::Boolean(true))
.with("showInDialog", Value::Boolean(true))
.with("Md ", blend_value(c.blend))
.with("Opct", unit(b"#Prc", c.opacity * 100.0))
}
fn gradient_keys(mut d: Descriptor, g: &Gradient) -> Descriptor {
d = d
.with("Grad", Value::Descriptor(gradient_desc(&g.stops, &g.opacity_stops)))
.with("Angl", unit(b"#Ang", g.angle))
.with("Type", gradient_style_value(g.style))
.with("Rvrs", Value::Boolean(g.reverse))
.with("Algn", Value::Boolean(g.align))
.with("Scl ", unit(b"#Prc", g.scale * 100.0));
d.with("Ofst", Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", unit(b"#Prc", g.offset.0 * 100.0)).with("Vrtc", unit(b"#Prc", g.offset.1 * 100.0))))
}
fn write_one(e: &Effect) -> (&'static str, Descriptor) {
match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => {
let inner = matches!(e, Effect::InnerShadow(_));
let mut d = with_common(Descriptor::new(if inner { "IrSh" } else { "DrSh" }), &s.common)
.with("Clr ", Value::Descriptor(color_to_desc(&s.color)))
.with("uglg", Value::Boolean(s.use_global_light))
.with("lagl", unit(b"#Ang", s.angle))
.with("Dstn", unit(b"#Pxl", s.distance))
.with("Ckmt", unit(b"#Pxl", s.spread * 100.0))
.with("blur", unit(b"#Pxl", s.size))
.with("Nose", unit(b"#Prc", s.noise * 100.0))
.with("AntA", Value::Boolean(s.anti_alias))
.with("TrnS", contour_value(&s.contour));
if !inner {
d = d.with("layerConceals", Value::Boolean(s.knocks_out));
}
(if inner { "IrSh" } else { "DrSh" }, d)
}
Effect::OuterGlow(g) | Effect::InnerGlow(g) => {
let inner = matches!(e, Effect::InnerGlow(_));
let mut d = with_common(Descriptor::new(if inner { "IrGl" } else { "OrGl" }), &g.common);
d = match &g.paint {
FxPaint::Gradient(gr) => gradient_keys(d, gr),
FxPaint::Color(c) => d.with("Clr ", Value::Descriptor(color_to_desc(c))),
FxPaint::Pattern { .. } => d.with("Clr ", Value::Descriptor(color_to_desc(&Color::WHITE))),
};
d = d
.with(
"GlwT",
Value::Enumerated { type_id: Id::new("BETE"), value: Id::new(if g.technique == GlowTechnique::Precise { "PrBL" } else { "SfBL" }) },
)
.with("Ckmt", unit(b"#Pxl", g.spread * 100.0))
.with("blur", unit(b"#Pxl", g.size))
.with("Nose", unit(b"#Prc", g.noise * 100.0))
.with("ShdN", unit(b"#Prc", g.jitter * 100.0))
.with("AntA", Value::Boolean(g.anti_alias))
.with("TrnS", contour_value(&g.contour))
.with("Inpr", unit(b"#Prc", g.range * 100.0));
if inner {
d = d
.with("glwS", Value::Enumerated { type_id: Id::new("IGSr"), value: Id::new(if g.source == GlowSource::Center { "SrcC" } else { "SrcE" }) });
}
(if inner { "IrGl" } else { "OrGl" }, d)
}
Effect::Stroke(s) => {
let styl = match s.position {
StrokePosition::Outside => "OutF",
StrokePosition::Inside => "InsF",
StrokePosition::Center => "CtrF",
};
let mut d = with_common(Descriptor::new("FrFX"), &s.common)
.with("Styl", Value::Enumerated { type_id: Id::new("FStl"), value: Id::new(styl) })
.with("Sz ", unit(b"#Pxl", s.size))
.with("overprint", Value::Boolean(s.overprint));
d = match &s.paint {
FxPaint::Color(c) => {
d.with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("SClr") }).with("Clr ", Value::Descriptor(color_to_desc(c)))
}
FxPaint::Gradient(g) => gradient_keys(d.with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("GrFl") }), g),
FxPaint::Pattern { name, id, scale } => d
.with("PntT", Value::Enumerated { type_id: Id::new("FrFl"), value: Id::new("Ptrn") })
.with(
"Ptrn",
Value::Descriptor(
Descriptor::new("Ptrn")
.with("Nm ", Value::Text(UnicodeString::new_nul(name)))
.with("Idnt", Value::Text(UnicodeString::new_nul(id))),
),
)
.with("Scl ", unit(b"#Prc", scale * 100.0)),
};
("FrFX", d)
}
Effect::ColorOverlay { common: c, color } => ("SoFi", with_common(Descriptor::new("SoFi"), c).with("Clr ", Value::Descriptor(color_to_desc(color)))),
Effect::GradientOverlay { common: c, gradient, dither } => {
("GrFl", gradient_keys(with_common(Descriptor::new("GrFl"), c), gradient).with("Dthr", Value::Boolean(*dither)))
}
Effect::PatternOverlay { common: c, name, id, scale, angle, link, phase } => (
"patternFill",
with_pattern_placement(
with_common(Descriptor::new("patternFill"), c)
.with("Ptrn", Value::Descriptor(pattern_ref_desc(name, id)))
.with("Scl ", unit(b"#Prc", scale * 100.0)),
*angle,
*link,
*phase,
),
),
Effect::Satin(s) => (
"ChFX",
with_common(Descriptor::new("ChFX"), &s.common)
.with("Clr ", Value::Descriptor(color_to_desc(&s.color)))
.with("AntA", Value::Boolean(s.anti_alias))
.with("Invr", Value::Boolean(s.invert))
.with("lagl", unit(b"#Ang", s.angle))
.with("Dstn", unit(b"#Pxl", s.distance))
.with("blur", unit(b"#Pxl", s.size))
.with("MpgS", contour_value(&s.contour)),
),
Effect::BevelEmboss(b) => {
let style = match b.style {
BevelStyle::OuterBevel => "OtrB",
BevelStyle::InnerBevel => "InrB",
BevelStyle::Emboss => "Embs",
BevelStyle::PillowEmboss => "PlEb",
BevelStyle::StrokeEmboss => "strokeEmboss",
};
let tech = match b.technique {
BevelTechnique::Smooth => "SfBL",
BevelTechnique::ChiselHard => "PrBL",
BevelTechnique::ChiselSoft => "Slmt",
};
let d = Descriptor::new("ebbl")
.with("enab", Value::Boolean(b.enabled))
.with("present", Value::Boolean(true))
.with("showInDialog", Value::Boolean(true))
.with("hglM", blend_value(b.highlight.blend))
.with("hglC", Value::Descriptor(color_to_desc(&b.highlight_color)))
.with("hglO", unit(b"#Prc", b.highlight.opacity * 100.0))
.with("sdwM", blend_value(b.shadow.blend))
.with("sdwC", Value::Descriptor(color_to_desc(&b.shadow_color)))
.with("sdwO", unit(b"#Prc", b.shadow.opacity * 100.0))
.with("bvlT", Value::Enumerated { type_id: Id::new("bvlT"), value: Id::new(tech) })
.with("bvlS", Value::Enumerated { type_id: Id::new("BESl"), value: Id::new(style) })
.with("uglg", Value::Boolean(b.use_global_light))
.with("lagl", unit(b"#Ang", b.angle))
.with("Lald", unit(b"#Ang", b.altitude))
.with("srgR", unit(b"#Prc", b.depth * 100.0))
.with("blur", unit(b"#Pxl", b.size))
.with("bvlD", Value::Enumerated { type_id: Id::new("BESs"), value: Id::new(if b.up { "In " } else { "Out " }) })
.with("TrnS", contour_value(&b.gloss_contour))
.with("antialiasGloss", Value::Boolean(b.gloss_anti_alias))
.with("Sftn", unit(b"#Pxl", b.soften));
let d = match &b.contour {
Some(c) => d
.with("useShape", Value::Boolean(true))
.with("MpgS", contour_value(&c.contour))
.with("AntA", Value::Boolean(c.anti_alias))
.with("Inpr", unit(b"#Prc", c.range * 100.0)),
None => d.with("useShape", Value::Boolean(false)),
};
let d = match &b.texture {
Some(t) => with_pattern_placement(
d.with("useTexture", Value::Boolean(true))
.with("InvT", Value::Boolean(t.invert))
.with("Scl ", unit(b"#Prc", t.scale * 100.0))
.with("textureDepth", unit(b"#Prc", t.depth * 100.0))
.with("Ptrn", Value::Descriptor(pattern_ref_desc(&t.name, &t.id))),
0.0,
t.link,
t.phase,
),
None => d.with("useTexture", Value::Boolean(false)),
};
("ebbl", d)
}
}
}
/// Serializes effects as `lfx2` block data.
pub fn write_lfx2(master: bool, items: &[Effect]) -> Vec<u8> {
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();
match of_kind.len() {
0 => {}
1 => d = d.with(single, Value::Descriptor(of_kind.into_iter().next().unwrap_or_default())),
_ if !multi.is_empty() => d = d.with(multi, Value::List(of_kind.into_iter().map(Value::Descriptor).collect())),
// Kinds without a multi key keep only the first instance.
_ => 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
}
fn fixed(v: u32) -> f32 {
v as i32 as f32 / 65536.0
}
/// Parses legacy `lrFX` effects (Photoshop 5 layout): drop/inner shadows,
/// outer/inner glows and solid fill. Bevels are skipped.
pub fn parse_lrfx(data: &[u8]) -> Option<(bool, Vec<Effect>)> {
let be16 = |at: usize| data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]]));
let be32 = |at: usize| data.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]));
let color_at = |at: usize| -> Option<Color> {
// space (2) + 4 × u16 components (RGB uses the first three).
let c = |k: usize| be16(at + 2 + k * 2).map(|v| f32::from(v >> 8) / 255.0);
Some(Color::rgb(c(0)?, c(1)?, c(2)?))
};
let blend_at = |at: usize| -> BlendMode {
data.get(at + 4..at + 8).and_then(|k| photocraft_color::BlendMode::from_psd_key([k[0], k[1], k[2], k[3]])).unwrap_or(BlendMode::Normal)
};
let count = be16(2)?;
let mut at = 4;
let mut out = Vec::new();
for _ in 0..count {
let sig = data.get(at..at + 4)?;
if sig != b"8BIM" {
return None;
}
let key: [u8; 4] = data.get(at + 4..at + 8)?.try_into().ok()?;
let size = be32(at + 8)? as usize;
let b = at + 12; // start of this effect's data (after the size field)
match &key {
b"dsdw" | b"isdw" => {
// Blur, angle and distance are 16.16 fixed point in practice.
let blur = fixed(be32(b + 4)?);
let angle = fixed(be32(b + 12)?);
let distance = fixed(be32(b + 16)?);
let color = color_at(b + 20)?;
let blend = blend_at(b + 30);
let enabled = *data.get(b + 38)? != 0;
let use_global = *data.get(b + 39)? != 0;
let opacity = f32::from(*data.get(b + 40)?) / 255.0;
let s = Shadow {
common: FxCommon { enabled, blend, opacity },
color,
angle,
use_global_light: use_global,
distance,
spread: 0.0,
size: blur,
contour: Contour::Linear,
anti_alias: false,
noise: 0.0,
knocks_out: &key == b"dsdw",
};
out.push(if &key == b"dsdw" { Effect::DropShadow(s) } else { Effect::InnerShadow(s) });
}
b"oglw" | b"iglw" => {
let blur = fixed(be32(b + 4)?);
let color = color_at(b + 12)?;
let blend = blend_at(b + 22);
let enabled = *data.get(b + 30)? != 0;
let opacity = f32::from(*data.get(b + 31)?) / 255.0;
let g = Glow {
common: FxCommon { enabled, blend, opacity },
paint: FxPaint::Color(color),
technique: GlowTechnique::Softer,
spread: 0.0,
size: blur,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
};
out.push(if &key == b"oglw" { Effect::OuterGlow(g) } else { Effect::InnerGlow(g) });
}
b"sofi" => {
let blend = blend_at(b + 4);
let color = color_at(b + 12)?;
let opacity = f32::from(*data.get(b + 22)?) / 255.0;
let enabled = *data.get(b + 23)? != 0;
out.push(Effect::ColorOverlay { common: FxCommon { enabled, blend, opacity }, color });
}
_ => {}
}
at = b + size;
}
Some((true, out))
}
#[cfg(test)]
#[allow(clippy::unreachable)] // clippy.toml exempts unwrap/expect/panic in tests, not unreachable!
mod tests {
use super::*;
fn sample_effects() -> Vec<Effect> {
let g = Gradient {
stops: vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))],
opacity_stops: vec![(0.0, 1.0), (1.0, 0.5)],
..Gradient::default()
};
vec![
Effect::default_drop_shadow(),
Effect::DropShadow(Shadow {
distance: 9.0,
spread: 0.25,
contour: Contour::Custom {
name: "Cone".into(),
points: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.5, output: 1.0 }, CurvePoint { input: 1.0, output: 0.0 }],
corners: vec![false, true, false],
},
..match Effect::default_drop_shadow() {
Effect::DropShadow(s) => s,
_ => unreachable!(),
}
}),
Effect::InnerShadow(Shadow {
knocks_out: false,
..match Effect::default_drop_shadow() {
Effect::DropShadow(s) => s,
_ => unreachable!(),
}
}),
Effect::OuterGlow(Glow {
common: FxCommon::new(BlendMode::Screen, 0.5),
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)),
technique: GlowTechnique::Precise,
spread: 0.1,
size: 8.0,
contour: Contour::Linear,
anti_alias: true,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
}),
Effect::InnerGlow(Glow {
common: FxCommon::new(BlendMode::Screen, 0.75),
paint: FxPaint::Gradient(g.clone()),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 5.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Center,
}),
Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 3.0,
position: StrokePosition::Inside,
paint: FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)),
overprint: false,
}),
Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Multiply, 0.5),
size: 6.0,
position: StrokePosition::Center,
paint: FxPaint::Gradient(g.clone()),
overprint: true,
}),
Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 0.5), color: Color::rgb(0.0, 0.0, 1.0) },
Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: g, dither: true },
Effect::PatternOverlay {
common: FxCommon::new(BlendMode::Normal, 1.0),
name: "Bubbles".into(),
id: "abc".into(),
scale: 1.0,
angle: 15.0,
link: false,
phase: (4.0, 2.0),
},
Effect::Satin(Satin {
common: FxCommon::new(BlendMode::Multiply, 0.5),
color: Color::BLACK,
angle: 19.0,
distance: 11.0,
size: 14.0,
contour: Contour::Linear,
anti_alias: true,
invert: true,
}),
Effect::BevelEmboss(Bevel {
enabled: true,
style: BevelStyle::Emboss,
technique: BevelTechnique::ChiselSoft,
depth: 1.5,
up: false,
size: 7.0,
soften: 2.0,
angle: 45.0,
altitude: 40.0,
use_global_light: false,
gloss_contour: Contour::Linear,
gloss_anti_alias: true,
highlight: FxCommon::new(BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(BlendMode::Multiply, 0.6),
shadow_color: Color::BLACK,
contour: Some(BevelContour { contour: Contour::Linear, range: 0.7, anti_alias: true }),
texture: Some(BevelTexture { name: "Bubbles".into(), id: "abc".into(), scale: 0.5, depth: -2.0, invert: true, link: false, phase: (3.0, 1.0) }),
}),
]
}
fn approx(a: &[Effect], b: &[Effect]) {
// Colours go through 0..255 doubles; compare via re-serialization.
assert_eq!(a.len(), b.len());
assert_eq!(write_lfx2(true, a), write_lfx2(true, b));
}
/// `approx` compares re-serialized bytes, so a field the writer drops looks equal on both
/// sides: read the Gloss Contour's Anti-aliased back directly.
#[test]
fn bevel_gloss_anti_alias_is_written() {
let fx: Vec<Effect> = sample_effects().into_iter().filter(|e| matches!(e, Effect::BevelEmboss(_))).collect();
assert!(matches!(fx.as_slice(), [Effect::BevelEmboss(b)] if b.gloss_anti_alias));
let (_, back) = parse_lfx2(&write_lfx2(false, &fx)).unwrap();
assert!(matches!(back.as_slice(), [Effect::BevelEmboss(b)] if b.gloss_anti_alias), "{back:?}");
}
#[test]
fn lfx2_roundtrip_all_kinds() {
let fx = sample_effects();
let data = write_lfx2(false, &fx);
let (master, back) = parse_lfx2(&data).unwrap();
assert!(!master);
approx(&fx, &back);
// Kind order is canonical: shadows first ... strokes last.
assert!(matches!(back[0], Effect::DropShadow(_)));
assert!(matches!(back.last().unwrap(), Effect::Stroke(_)));
}
#[test]
fn stroke_overprint_is_read_and_written() {
for overprint in [false, true] {
let fx = [Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::BLACK),
overprint,
})];
let (_, back) = parse_lfx2(&write_lfx2(false, &fx)).unwrap();
let [Effect::Stroke(s)] = back.as_slice() else { panic!("{back:?}") };
assert_eq!(s.overprint, overprint);
}
// A stroke without the key (other writers) has Overprint off.
let d = Descriptor::new("null").with("FrFX", Value::Descriptor(Descriptor::new("FrFX").with("enab", Value::Boolean(true))));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor::new(d).to_bytes());
let (_, back) = parse_lfx2(&data).unwrap();
let [Effect::Stroke(s)] = back.as_slice() else { panic!("{back:?}") };
assert!(!s.overprint);
}
#[test]
fn lfx2_ignores_absent_effects_but_keeps_disabled_configured_effects() {
let d = Descriptor::new("null")
.with("DrSh", Value::Descriptor(Descriptor::new("DrSh").with("present", Value::Boolean(false)).with("enab", Value::Boolean(false))))
.with("FrFX", Value::Descriptor(Descriptor::new("FrFX").with("present", Value::Boolean(true)).with("enab", Value::Boolean(false))))
.with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(false))));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor::new(d).to_bytes());
let (_, effects) = parse_lfx2(&data).unwrap();
assert_eq!(effects.len(), 2);
assert!(effects.iter().all(|effect| !effect.enabled()));
assert!(effects.iter().any(|effect| effect.label() == "Stroke"));
assert!(effects.iter().any(|effect| effect.label() == "Color Overlay"));
}
#[test]
fn multi_instances_use_multi_keys() {
let fx = sample_effects();
let data = write_lfx2(true, &fx);
let (vd, _) = VersionedDescriptor::parse_prefix(&data[4..]).unwrap();
assert!(vd.descriptor.get("dropShadowMulti").is_some());
assert!(vd.descriptor.get("frameFXMulti").is_some());
assert!(vd.descriptor.get("SoFi").is_some());
}
#[test]
fn blend_names_are_unique() {
let mut s = std::collections::HashSet::new();
for (_, n) in BLEND_NAMES {
assert!(s.insert(n));
}
}
#[test]
fn long_string_blend_ids_parse() {
// Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`)
// instead of the four-character codes (psd-tools effects/blend-modes.psd).
let long = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) };
let d = Descriptor::new("null")
.with("masterFXSwitch", Value::Boolean(true))
.with("DrSh", Value::Descriptor(Descriptor::new("DrSh").with("enab", Value::Boolean(true)).with("Md ", long("linearBurn"))))
.with("ebbl", Value::Descriptor(Descriptor::new("ebbl").with("hglM", long("colorDodge")).with("sdwM", long("colorBurn"))))
.with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("Md ", long("hue"))));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes());
let (_, fx) = parse_lfx2(&data).unwrap();
let modes: Vec<BlendMode> = fx
.iter()
.flat_map(|e| match e {
Effect::DropShadow(s) => vec![s.common.blend],
Effect::BevelEmboss(b) => vec![b.highlight.blend, b.shadow.blend],
Effect::ColorOverlay { common, .. } => vec![common.blend],
_ => vec![],
})
.collect();
assert_eq!(modes, vec![BlendMode::LinearBurn, BlendMode::ColorDodge, BlendMode::ColorBurn, BlendMode::Hue]);
for (m, n) in BLEND_LONG_NAMES {
assert_eq!(blend_from_id(n.as_bytes()), Some(m));
}
}
/// A Gradient Overlay `lfx2` laid out as Photoshop writes it: one black colour, opacity
/// 100 → 0 % with the opacity midpoint `mid` (%), Smoothness `intr` (0..4096) and the method
/// enum `gs99` (Photoshop's key in layer effects; codes `Gcls`, `Lnr `, `Perc`).
fn overlay_lfx2(mid: i32, intr: f64, method: &str) -> Vec<u8> {
let black = || Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0));
let clr = |loc: i32| {
Value::Descriptor(
Descriptor::new("Clrt")
.with("Clr ", Value::Descriptor(black()))
.with("Type", Value::Enumerated { type_id: Id::new("Clry"), value: Id::new("UsrS") })
.with("Lctn", Value::Integer(loc))
.with("Mdpn", Value::Integer(50)),
)
};
let trn = |loc: i32, op: f32, mid: i32| {
Value::Descriptor(Descriptor::new("TrnS").with("Opct", unit(b"#Prc", op)).with("Lctn", Value::Integer(loc)).with("Mdpn", Value::Integer(mid)))
};
let grad = Descriptor::new("Grdn")
.with("GrdF", Value::Enumerated { type_id: Id::new("GrdF"), value: Id::new("CstS") })
.with("Intr", Value::Double(intr))
.with("Clrs", Value::List(vec![clr(0), clr(4096)]))
.with("Trns", Value::List(vec![trn(0, 100.0, 50), trn(4096, 0.0, mid)]));
let fx = Descriptor::new("GrFl")
.with("enab", Value::Boolean(true))
.with("Grad", Value::Descriptor(grad))
.with("Angl", unit(b"#Ang", 0.0))
.with("gs99", Value::Enumerated { type_id: Id::new("gradientInterpolationMethodType"), value: Id::new(method) });
let d = Descriptor::new("null").with("masterFXSwitch", Value::Boolean(true)).with("GrFl", Value::Descriptor(fx));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes());
data
}
fn overlay_opacity(data: &[u8]) -> Vec<(f32, f32)> {
let (_, fx) = parse_lfx2(data).unwrap();
let Some(Effect::GradientOverlay { gradient, .. }) = fx.first() else { panic!("overlay: {fx:?}") };
gradient.opacity_stops.clone()
}
fn opacity_at(o: &[(f32, f32)], t: f32) -> f32 {
let i = o.windows(2).position(|w| t >= w[0].0 && t <= w[1].0).unwrap();
o[i].1 + (o[i + 1].1 - o[i].1) * (t - o[i].0) / (o[i + 1].0 - o[i].0)
}
/// #1954: the overlay's transparency follows Smoothness, the opacity midpoints and the method
/// like a gradient fill layer's (Photoshop renders both identically; the values are its
/// composites at t = 0.25 and 0.5).
#[test]
fn gradient_overlay_opacity_follows_smoothness_midpoint_and_method() {
let at = |mid, intr, method: &str, t| opacity_at(&overlay_opacity(&overlay_lfx2(mid, intr, method)), t);
// Plain Classic stays the linear fade it is stored as.
assert_eq!(overlay_opacity(&overlay_lfx2(50, 0.0, "Gcls")), vec![(0.0, 1.0), (1.0, 0.0)]);
// Midpoint 25 %: half transparent a quarter of the way.
assert!((at(25, 0.0, "Gcls", 0.25) - 0.5).abs() < 0.01);
// Smoothness 100 % eases the ends (Photoshop: 0.796 at a quarter).
assert!((at(50, 4096.0, "Gcls", 0.25) - 0.796).abs() < 0.015);
// The method bends the transparency ramp too, not only the colours (Photoshop at the
// middle: 0.631 Linear, 0.255 Perceptual).
assert!((at(50, 0.0, "Lnr ", 0.5) - 0.631).abs() < 0.015);
assert!((at(50, 0.0, "Perc", 0.5) - 0.255).abs() < 0.015);
assert!((at(25, 4096.0, "Perc", 0.25) - 0.255).abs() < 0.015);
}
/// A regenerated `lfx2` carries the baked ramp as plain (Classic, Smoothness 0) stops, so it
/// reads back unchanged.
#[test]
fn baked_overlay_opacity_round_trips() {
let data = overlay_lfx2(25, 4096.0, "Perc");
let (master, fx) = parse_lfx2(&data).unwrap();
let a = overlay_opacity(&data);
let b = overlay_opacity(&write_lfx2(master, &fx));
assert!(a.len() > 2);
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(&b) {
assert!((x.0 - y.0).abs() < 1e-3 && (x.1 - y.1).abs() < 1e-4, "{x:?} vs {y:?}");
}
}
#[test]
fn garbage_is_none() {
assert!(parse_lfx2(&[0, 0]).is_none());
assert!(parse_lrfx(&[0, 0, 0, 1, b'X']).is_none());
}
#[test]
fn lrfx_drop_shadow() {
// version 0, count 1, 'dsdw' v2 record.
let mut d = vec![0, 0, 0, 1];
d.extend_from_slice(b"8BIMdsdw");
let mut rec = Vec::new();
rec.extend_from_slice(&2u32.to_be_bytes()); // version
rec.extend_from_slice(&(7u32 << 16).to_be_bytes()); // blur (16.16)
rec.extend_from_slice(&0u32.to_be_bytes()); // intensity
rec.extend_from_slice(&(90u32 << 16).to_be_bytes()); // angle
rec.extend_from_slice(&(4u32 << 16).to_be_bytes()); // distance
rec.extend_from_slice(&[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0]); // color: red
rec.extend_from_slice(b"8BIMmul ");
rec.extend_from_slice(&[1, 1, 128]);
rec.extend_from_slice(&[0; 10]);
d.extend_from_slice(&(rec.len() as u32).to_be_bytes());
d.extend(rec);
let (_, fx) = parse_lrfx(&d).unwrap();
match &fx[0] {
Effect::DropShadow(s) => {
assert_eq!(s.size, 7.0);
assert_eq!(s.distance, 4.0);
assert_eq!(s.common.blend, BlendMode::Multiply);
assert!((s.common.opacity - 128.0 / 255.0).abs() < 1e-6);
assert_eq!(s.color.c[0], 1.0);
}
other => panic!("{other:?}"),
}
}
}