From 7e36451af4dd268d2744309766bdb449bc8ac22a Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 14 May 2026 15:09:10 +0800 Subject: [PATCH] Implement ImageResizeMode in SkiaSharp resizer and harden compressor - SkiaSharp resizer now honors ImageResizeArgs.Mode (Stretch, Max, Min, Crop, Pad, BoxPad) instead of stretching to the target dimensions regardless of mode. None and Default normalize to Crop to match the ImageSharp contributor. - BoxPad now Max-fits the source into the target box first when the source exceeds it, instead of cropping with negative offsets. - SkiaSharp compressor caches the bitmap stream length before SKBitmap.Decode takes ownership of it, so the post-encode size check no longer accesses a disposed stream and tolerates non-seekable inputs reaching the contributor directly. - Add resizer tests for every mode (exact target size for fixed modes, bounded size for Max/Min, pixel-level transparency check for BoxPad with a source larger than the target), and a compressor test that feeds a non-seekable stream directly to the contributor. --- .../SkiaSharpImageCompressorContributor.cs | 3 +- .../SkiaSharpImageResizerContributor.cs | 140 +++++++++++++++++- .../Imaging/SkiaSharpImageCompressorTests.cs | 44 ++++++ .../Abp/Imaging/SkiaSharpImageResizerTests.cs | 77 ++++++++++ 4 files changed, 262 insertions(+), 2 deletions(-) diff --git a/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageCompressorContributor.cs b/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageCompressorContributor.cs index 87912db3fb..c3117d0461 100644 --- a/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageCompressorContributor.cs +++ b/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageCompressorContributor.cs @@ -28,6 +28,7 @@ public class SkiaSharpImageCompressorContributor : IImageCompressorContributor, } var (memoryBitmapStream, memorySkCodecStream) = await CreateMemoryStream(stream, cancellationToken); + var originalLength = memoryBitmapStream.Length; try { @@ -52,7 +53,7 @@ public class SkiaSharpImageCompressorContributor : IImageCompressorContributor, encoded.SaveTo(output); output.Position = 0; - if (output.Length < stream.Length) + if (output.Length < originalLength) { return new ImageCompressResult(output, ImageProcessState.Done); } diff --git a/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageResizerContributor.cs b/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageResizerContributor.cs index 872fa99172..f17ce81fbb 100644 --- a/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageResizerContributor.cs +++ b/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageResizerContributor.cs @@ -63,7 +63,7 @@ public class SkiaSharpImageResizerContributor : IImageResizerContributor, ITrans return new ImageResizeResult(stream, ImageProcessState.Unsupported); } - using var resized = original.Resize(new SKImageInfo((int)resizeArgs.Width, (int)resizeArgs.Height), Options.SKSamplingOptions); + using var resized = ApplyResize(original, resizeArgs); using var image = SKImage.FromBitmap(resized); var memoryStream = new MemoryStream(); @@ -139,4 +139,142 @@ public class SkiaSharpImageResizerContributor : IImageResizerContributor, ITrans _ => false }; } + + protected virtual SKBitmap ApplyResize(SKBitmap source, ImageResizeArgs resizeArgs) + { + var targetWidth = (int)resizeArgs.Width; + var targetHeight = (int)resizeArgs.Height; + + if (targetWidth <= 0 && targetHeight <= 0) + { + return source.Copy(); + } + + if (targetWidth <= 0) + { + targetWidth = Math.Max(1, (int)Math.Round((double)source.Width * targetHeight / source.Height)); + } + else if (targetHeight <= 0) + { + targetHeight = Math.Max(1, (int)Math.Round((double)source.Height * targetWidth / source.Width)); + } + + var mode = resizeArgs.Mode == ImageResizeMode.Default ? ImageResizeMode.Crop : resizeArgs.Mode; + if (mode == ImageResizeMode.None) + { + mode = ImageResizeMode.Crop; + } + + switch (mode) + { + case ImageResizeMode.Stretch: + return source.Resize(new SKImageInfo(targetWidth, targetHeight), Options.SKSamplingOptions); + + case ImageResizeMode.Max: + { + var scale = Math.Min((double)targetWidth / source.Width, (double)targetHeight / source.Height); + var newW = Math.Max(1, (int)Math.Round(source.Width * scale)); + var newH = Math.Max(1, (int)Math.Round(source.Height * scale)); + return source.Resize(new SKImageInfo(newW, newH), Options.SKSamplingOptions); + } + + case ImageResizeMode.Min: + { + var scale = Math.Max((double)targetWidth / source.Width, (double)targetHeight / source.Height); + var newW = Math.Max(1, (int)Math.Round(source.Width * scale)); + var newH = Math.Max(1, (int)Math.Round(source.Height * scale)); + return source.Resize(new SKImageInfo(newW, newH), Options.SKSamplingOptions); + } + + case ImageResizeMode.Crop: + { + var scale = Math.Max((double)targetWidth / source.Width, (double)targetHeight / source.Height); + var intermediateW = Math.Max(1, (int)Math.Round(source.Width * scale)); + var intermediateH = Math.Max(1, (int)Math.Round(source.Height * scale)); + using var intermediate = source.Resize(new SKImageInfo(intermediateW, intermediateH), Options.SKSamplingOptions); + + var bitmap = new SKBitmap(targetWidth, targetHeight); + try + { + using var canvas = new SKCanvas(bitmap); + var srcX = (intermediateW - targetWidth) / 2; + var srcY = (intermediateH - targetHeight) / 2; + canvas.DrawBitmap( + intermediate, + new SKRect(srcX, srcY, srcX + targetWidth, srcY + targetHeight), + new SKRect(0, 0, targetWidth, targetHeight)); + return bitmap; + } + catch + { + bitmap.Dispose(); + throw; + } + } + + case ImageResizeMode.Pad: + { + var scale = Math.Min((double)targetWidth / source.Width, (double)targetHeight / source.Height); + var intermediateW = Math.Max(1, (int)Math.Round(source.Width * scale)); + var intermediateH = Math.Max(1, (int)Math.Round(source.Height * scale)); + using var intermediate = source.Resize(new SKImageInfo(intermediateW, intermediateH), Options.SKSamplingOptions); + + var bitmap = new SKBitmap(targetWidth, targetHeight); + try + { + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + var dstX = (targetWidth - intermediateW) / 2; + var dstY = (targetHeight - intermediateH) / 2; + canvas.DrawBitmap(intermediate, new SKPoint(dstX, dstY)); + return bitmap; + } + catch + { + bitmap.Dispose(); + throw; + } + } + + case ImageResizeMode.BoxPad: + { + SKBitmap? scaled = null; + try + { + var working = source; + if (source.Width > targetWidth || source.Height > targetHeight) + { + var scale = Math.Min((double)targetWidth / source.Width, (double)targetHeight / source.Height); + var newW = Math.Max(1, (int)Math.Round(source.Width * scale)); + var newH = Math.Max(1, (int)Math.Round(source.Height * scale)); + scaled = source.Resize(new SKImageInfo(newW, newH), Options.SKSamplingOptions); + working = scaled; + } + + var bitmap = new SKBitmap(targetWidth, targetHeight); + try + { + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + var dstX = (targetWidth - working.Width) / 2; + var dstY = (targetHeight - working.Height) / 2; + canvas.DrawBitmap(working, new SKPoint(dstX, dstY)); + return bitmap; + } + catch + { + bitmap.Dispose(); + throw; + } + } + finally + { + scaled?.Dispose(); + } + } + + default: + throw new NotSupportedException("Resize mode " + resizeArgs.Mode + " is not supported!"); + } + } } diff --git a/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageCompressorTests.cs b/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageCompressorTests.cs index 0f72658148..4c575d9f04 100644 --- a/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageCompressorTests.cs +++ b/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageCompressorTests.cs @@ -1,4 +1,6 @@ +using System; using System.IO; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Shouldly; @@ -132,4 +134,46 @@ public class SkiaSharpImageCompressorTests : AbpImagingSkiaSharpTestBase compressedImage.State.ShouldBe(ImageProcessState.Unsupported); compressedImage.Result.ShouldBe(bytes); } + + [Fact] + public async Task Should_Handle_Non_Seekable_Stream_Directly_On_Contributor() + { + var contributor = GetRequiredService(); + + await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); + var bytes = await jpegImage.GetAllBytesAsync(); + await using var nonSeekable = new NonSeekableStream(new MemoryStream(bytes)); + + var compressedImage = await contributor.TryCompressAsync(nonSeekable, "image/jpeg"); + + compressedImage.ShouldNotBeNull(); + compressedImage.State.ShouldBeOneOf(ImageProcessState.Done, ImageProcessState.Canceled); + if (compressedImage.State == ImageProcessState.Done) + { + compressedImage.Result.ShouldNotBe(nonSeekable); + compressedImage.Result.Dispose(); + } + } + + private sealed class NonSeekableStream : Stream + { + private readonly Stream _inner; + public NonSeekableStream(Stream inner) { _inner = inner; } + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _inner.ReadAsync(buffer, offset, count, cancellationToken); + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) + { + if (disposing) _inner.Dispose(); + base.Dispose(disposing); + } + } } diff --git a/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageResizerTests.cs b/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageResizerTests.cs index ecc2c2dd79..593beb506d 100644 --- a/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageResizerTests.cs +++ b/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageResizerTests.cs @@ -1,6 +1,7 @@ using System.IO; using System.Threading.Tasks; using Shouldly; +using SkiaSharp; using Xunit; namespace Volo.Abp.Imaging; @@ -112,4 +113,80 @@ public class SkiaSharpImageResizerTests : AbpImagingSkiaSharpTestBase resizedImage.State.ShouldBe(ImageProcessState.Unsupported); resizedImage.Result.ShouldBe(bytes); } + + [Theory] + [InlineData(ImageResizeMode.None)] + [InlineData(ImageResizeMode.Stretch)] + [InlineData(ImageResizeMode.Crop)] + [InlineData(ImageResizeMode.Pad)] + [InlineData(ImageResizeMode.BoxPad)] + [InlineData(ImageResizeMode.Default)] + public async Task Should_Produce_Exact_Target_Size_For_Fixed_Modes(ImageResizeMode mode) + { + await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); + var resizedImage = await ImageResizer.ResizeAsync(jpegImage, new ImageResizeArgs(120, 80, mode)); + + resizedImage.State.ShouldBe(ImageProcessState.Done); + using var decoded = SKBitmap.Decode(resizedImage.Result); + decoded.ShouldNotBeNull(); + decoded.Width.ShouldBe(120); + decoded.Height.ShouldBe(80); + resizedImage.Result.Dispose(); + } + + [Fact] + public async Task Should_Produce_Bounded_Size_For_Max_Mode() + { + await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); + var resizedImage = await ImageResizer.ResizeAsync(jpegImage, new ImageResizeArgs(120, 80, ImageResizeMode.Max)); + + resizedImage.State.ShouldBe(ImageProcessState.Done); + using var decoded = SKBitmap.Decode(resizedImage.Result); + decoded.Width.ShouldBeLessThanOrEqualTo(120); + decoded.Height.ShouldBeLessThanOrEqualTo(80); + (decoded.Width == 120 || decoded.Height == 80).ShouldBeTrue(); + resizedImage.Result.Dispose(); + } + + [Fact] + public async Task Should_Max_Fit_Source_Larger_Than_Target_For_BoxPad_Mode() + { + using var src = new SKBitmap(400, 100); + using (var canvas = new SKCanvas(src)) + { + canvas.Clear(SKColors.Red); + } + using var srcImage = SKImage.FromBitmap(src); + using var srcData = srcImage.Encode(SKEncodedImageFormat.Png, 100); + using var srcStream = new MemoryStream(); + srcData.SaveTo(srcStream); + srcStream.Position = 0; + + var resizedImage = await ImageResizer.ResizeAsync(srcStream, new ImageResizeArgs(100, 100, ImageResizeMode.BoxPad)); + + resizedImage.State.ShouldBe(ImageProcessState.Done); + using var decoded = SKBitmap.Decode(resizedImage.Result); + decoded.Width.ShouldBe(100); + decoded.Height.ShouldBe(100); + + decoded.GetPixel(50, 0).Alpha.ShouldBe((byte)0); + decoded.GetPixel(50, 99).Alpha.ShouldBe((byte)0); + decoded.GetPixel(50, 50).Red.ShouldBeGreaterThan((byte)200); + + resizedImage.Result.Dispose(); + } + + [Fact] + public async Task Should_Produce_Bounded_Size_For_Min_Mode() + { + await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); + var resizedImage = await ImageResizer.ResizeAsync(jpegImage, new ImageResizeArgs(120, 80, ImageResizeMode.Min)); + + resizedImage.State.ShouldBe(ImageProcessState.Done); + using var decoded = SKBitmap.Decode(resizedImage.Result); + decoded.Width.ShouldBeGreaterThanOrEqualTo(120); + decoded.Height.ShouldBeGreaterThanOrEqualTo(80); + (decoded.Width == 120 || decoded.Height == 80).ShouldBeTrue(); + resizedImage.Result.Dispose(); + } }