Skip to content

Commit c9a9e4d

Browse files
Expand layer effect bounds using reach
Introduce a `Reach` contract on `LayerEffect` and implement effect-specific reach values (blur, glow, shadows, color matrix, backdrop) so layer processing can reserve enough pixels for blur spread and offsets. `DrawingCanvas.SaveLayer` now inflates content/region bounds by effect reach to prevent clipped output, and a new SaveLayer blur test covers the box-shadow-style case where output must extend and feather beyond the original content bounds.
1 parent 787de2f commit c9a9e4d

4 files changed

Lines changed: 92 additions & 23 deletions

File tree

src/ImageSharp.Drawing/Processing/BackdropLayerEffect.cs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ protected BackdropLayerEffect(BackdropLayerEffect fallbackEffect)
3131
: base(fallbackEffect)
3232
{
3333
}
34+
35+
/// <summary>
36+
/// Gets the output reach. Backdrop effects filter the pixels already beneath the layer
37+
/// clipped to the region, so their output never extends past it.
38+
/// </summary>
39+
public sealed override int Reach => 0;
3440
}
3541

3642
/// <summary>
@@ -56,7 +62,7 @@ public BackdropBlurLayerEffect(float sigma)
5662
public float Sigma { get; }
5763

5864
/// <inheritdoc/>
59-
internal override bool IsPassThrough => this.Sigma == 0;
65+
public override bool IsPassThrough => this.Sigma == 0;
6066
}
6167

6268
/// <summary>
@@ -163,11 +169,11 @@ public BackdropDropShadowLayerEffect(Point offset, float sigma, Color color)
163169
public Color Color { get; }
164170

165171
/// <inheritdoc/>
166-
internal override GraphicsOptions? WriteBackOptions
172+
public override GraphicsOptions? WriteBackOptions
167173
=> new() { AlphaCompositionMode = PixelAlphaCompositionMode.DestOver };
168174

169175
/// <inheritdoc/>
170-
internal override Point WriteBackOffset => this.Offset;
176+
public override Point WriteBackOffset => this.Offset;
171177

172178
/// <summary>
173179
/// Creates the CPU backdrop drop-shadow operation for the supplied effect values.
@@ -219,7 +225,7 @@ public BackdropColorMatrixLayerEffect(ColorMatrix matrix)
219225
public ColorMatrix Matrix { get; }
220226

221227
/// <inheritdoc/>
222-
internal override bool IsPassThrough => this.Matrix == ColorMatrix.Identity;
228+
public override bool IsPassThrough => this.Matrix == ColorMatrix.Identity;
223229
}
224230

225231
/// <summary>

src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,10 @@ public override int SaveLayer(GraphicsOptions layerOptions, IPath region, LayerE
413413
private int SaveEffectLayerCore(GraphicsOptions layerOptions, Rectangle bounds, LayerEffect effect, DrawingOptions? options)
414414
{
415415
Guard.NotNull(effect, nameof(effect));
416+
417+
// Rectangular content bounds grow into the effect region here so blurred and offset
418+
// output spills naturally around the content instead of being cut at its edge.
419+
bounds.Inflate(effect.Reach, effect.Reach);
416420
return this.SaveEffectLayerCore(layerOptions, new RectanglePolygon(bounds), effect, options);
417421
}
418422

@@ -433,12 +437,15 @@ private int SaveEffectLayerCore(GraphicsOptions layerOptions, IPath region, Laye
433437
Guard.NotNull(region, nameof(region));
434438
Guard.NotNull(effect, nameof(effect));
435439

440+
// The layer must contain the effect output, including pixels the write-back lands beyond
441+
// the region when the effect carries an offset.
436442
RectangleF regionBounds = region.Bounds;
443+
int reach = Math.Max(effect.Reach, 1);
437444
Rectangle layerBounds = Rectangle.FromLTRB(
438-
(int)MathF.Floor(regionBounds.Left),
439-
(int)MathF.Floor(regionBounds.Top),
440-
(int)MathF.Ceiling(regionBounds.Right),
441-
(int)MathF.Ceiling(regionBounds.Bottom));
445+
(int)MathF.Floor(regionBounds.Left) - reach,
446+
(int)MathF.Floor(regionBounds.Top) - reach,
447+
(int)MathF.Ceiling(regionBounds.Right) + reach,
448+
(int)MathF.Ceiling(regionBounds.Bottom) + reach);
442449

443450
DrawingCanvasState currentState = this.ResolveState();
444451

src/ImageSharp.Drawing/Processing/LayerEffect.cs

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public abstract class LayerEffect
1616
private readonly GraphicsOptions? writeBackOptions;
1717
private readonly Point writeBackOffset;
1818
private readonly Action<IImageProcessingContext> operation;
19+
private readonly int reach;
1920

2021
/// <summary>
2122
/// Initializes a new instance of the <see cref="LayerEffect"/> class.
@@ -39,25 +40,33 @@ protected LayerEffect(LayerEffect fallbackEffect)
3940
this.writeBackOptions = fallbackEffect.WriteBackOptions;
4041
this.writeBackOffset = fallbackEffect.WriteBackOffset;
4142
this.operation = fallbackEffect.CreateOperation();
43+
this.reach = fallbackEffect.Reach;
4244
}
4345

46+
/// <summary>
47+
/// Gets the distance, in pixels, the effect can push content beyond its source region. Layer
48+
/// and processing bounds are expanded by this reach so the effect output is not cut off.
49+
/// Effects constructed over a fallback inherit the fallback's reach.
50+
/// </summary>
51+
public virtual int Reach => this.reach;
52+
4453
/// <summary>
4554
/// Gets a value indicating whether the effect leaves the layer content unchanged, in which
4655
/// case its application is skipped entirely.
4756
/// </summary>
48-
internal virtual bool IsPassThrough => this.isPassThrough;
57+
public virtual bool IsPassThrough => this.isPassThrough;
4958

5059
/// <summary>
5160
/// Gets the graphics options used to composite the processed pixels back onto the layer, or
5261
/// <see langword="null"/> to replace the processed region outright.
5362
/// </summary>
54-
internal virtual GraphicsOptions? WriteBackOptions => this.writeBackOptions;
63+
public virtual GraphicsOptions? WriteBackOptions => this.writeBackOptions;
5564

5665
/// <summary>
5766
/// Gets the offset, in pixels, at which the processed pixels are written back relative to the
5867
/// region they were read from.
5968
/// </summary>
60-
internal virtual Point WriteBackOffset => this.writeBackOffset;
69+
public virtual Point WriteBackOffset => this.writeBackOffset;
6170

6271
/// <summary>
6372
/// Creates the image-processing operation that transforms the layer snapshot into the effect
@@ -89,13 +98,16 @@ public BlurLayerEffect(float sigma)
8998
public float Sigma { get; }
9099

91100
/// <inheritdoc/>
92-
internal override bool IsPassThrough => this.Sigma == 0;
101+
public override int Reach => (int)MathF.Ceiling(this.Sigma * 3F) + 1;
93102

94103
/// <inheritdoc/>
95-
internal override GraphicsOptions? WriteBackOptions => null;
104+
public override bool IsPassThrough => this.Sigma == 0;
96105

97106
/// <inheritdoc/>
98-
internal override Point WriteBackOffset => default;
107+
public override GraphicsOptions? WriteBackOptions => null;
108+
109+
/// <inheritdoc/>
110+
public override Point WriteBackOffset => default;
99111
}
100112

101113
/// <summary>
@@ -138,11 +150,15 @@ public DropShadowLayerEffect(Point offset, float sigma, Color color)
138150
public Color Color { get; }
139151

140152
/// <inheritdoc/>
141-
internal override GraphicsOptions? WriteBackOptions
153+
public override int Reach
154+
=> (int)MathF.Ceiling(this.Sigma * 3F) + Math.Max(Math.Abs(this.Offset.X), Math.Abs(this.Offset.Y)) + 1;
155+
156+
/// <inheritdoc/>
157+
public override GraphicsOptions? WriteBackOptions
142158
=> new() { AlphaCompositionMode = PixelAlphaCompositionMode.DestOver };
143159

144160
/// <inheritdoc/>
145-
internal override Point WriteBackOffset => this.Offset;
161+
public override Point WriteBackOffset => this.Offset;
146162

147163
/// <summary>
148164
/// Creates the CPU drop-shadow operation for the supplied effect values.
@@ -207,11 +223,14 @@ public GlowLayerEffect(float sigma, Color color)
207223
public Color Color { get; }
208224

209225
/// <inheritdoc/>
210-
internal override GraphicsOptions? WriteBackOptions
226+
public override int Reach => (int)MathF.Ceiling(this.Sigma * 3F) + 1;
227+
228+
/// <inheritdoc/>
229+
public override GraphicsOptions? WriteBackOptions
211230
=> new() { AlphaCompositionMode = PixelAlphaCompositionMode.DestOver };
212231

213232
/// <inheritdoc/>
214-
internal override Point WriteBackOffset => default;
233+
public override Point WriteBackOffset => default;
215234

216235
/// <summary>
217236
/// Creates the CPU glow operation for the supplied effect values.
@@ -284,11 +303,15 @@ public InnerShadowLayerEffect(Point offset, float sigma, Color color)
284303
public Color Color { get; }
285304

286305
/// <inheritdoc/>
287-
internal override GraphicsOptions? WriteBackOptions
306+
public override int Reach
307+
=> (int)MathF.Ceiling(this.Sigma * 3F) + Math.Max(Math.Abs(this.Offset.X), Math.Abs(this.Offset.Y)) + 1;
308+
309+
/// <inheritdoc/>
310+
public override GraphicsOptions? WriteBackOptions
288311
=> new() { AlphaCompositionMode = PixelAlphaCompositionMode.SrcAtop };
289312

290313
/// <inheritdoc/>
291-
internal override Point WriteBackOffset => this.Offset;
314+
public override Point WriteBackOffset => this.Offset;
292315

293316
/// <summary>
294317
/// Creates the CPU inner-shadow operation for the supplied effect values.
@@ -343,11 +366,14 @@ public ColorMatrixLayerEffect(ColorMatrix matrix)
343366
public ColorMatrix Matrix { get; }
344367

345368
/// <inheritdoc/>
346-
internal override GraphicsOptions? WriteBackOptions => null;
369+
public override int Reach => 0;
370+
371+
/// <inheritdoc/>
372+
public override GraphicsOptions? WriteBackOptions => null;
347373

348374
/// <inheritdoc/>
349-
internal override Point WriteBackOffset => default;
375+
public override Point WriteBackOffset => default;
350376

351377
/// <inheritdoc/>
352-
internal override bool IsPassThrough => this.Matrix == ColorMatrix.Identity;
378+
public override bool IsPassThrough => this.Matrix == ColorMatrix.Identity;
353379
}

tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.SaveLayer.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,4 +370,34 @@ public void SaveLayer_Apply_CompositesLayerOpacityAfterProcessing<TPixel>(TestIm
370370
Assert.Equal(255, blended.B);
371371
Assert.Equal(255, blended.A);
372372
}
373+
374+
[Theory]
375+
[WithSolidFilledImages(240, 160, nameof(Color.White), PixelTypes.Rgba32)]
376+
public void SaveLayer_BlurEffect_OutputReachesBeyondContentBounds<TPixel>(TestImageProvider<TPixel> provider)
377+
where TPixel : unmanaged, IPixel<TPixel>
378+
{
379+
using Image<TPixel> target = provider.GetImage();
380+
381+
// The box-shadow pattern: a blur effect layer whose bounds equal the shadow geometry
382+
// exactly, relying on the canvas to expand the effect region by the blur's reach.
383+
Rectangle content = new(60, 40, 120, 80);
384+
using (DrawingCanvas<TPixel> canvas = CreateCanvas(provider, target, new DrawingOptions()))
385+
{
386+
canvas.Clear(Brushes.Solid(Color.White));
387+
canvas.SaveLayer(new GraphicsOptions(), content, new BlurLayerEffect(6F));
388+
canvas.Fill(Brushes.Solid(Color.Black), new RectanglePolygon((RectangleF)content));
389+
canvas.Restore();
390+
}
391+
392+
target.DebugSave(provider, appendSourceFileOrDescription: false);
393+
394+
// Blurred ink must spill outside the content rectangle, and the boundary column must
395+
// no longer be a hard edge: without reach expansion the output clamps to the content
396+
// bounds, leaving pure white one pixel outside and pure black one pixel inside.
397+
Rgba32 outside = target[content.Right + 4, content.Top + (content.Height / 2)].ToRgba32();
398+
Assert.True(outside.R < 250, $"Expected blurred ink beyond the content edge but found {outside}.");
399+
400+
Rgba32 edge = target[content.Right - 1, content.Top + (content.Height / 2)].ToRgba32();
401+
Assert.True(edge.R > 5, $"Expected the content edge to feather but found {edge}.");
402+
}
373403
}

0 commit comments

Comments
 (0)