Stroke: keep Overprint (overprint)

Photoshop stores Stroke › Overprint as `overprint` in the FrFX
descriptor. StrokeFx had no field for it, so an edited stroke was
written back without it and a stroke with Overprint on lost it.

Add `StrokeFx::overprint` (serde default, so older .pcraft files load),
read and write `overprint`, accept it in layer.layerStyle.stroke and show
the Overprint checkbox in the Layer Style dialog, as Photoshop does.
Rendering is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
varanus-salvator
2026-10-15 12:55:18 +07:00
parent ad96df566b
commit 3227bf8366
17 changed files with 71 additions and 9 deletions
@@ -26,6 +26,7 @@ fn document_with_strokes(layer_count: usize) -> Result<Document, Box<dyn Error>>
size: 6.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(0.8, 0.1, 0.2)),
overprint: false,
}));
layer.advanced.transparency_shapes = false;
document.layers.push(layer);
+3
View File
@@ -289,6 +289,7 @@ fn inside_stroke() -> Effect {
size: 2.0,
position: StrokePosition::Inside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})
}
@@ -298,6 +299,7 @@ fn outside_stroke() -> Effect {
size: 6.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})
}
@@ -318,6 +320,7 @@ fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() {
size: 6.0,
position,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})];
l.blend = blend;
l.fill_opacity = fill;
+1 -1
View File
@@ -21,7 +21,7 @@ fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] {
}
fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> Effect {
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint })
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint, overprint: false })
}
fn blue() -> FxPaint {
+3
View File
@@ -874,6 +874,7 @@ fn stroke(size: f32, position: StrokePosition) -> Effect {
size,
position,
paint: FxPaint::Color(Color::rgb(0.0, 0.0, 1.0)),
overprint: false,
})
}
@@ -1587,6 +1588,7 @@ fn upper_stroke_blends_with_the_backdrop_not_the_lower_stroke() {
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.0)),
overprint: false,
});
let d = fx_doc(vec![yellow, stroke(8.0, StrokePosition::Outside)]);
assert!(close4(px(&d, 8, 20), [1.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 8, 20));
@@ -2517,6 +2519,7 @@ fn nested_parallel_fx_doc() -> Document {
size: 3.0,
position: StrokePosition::Inside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})];
let mut glow = Layer::new("glow", LayerContent::Fill(g(90.0)));
glow.opacity = 0.5;
+3
View File
@@ -166,6 +166,9 @@ pub struct StrokeFx {
pub size: f32,
pub position: StrokePosition,
pub paint: FxPaint,
/// Overprint (PSD `overprint`).
#[serde(default)]
pub overprint: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+17 -2
View File
@@ -91,7 +91,7 @@ pub fn effect_defaults(kind: &str) -> Value {
"innerGlow" => {
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0})
}
"stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "color": "#000000"}),
"stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "overprint": false, "color": "#000000"}),
"colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}),
"gradientOverlay" => {
json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100})
@@ -510,6 +510,7 @@ fn fresh_effect(kind: &str) -> Option<Effect> {
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
}),
"colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) },
"gradientOverlay" => Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false },
@@ -690,6 +691,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) {
_ => StrokePosition::Outside,
};
}
set_bool(p, "overprint", &mut s.overprint);
if p.get("from").is_some() {
s.paint = FxPaint::Gradient(gradient(p));
} else if let Some(c) = color_of(p, "color") {
@@ -1005,7 +1007,7 @@ pub fn specs() -> Vec<CommandSpec> {
style_cmd!(
"stroke",
"Stroke…",
r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"##
r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"overprint":bool,"add":bool}"##
),
style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##),
style_cmd!(
@@ -1137,6 +1139,17 @@ mod tests {
assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true));
}
#[test]
fn stroke_overprint_is_a_param() {
let mut s = session();
s.execute("layer.layerStyle.stroke", json!({"size": 4, "overprint": true})).unwrap();
let Effect::Stroke(st) = &effects(&s)[0] else { panic!() };
assert!(st.overprint);
s.execute("layer.layerStyle.stroke", json!({"overprint": false})).unwrap();
let Effect::Stroke(st) = &effects(&s)[0] else { panic!() };
assert!(!st.overprint);
}
#[test]
fn every_style_command_adds_its_effect() {
for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] {
@@ -1313,6 +1326,7 @@ mod tests {
size: 7.0,
position: StrokePosition::Outside,
paint: FxPaint::Gradient(Gradient { stops: vec![(0.0, Color::rgb(0.0, 0.0, 1.0)), (1.0, Color::WHITE)], ..Gradient::default() }),
overprint: false,
});
let r = s
.execute(
@@ -1349,6 +1363,7 @@ mod tests {
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::BLACK),
overprint: false,
});
for p in [
json!({}),
+3 -2
View File
@@ -852,8 +852,9 @@ mod tests {
use photocraft_color::{BlendMode, Color};
use photocraft_doc::{FxCommon, FxPaint, StrokeFx, StrokePosition};
let light = GlobalLight::default();
let stroke =
|size, position| Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::BLACK) });
let stroke = |size, position| {
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::BLACK), overprint: false })
};
// Narrow (< STROKE_CHAMFER_MAX): the chamfer, exactly as before #3646.
let narrow = program(&stroke(6.0, StrokePosition::Outside), &light, false);
assert!(narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "a narrow raster outside stroke keeps the chamfer: {:?}", narrow.fields);
+8 -2
View File
@@ -855,7 +855,13 @@ fn hard_stop_effects_and_gradient_maps_request_cpu() {
let paint = FxPaint::Gradient(gradient.clone());
let effects = [
Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient, dither: false },
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size: 1.0, position: StrokePosition::Inside, paint: paint.clone() }),
Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 1.0,
position: StrokePosition::Inside,
paint: paint.clone(),
overprint: false,
}),
Effect::InnerGlow(glow(paint.clone(), GlowTechnique::Softer, 1.0, 0.0, GlowSource::Edge)),
Effect::OuterGlow(glow(paint, GlowTechnique::Softer, 1.0, 0.0, GlowSource::Edge)),
];
@@ -1340,7 +1346,7 @@ fn bevel(style: BevelStyle, up: bool, size: f32, soften: f32) -> Bevel {
}
fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> StrokeFx {
StrokeFx { common: FxCommon::new(BlendMode::Normal, 0.9), size, position, paint }
StrokeFx { common: FxCommon::new(BlendMode::Normal, 0.9), size, position, paint, overprint: false }
}
fn checker_pattern() -> Pattern {
+5 -1
View File
@@ -213,6 +213,7 @@ fn parse_stroke(d: &Descriptor) -> StrokeFx {
_ => StrokePosition::Outside,
},
paint,
overprint: bool_of(d, "overprint"),
}
}
@@ -404,7 +405,8 @@ fn write_one(e: &Effect) -> (&'static str, Descriptor) {
};
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("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)))
@@ -680,12 +682,14 @@ mod tests {
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 },
+1 -1
View File
@@ -8,7 +8,7 @@ use photocraft_geom::{Rect, Size};
use photocraft_io::{ExportOptions, export, import};
fn stroke(size: f32, position: StrokePosition, paint: FxPaint, opacity: f32) -> Effect {
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, opacity), size, position, paint })
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, opacity), size, position, paint, overprint: false })
}
fn roundtrip(doc: &Document) -> Document {
+2
View File
@@ -340,12 +340,14 @@ fn effects_roundtrip_typed_and_raw() {
size: 3.0,
position: StrokePosition::Center,
paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 1.0, 0.0)),
overprint: false,
}),
Effect::Stroke(StrokeFx {
common: FxCommon::new(photocraft_color::BlendMode::Normal, 0.5),
size: 6.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)),
overprint: false,
}),
];
let back = roundtrip(&d);
+1
View File
@@ -46,6 +46,7 @@ fn effects() -> Vec<Effect> {
size: 6.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(0.95, 0.85, 0.2)),
overprint: false,
}),
Effect::BevelEmboss(Bevel {
enabled: true,
@@ -97,6 +97,7 @@ fn text(name: &str, r: Rect) -> Layer {
size: 4.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)),
overprint: false,
}),
];
l
@@ -144,6 +145,7 @@ fn build(w: u32, h: u32) -> Document {
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(0.1, 0.1, 0.1)),
overprint: false,
}),
];
d.layers.push(band);
+18
View File
@@ -141,6 +141,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] {
("position", "Position", P::Choice(POS)),
("blend", "Blend Mode", P::Blend),
("opacity", "Opacity", P::Slider(0.0, 100.0, "%")),
("overprint", "Overprint", P::Check),
("color", "Color", P::Color),
],
"colorOverlay" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%"))],
@@ -266,6 +267,7 @@ fn values_of(e: &Effect, light: f32) -> Value {
_ => "outside",
}),
);
set(&mut v, "overprint", json!(s.overprint));
if let FxPaint::Color(c) = &s.paint {
set(&mut v, "color", json!(hex(c)));
} else {
@@ -1634,6 +1636,22 @@ mod tests {
);
}
#[test]
fn stroke_overprint_survives_the_dialog() {
let mut s = session();
s.execute("layer.layerStyle.stroke", json!({"size": 3, "color": "#000000", "overprint": true})).unwrap();
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let mut f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle);
assert_eq!(effects_of(&f)[0]["params"]["overprint"], json!(true));
// Editing another setting keeps Overprint.
set_param(&mut f, "fx1", "size", json!(5));
apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap();
let strokes = strokes_of(&s);
assert_eq!(strokes[0].size, 5.0);
assert!(strokes[0].overprint);
}
#[test]
fn preview_applies_the_style_without_touching_the_document() {
let s = session();
+1
View File
@@ -429,6 +429,7 @@ mod tests {
size,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
overprint: false,
})
}
@@ -88,6 +88,7 @@ fn stroke_fx(size: f32, c: [f32; 3]) -> Effect {
size,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(c[0], c[1], c[2])),
overprint: false,
})
}
+1
View File
@@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Stroke › Overprint (`overprint`) is read and written, set by the stroke command's `overprint` and shown in the Layer Style dialog; an edited stroke no longer drops it. Rendering ignores it for now |
| 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 |
| 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) |
| 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 |