diff --git a/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs b/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs index 8ff2bac6..c44e3b35 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs @@ -662,8 +662,24 @@ public override void DrawImage( return; } - Image convertedImage = image.CloneAs(); - this.DrawImageCore(convertedImage, sourceRect, destinationRect, sampler, ownsSourceImage: true); + // Only the pixels inside the clipped source region are ever sampled by the draw operation. + // When that region covers just part of the image, crop it in the source pixel format first so + // the per-pixel format conversion runs over the required region instead of the whole image. + if (!TryGetDrawImageClip(sourceRect, destinationRect, image.Bounds, out Rectangle clippedSourceRect, out RectangleF clippedDestinationRect)) + { + return; + } + + if (clippedSourceRect == image.Bounds) + { + Image convertedImage = image.CloneAs(); + this.DrawImageCore(convertedImage, sourceRect, destinationRect, sampler, ownsSourceImage: true); + return; + } + + using Image croppedSource = image.Clone(ctx => ctx.Crop(clippedSourceRect)); + Image convertedRegion = croppedSource.CloneAs(); + this.DrawImageCore(convertedRegion, convertedRegion.Bounds, clippedDestinationRect, sampler, ownsSourceImage: true); } /// @@ -722,41 +738,26 @@ private void DrawImageCore( DrawingOptions commandOptions = effectiveOptions; IReadOnlyList commandClipPaths = state.ClipPaths; - if (sourceRect.Width <= 0 || - sourceRect.Height <= 0 || - destinationRect.Width <= 0 || - destinationRect.Height <= 0) - { - return; - } - - Rectangle clippedSourceRect = Rectangle.Intersect(sourceRect, image.Bounds); - if (clippedSourceRect.Width <= 0 || clippedSourceRect.Height <= 0) - { - return; - } - - RectangleF clippedDestinationRect = MapSourceClipToDestination(sourceRect, destinationRect, clippedSourceRect); - if (clippedDestinationRect.Width <= 0 || clippedDestinationRect.Height <= 0) + Image? ownedImage = null; + try { - return; - } + if (!TryGetDrawImageClip(sourceRect, destinationRect, image.Bounds, out Rectangle clippedSourceRect, out RectangleF clippedDestinationRect)) + { + return; + } - Size scaledSize = new( - Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Width)), - Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Height))); + Size scaledSize = new( + Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Width)), + Math.Max(1, (int)MathF.Ceiling(clippedDestinationRect.Height))); - bool requiresScaling = - clippedSourceRect.Width != scaledSize.Width || - clippedSourceRect.Height != scaledSize.Height; + bool requiresScaling = + clippedSourceRect.Width != scaledSize.Width || + clippedSourceRect.Height != scaledSize.Height; - Image brushImage = image; - RectangleF brushImageRegion = clippedSourceRect; - RectangleF renderDestinationRect = clippedDestinationRect; - Image? ownedImage = null; + Image brushImage = image; + RectangleF brushImageRegion = clippedSourceRect; + RectangleF renderDestinationRect = clippedDestinationRect; - try - { // Phase 1: Prepare source pixels (crop/scale) in image-local space. if (requiresScaling) { @@ -1532,6 +1533,44 @@ private static Image CreateTransformedDrawImage( sampler ?? KnownResamplers.Bicubic)); } + /// + /// Computes the source and destination rectangles that a draw-image operation will actually + /// touch, clipping the requested source rectangle to the image bounds. + /// + /// Requested source rectangle. + /// Requested destination rectangle. + /// Bounds of the source image. + /// Receives the source rectangle clipped to . + /// Receives the destination rectangle matching . + /// when the operation covers a non-empty region; otherwise . + private static bool TryGetDrawImageClip( + Rectangle sourceRect, + RectangleF destinationRect, + Rectangle imageBounds, + out Rectangle clippedSourceRect, + out RectangleF clippedDestinationRect) + { + clippedSourceRect = default; + clippedDestinationRect = default; + + // A zero-area source cannot be sampled and would divide by zero when mapping to the destination. + if (sourceRect.Width <= 0 || sourceRect.Height <= 0) + { + return false; + } + + clippedSourceRect = Rectangle.Intersect(sourceRect, imageBounds); + if (clippedSourceRect.Width <= 0 || clippedSourceRect.Height <= 0) + { + return false; + } + + clippedDestinationRect = MapSourceClipToDestination(sourceRect, destinationRect, clippedSourceRect); + + // A degenerate (empty or inverted) destination maps to nothing to draw. + return clippedDestinationRect.Width > 0 && clippedDestinationRect.Height > 0; + } + /// /// Maps a clipped source rectangle back to the corresponding destination rectangle. /// diff --git a/tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.DrawImage.cs b/tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.DrawImage.cs index a0a56b43..5b62de7b 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.DrawImage.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.DrawImage.cs @@ -107,4 +107,186 @@ public void DrawImage_WithClipPathAndTransform_MatchesReference(TestImag target.DebugSave(provider, appendSourceFileOrDescription: false); target.CompareToReferenceOutput(provider, appendSourceFileOrDescription: false); } + + [Theory] + [WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)] + public void DrawImage_WithForeignPixelFormat_MatchesFullConversion(TestImageProvider provider) + where TPixel : unmanaged, IPixel + => AssertForeignPixelFormatMatchesFullConversion( + provider, + new Rectangle(64, 48, 180, 150), + new RectangleF(40, 30, 200, 170), + new Matrix4x4(Matrix3x2.CreateRotation(0.28F, new Vector2(160, 120)))); + + [Theory] + [WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)] + public void DrawImage_WithForeignPixelFormat_PartialRegionNoTransform_MatchesFullConversion(TestImageProvider provider) + where TPixel : unmanaged, IPixel + => AssertForeignPixelFormatMatchesFullConversion( + provider, + new Rectangle(64, 48, 180, 150), + new RectangleF(40, 30, 200, 170), + Matrix4x4.Identity); + + [Theory] + [WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)] + public void DrawImage_WithForeignPixelFormat_SourceOutsideTopLeft_MatchesFullConversion(TestImageProvider provider) + where TPixel : unmanaged, IPixel + => AssertForeignPixelFormatMatchesFullConversion( + provider, + new Rectangle(-48, -32, 220, 190), + new RectangleF(30, 24, 210, 180), + new Matrix4x4(Matrix3x2.CreateRotation(0.21F, new Vector2(160, 120)))); + + [Theory] + [WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)] + public void DrawImage_WithForeignPixelFormat_SourceOutsideBottomRight_MatchesFullConversion(TestImageProvider provider) + where TPixel : unmanaged, IPixel + => AssertForeignPixelFormatMatchesFullConversion( + provider, + new Rectangle(200, 150, 260, 220), + new RectangleF(48, 40, 200, 168), + Matrix4x4.Identity); + + [Theory] + [WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)] + public void DrawImage_WithForeignPixelFormat_ProjectiveTransform_MatchesFullConversion(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + // A quad/projective transform (non-affine Matrix4x4 with perspective terms) combined + // with a rotation, exercising the transform path over the clipped region. + Matrix4x4 projective = new Matrix4x4(Matrix3x2.CreateRotation(0.18F, new Vector2(160, 120))) + { + M14 = 0.0006F, + M24 = 0.0004F + }; + + AssertForeignPixelFormatMatchesFullConversion( + provider, + new Rectangle(56, 40, 190, 160), + new RectangleF(44, 34, 200, 168), + projective); + } + + [Theory] + [WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)] + public void DrawImage_WithForeignPixelFormat_WholeImage_MatchesFullConversion(TestImageProvider provider) + where TPixel : unmanaged, IPixel + => AssertForeignPixelFormatMatchesFullConversion( + provider, + new Rectangle(0, 0, 320, 240), + new RectangleF(24, 20, 260, 200), + new Matrix4x4(Matrix3x2.CreateRotation(0.15F, new Vector2(160, 120)))); + + [Theory] + [WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)] + public void DrawImage_WithEmptySourceRect_IsNoOp(TestImageProvider provider) + where TPixel : unmanaged, IPixel + => AssertDrawImageIsNoOp( + provider, + new Rectangle(40, 30, 0, 120), + new RectangleF(20, 20, 200, 160)); + + [Theory] + [WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)] + public void DrawImage_WithEmptyDestinationRect_IsNoOp(TestImageProvider provider) + where TPixel : unmanaged, IPixel + => AssertDrawImageIsNoOp( + provider, + new Rectangle(40, 30, 180, 150), + new RectangleF(20, 20, 200, 0)); + + [Theory] + [WithBasicTestPatternImages(320, 240, PixelTypes.Rgba32)] + public void DrawImage_WithSourceRectFullyOutsideImage_IsNoOp(TestImageProvider provider) + where TPixel : unmanaged, IPixel + => AssertDrawImageIsNoOp( + provider, + new Rectangle(400, 300, 120, 100), + new RectangleF(20, 20, 200, 160)); + + /// + /// A draw whose clipped source/destination region is empty must be a no-op for both the + /// typed overload and the foreign-pixel-format + /// overload, leaving the cleared background untouched. + /// + private static void AssertDrawImageIsNoOp( + TestImageProvider provider, + Rectangle sourceRect, + RectangleF destinationRect) + where TPixel : unmanaged, IPixel + { + using Image source = provider.GetImage(); + + // A source image whose pixel format differs from the canvas, forcing the foreign-format path. + using Image foreignSource = source.CloneAs(); + + // The reference is the cleared background: a degenerate draw must not change any pixel. + using Image expected = new(source.Width, source.Height); + using (DrawingCanvas reference = CreateCanvas(provider, expected, new DrawingOptions())) + { + reference.Clear(Brushes.Solid(Color.White)); + } + + void AssertNoOp(Action> draw) + { + using Image actual = new(source.Width, source.Height); + using (DrawingCanvas canvas = CreateCanvas(provider, actual, new DrawingOptions())) + { + canvas.Clear(Brushes.Solid(Color.White)); + draw(canvas); + } + + ImageComparer.Exact.VerifySimilarity(expected, actual); + } + + // Typed overload -> DrawImageCore empty-region early-return. + AssertNoOp(canvas => canvas.DrawImage(source, sourceRect, destinationRect, KnownResamplers.Bicubic)); + + // Foreign-format overload -> DrawImage empty-region early-return before any conversion. + AssertNoOp(canvas => canvas.DrawImage((Image)foreignSource, sourceRect, destinationRect, KnownResamplers.Bicubic)); + } + + /// + /// Drawing a foreign-pixel-format image (which converts only the clipped source region) must + /// produce pixels identical to first converting the whole image to the canvas format and drawing that. + /// + private static void AssertForeignPixelFormatMatchesFullConversion( + TestImageProvider provider, + Rectangle sourceRect, + RectangleF destinationRect, + Matrix4x4 transform) + where TPixel : unmanaged, IPixel + { + using Image source = provider.GetImage(); + + // A source image whose pixel format differs from the canvas, forcing a per-pixel conversion. + using Image foreignSource = source.CloneAs(); + + // Reference source: the whole foreign image converted up-front to the canvas format. + using Image convertedSource = foreignSource.CloneAs(); + + DrawingOptions options = new() + { + Transform = transform + }; + + using Image actual = new(source.Width, source.Height); + using Image expected = new(source.Width, source.Height); + + using (DrawingCanvas canvas = CreateCanvas(provider, actual, options)) + { + canvas.Clear(Brushes.Solid(Color.White)); + canvas.DrawImage((Image)foreignSource, sourceRect, destinationRect, KnownResamplers.Bicubic); + } + + using (DrawingCanvas canvas = CreateCanvas(provider, expected, options)) + { + canvas.Clear(Brushes.Solid(Color.White)); + canvas.DrawImage(convertedSource, sourceRect, destinationRect, KnownResamplers.Bicubic); + } + + // Converting only the clipped region must produce pixels identical to converting the whole image. + ImageComparer.Exact.VerifySimilarity(expected, actual); + } }