Shapes: a hairline vector stroke no longer darkens the shape's edge pixels (#397)

* Shapes: a hairline vector stroke no longer darkens the shape's edge pixels

A stroked shape with layer effects (or clipped layers) is drawn as our
fill plus a vector stroke fitted to Photoshop's own pixels: where those
pixels reach past our fill, the stroke takes the alpha the fill doesn't
explain, `(pixels - fill) / (1 - fill)`. It kept our stroke's colour for
that alpha. When Photoshop's fill covers more of an edge pixel than ours
(shapes snapped to whole pixels with Align Edges, our path a fraction off
the grid), the fitted alpha is mostly Photoshop's fill, so a 0.25 px grey
border on a white card was painted as a solid grey line: the edge came out
at the stroke's full colour (122) instead of Photoshop's 220.

The fitted stroke now takes the colour that, drawn Normal over our fill,
gives back Photoshop's pixel (un-compositing the premultiplied colour),
and keeps our colour only where the fitted alpha is negligible. Where
Photoshop's pixels agree with our fill this is our stroke's colour, as
before.

Test: hairline_vector_stroke_keeps_photoshops_edge_pixel_colour (edge
pixel 0.478 before, 0.86 after). The compose, gpu, io and engine tests
and the psd/io/engine corpus tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shapes: neutral wording in the hairline-stroke fix; test it at 8, 16 and 32 bits

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
This commit is contained in:
EthanSuquet 2026-10-07 16:50:09 -04:00 committed by GitHub
parent a1f4387e63
commit fb924dad16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 43 additions and 2 deletions

View File

@ -67,7 +67,11 @@ pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
/// Fits the stroke part to the shape's own pixels: where they reach past the fill, the stroke
/// covers what the fill doesn't (`(pixels - fill) / (1 - fill)`), in the pixels' colour where we
/// drew no stroke.
/// drew no stroke, and elsewhere in the colour that, drawn over the fill, gives back the pixels'
/// colour. Our stroke's own colour would be wrong where the file's fill covers more of the pixel
/// than ours (shapes snapped to whole pixels with a stroke thinner than a pixel): the fitted
/// stroke is then mostly the file's fill, and an edge drawn in the stroke's full colour is far
/// too dark.
fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rect) {
// Where either has pixels (the stroke may reach past the shape's pixels, and vice versa).
let (cb, sb) = (crate::bounds::content_bounds(cache), crate::bounds::content_bounds(stroke));
@ -92,7 +96,16 @@ fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rec
let mut p = *sp;
if fp[3] < 1.0 - 1e-3 {
let a = ((cp[3] - fp[3]) / (1.0 - fp[3])).clamp(0.0, 1.0);
p = if sp[3] <= 0.0 { [cp[0], cp[1], cp[2], a] } else { [sp[0], sp[1], sp[2], a] };
p = if sp[3] <= 0.0 {
[cp[0], cp[1], cp[2], a]
} else if a > 1e-3 {
// Normal over the fill: a·p + f·(1 - a)·fill = pixels (premultiplied).
let k = fp[3] * (1.0 - a);
let un = |i: usize| ((cp[3] * cp[i] - k * fp[i]) / a).clamp(0.0, 1.0);
[un(0), un(1), un(2), a]
} else {
[sp[0], sp[1], sp[2], a]
};
}
out.extend_from_slice(&p);
}

View File

@ -165,3 +165,31 @@ fn several_strokes_stack_top_instance_first() {
assert!(close4(px(&d, 20, 30), [1.0, 1.0, 0.0, 1.0]));
assert!(close4(px(&d, 14, 30), [1.0; 4]));
}
#[test]
fn hairline_vector_stroke_keeps_the_files_edge_pixel_colour() {
// The file's shape was snapped to whole pixels (Align Edges) with a 0.25 px stroke: its edge
// pixels are opaque and mostly fill. Our path sits off the pixel grid, so our fill covers only
// part of an edge pixel; fitting the stroke to the file's pixels must keep their colour there,
// not paint the edge in the stroke's full colour. At every depth.
for (sample, fmt) in [(SampleType::U8, PixelFormat::RGBA8), (SampleType::U16, PixelFormat::RGBA16), (SampleType::F32, PixelFormat::RGBA32F)] {
let mut d = Document::with_background("t", Size::new(40, 40), ColorMode::Rgb, sample, Color::WHITE);
let edge = 0.86;
let vs = ShapeStroke { width: 0.25, paint: Fill::Solid(Color::rgb(0.48, 0.48, 0.48)), ..ShapeStroke::default() };
let path = Path::new(vec![Subpath::polygon(&[(10.3, 10.3), (29.7, 10.3), (29.7, 29.7), (10.3, 29.7)])]);
let mut cache = photocraft_raster::Surface::new(fmt);
cache.fill_rect(Rect::new(10, 10, 30, 30), &[edge, edge, edge, 1.0]);
cache.fill_rect(Rect::new(11, 11, 29, 29), &[1.0, 1.0, 1.0, 1.0]);
let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::WHITE)), stroke: Some(vs), live: None, cache: Some(cache), psd_raw: None };
let mut l = Layer::new("shape", LayerContent::Shape(sh));
// Any layer effect sends the shape through the fill/stroke split.
l.effects.items = vec![Effect::default_drop_shadow()];
d.layers.push(l);
for (x, y) in [(10, 20), (29, 20), (20, 10), (20, 29)] {
let p = px(&d, x, y);
assert!(close4(p, [edge, edge, edge, 1.0]), "{sample:?} edge pixel ({x}, {y}): {p:?}");
}
// The interior keeps the fill.
assert!(close4(px(&d, 20, 20), [1.0, 1.0, 1.0, 1.0]), "{sample:?} {:?}", px(&d, 20, 20));
}
}