diff --git a/crates/compose/examples/bench_tsly_stroke.rs b/crates/compose/examples/bench_tsly_stroke.rs index 97ff9c85b6..ec5a91b19f 100644 --- a/crates/compose/examples/bench_tsly_stroke.rs +++ b/crates/compose/examples/bench_tsly_stroke.rs @@ -26,6 +26,7 @@ fn document_with_strokes(layer_count: usize) -> Result> 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); diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index c19b0dc92f..0b38fd7f5b 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -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; diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs index ab265fa208..36c44011fa 100644 --- a/crates/compose/src/stroke_tests.rs +++ b/crates/compose/src/stroke_tests.rs @@ -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 { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index eb4b250728..0e9e70a489 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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; diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 6ae790e26c..ede32e1145 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -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)] diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 95b5476850..6027daebf3 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -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 { 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 { 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!({}), diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 01e147d51a..7815f42569 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -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); diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..8fbf93382d 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -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 { diff --git a/crates/io/src/effects_map.rs b/crates/io/src/effects_map.rs index 00815e2c83..5ee9996351 100644 --- a/crates/io/src/effects_map.rs +++ b/crates/io/src/effects_map.rs @@ -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 }, diff --git a/crates/io/tests/effects_multi.rs b/crates/io/tests/effects_multi.rs index 4eda6ad81b..112754f10d 100644 --- a/crates/io/tests/effects_multi.rs +++ b/crates/io/tests/effects_multi.rs @@ -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 { diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs index 6502079f20..dcb7bce62a 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/roundtrip.rs @@ -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); diff --git a/crates/ui-egui/examples/fx_bench.rs b/crates/ui-egui/examples/fx_bench.rs index aa2bf61b15..80666b0a12 100644 --- a/crates/ui-egui/examples/fx_bench.rs +++ b/crates/ui-egui/examples/fx_bench.rs @@ -46,6 +46,7 @@ fn effects() -> Vec { size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(0.95, 0.85, 0.2)), + overprint: false, }), Effect::BevelEmboss(Bevel { enabled: true, diff --git a/crates/ui-egui/examples/huge_doc_bench.rs b/crates/ui-egui/examples/huge_doc_bench.rs index 0454aefc3f..dffe9b89c0 100644 --- a/crates/ui-egui/examples/huge_doc_bench.rs +++ b/crates/ui-egui/examples/huge_doc_bench.rs @@ -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); diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index d876c18278..9d355c3fbe 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -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(); diff --git a/crates/ui-egui/src/transform_fx.rs b/crates/ui-egui/src/transform_fx.rs index 3b56225efa..2cb7afb0b7 100644 --- a/crates/ui-egui/src/transform_fx.rs +++ b/crates/ui-egui/src/transform_fx.rs @@ -429,6 +429,7 @@ mod tests { size, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, }) } diff --git a/crates/ui-egui/tests/support/layout_doc.rs b/crates/ui-egui/tests/support/layout_doc.rs index f44fac16a6..f79523d505 100644 --- a/crates/ui-egui/tests/support/layout_doc.rs +++ b/crates/ui-egui/tests/support/layout_doc.rs @@ -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, }) } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..d59a58541c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 |