diff --git a/Directory.Packages.props b/Directory.Packages.props index 69d1cf0a70..c3cf4389e5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -55,7 +55,7 @@ - + diff --git a/docs/en/framework/infrastructure/image-manipulation.md b/docs/en/framework/infrastructure/image-manipulation.md index 49a2e74c46..587fab07c7 100644 --- a/docs/en/framework/infrastructure/image-manipulation.md +++ b/docs/en/framework/infrastructure/image-manipulation.md @@ -1,12 +1,12 @@ ```json //[doc-seo] { - "Description": "Learn how to efficiently compress and resize images in your applications using ABP Framework's extensible services powered by ImageSharp and Magick.NET." + "Description": "Learn how to efficiently compress and resize images in your applications using ABP Framework's extensible services powered by ImageSharp, Magick.NET and SkiaSharp." } ``` # Image Manipulation -ABP provides services to compress and resize images and implements these services with popular [ImageSharp](https://sixlabors.com/products/imagesharp/) and [Magick.NET](https://github.com/dlemstra/Magick.NET) libraries. You can use these services in your reusable modules, libraries and applications, so you don't depend on a specific imaging library. +ABP provides services to compress and resize images and implements these services with popular [ImageSharp](https://sixlabors.com/products/imagesharp/), [Magick.NET](https://github.com/dlemstra/Magick.NET) and [SkiaSharp](https://github.com/mono/SkiaSharp) libraries. You can use these services in your reusable modules, libraries and applications, so you don't depend on a specific imaging library. > The image resizer/compressor system is designed to be extensible. You can implement your own image resizer/compressor contributor and use it in your application. @@ -46,10 +46,11 @@ public class YourModule : AbpModule ## Providers -ABP provides two image resizer/compressor implementations out of the box: +ABP provides three image resizer/compressor implementations out of the box: * [Magick.NET](#magick-net-provider) * [ImageSharp](#imagesharp-provider) +* [SkiaSharp](#skiasharp-provider) You should install one of these provides to make it actually working. @@ -334,6 +335,67 @@ Configure(options => }); ``` +## SkiaSharp Provider + +`Volo.Abp.Imaging.SkiaSharp` NuGet package implements the image operations using the [SkiaSharp](https://github.com/mono/SkiaSharp) library. + +## Installation + +You can add this package to your application by either using the [ABP CLI](../../cli) or manually installing it. Using the [ABP CLI](../../cli) is the recommended approach. + +### Using the ABP CLI + +Open a command line terminal in the folder of your project (.csproj file) and type the following command: + +```bash +abp add-package Volo.Abp.Imaging.SkiaSharp +``` + +### Manual Installation + +If you want to manually install; + +1. Add the [Volo.Abp.Imaging.SkiaSharp](https://www.nuget.org/packages/Volo.Abp.Imaging.SkiaSharp) NuGet package to your project: + +``` +dotnet add package Volo.Abp.Imaging.SkiaSharp +``` + +2. Add `AbpImagingSkiaSharpModule` to your [module](../architecture/modularity/basics.md)'s dependency list: + +```csharp +[DependsOn(typeof(AbpImagingSkiaSharpModule))] +public class MyModule : AbpModule +{ + //... +} +``` + +### Configuration + +`SkiaSharpResizerOptions` is an [options object](../fundamentals/options.md) that is used to configure the SkiaSharp image resize system. It has the following properties: + +* `SKSamplingOptions`: The sampling options used by SkiaSharp when resizing. (Default: `SKSamplingOptions.Default`) +* `Quality`: The quality of the encoded image (0-100). (Default: `75`) + +`SkiaSharpCompressOptions` is an [options object](../fundamentals/options.md) that is used to configure the SkiaSharp image compression system. It has the following properties: + +* `Quality`: The quality of the encoded image (0-100). (Default: `75`) + +**Example usage:** + +```csharp +Configure(options => +{ + options.Quality = 80; +}); + +Configure(options => +{ + options.Quality = 60; +}); +``` + ## ASP.NET Core Integration `Volo.Abp.Imaging.AspNetCore` NuGet package defines attributes for controller actions that can automatically compress and/or resize uploaded files. diff --git a/docs/en/package-version-changes.md b/docs/en/package-version-changes.md index 15fc138dc8..84a10fafe9 100644 --- a/docs/en/package-version-changes.md +++ b/docs/en/package-version-changes.md @@ -11,6 +11,7 @@ | Package | Old Version | New Version | PR | |---------|-------------|-------------|-----| +| Magick.NET-Q16-AnyCPU | 14.9.1 | 14.13.0 | #25427 | | MongoDB.Driver | 3.8.0 | 3.8.1 | #25404 | ## 10.4.0-rc.1 diff --git a/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpCompressOptions.cs b/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpCompressOptions.cs new file mode 100644 index 0000000000..1c7e029807 --- /dev/null +++ b/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpCompressOptions.cs @@ -0,0 +1,11 @@ +namespace Volo.Abp.Imaging; + +public class SkiaSharpCompressOptions +{ + public int Quality { get; set; } + + public SkiaSharpCompressOptions() + { + Quality = 75; + } +} 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 new file mode 100644 index 0000000000..c3117d0461 --- /dev/null +++ b/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageCompressorContributor.cs @@ -0,0 +1,152 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using SkiaSharp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Http; + +namespace Volo.Abp.Imaging; + +public class SkiaSharpImageCompressorContributor : IImageCompressorContributor, ITransientDependency +{ + protected SkiaSharpCompressOptions Options { get; } + + public SkiaSharpImageCompressorContributor(IOptions options) + { + Options = options.Value; + } + + public virtual async Task> TryCompressAsync( + Stream stream, + string? mimeType = null, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(mimeType) && !CanCompress(mimeType)) + { + return new ImageCompressResult(stream, ImageProcessState.Unsupported); + } + + var (memoryBitmapStream, memorySkCodecStream) = await CreateMemoryStream(stream, cancellationToken); + var originalLength = memoryBitmapStream.Length; + + try + { + using var codec = SKCodec.Create(memorySkCodecStream); + if (codec == null || !CanEncodeFormat(codec.EncodedFormat)) + { + return new ImageCompressResult(stream, ImageProcessState.Unsupported); + } + + using var bitmap = SKBitmap.Decode(memoryBitmapStream); + if (bitmap == null) + { + return new ImageCompressResult(stream, ImageProcessState.Unsupported); + } + + using var image = SKImage.FromBitmap(bitmap); + using var encoded = image.Encode(codec.EncodedFormat, Options.Quality); + + var output = new MemoryStream(); + try + { + encoded.SaveTo(output); + output.Position = 0; + + if (output.Length < originalLength) + { + return new ImageCompressResult(output, ImageProcessState.Done); + } + + output.Dispose(); + return new ImageCompressResult(stream, ImageProcessState.Canceled); + } + catch + { + output.Dispose(); + throw; + } + } + finally + { + memoryBitmapStream.Dispose(); + memorySkCodecStream.Dispose(); + } + } + + public virtual async Task> TryCompressAsync( + byte[] bytes, + string? mimeType = null, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(mimeType) && !CanCompress(mimeType)) + { + return new ImageCompressResult(bytes, ImageProcessState.Unsupported); + } + + using var ms = new MemoryStream(bytes); + var result = await TryCompressAsync(ms, mimeType, cancellationToken); + + if (result.State != ImageProcessState.Done) + { + return new ImageCompressResult(bytes, result.State); + } + + var newBytes = await result.Result.GetAllBytesAsync(cancellationToken); + result.Result.Dispose(); + return new ImageCompressResult(newBytes, result.State); + } + + protected virtual bool CanCompress(string? mimeType) + { + return mimeType switch { + MimeTypes.Image.Jpeg => true, + MimeTypes.Image.Png => true, + MimeTypes.Image.Webp => true, + _ => false + }; + } + + protected virtual bool CanEncodeFormat(SKEncodedImageFormat format) + { + return format switch + { + SKEncodedImageFormat.Jpeg => true, + SKEncodedImageFormat.Png => true, + SKEncodedImageFormat.Webp => true, + _ => false + }; + } + + protected virtual async Task<(MemoryStream, MemoryStream)> CreateMemoryStream(Stream stream, CancellationToken cancellationToken) + { + var streamPosition = stream.CanSeek ? stream.Position : 0; + + var memoryBitmapStream = new MemoryStream(); + var memorySkCodecStream = new MemoryStream(); + + try + { + await stream.CopyToAsync(memoryBitmapStream, cancellationToken); + + if (stream.CanSeek) + { + stream.Position = streamPosition; + } + + memoryBitmapStream.Position = 0; + await memoryBitmapStream.CopyToAsync(memorySkCodecStream, cancellationToken); + + memoryBitmapStream.Position = 0; + memorySkCodecStream.Position = 0; + + return (memoryBitmapStream, memorySkCodecStream); + } + catch + { + memoryBitmapStream.Dispose(); + memorySkCodecStream.Dispose(); + throw; + } + } +} 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 7a5db23620..dd7da3251a 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 @@ -47,34 +47,76 @@ public class SkiaSharpImageResizerContributor : IImageResizerContributor, ITrans return new ImageResizeResult(stream, ImageProcessState.Unsupported); } - var (memoryBitmapStream, memorySkCodecStream) = await CreateMemoryStream(stream); - - using var original = SKBitmap.Decode(memoryBitmapStream); - using var resized = original.Resize(new SKImageInfo((int)resizeArgs.Width, (int)resizeArgs.Height), Options.SKSamplingOptions); - using var image = SKImage.FromBitmap(resized); - using var codec = SKCodec.Create(memorySkCodecStream); - var memoryStream = new MemoryStream(); - using var skData = image.Encode(codec.EncodedFormat, Options.Quality); - skData.SaveTo(memoryStream); - return new ImageResizeResult(memoryStream, ImageProcessState.Done); + var (memoryBitmapStream, memorySkCodecStream) = await CreateMemoryStream(stream, cancellationToken); + + try + { + using var codec = SKCodec.Create(memorySkCodecStream); + if (codec == null || !CanEncodeFormat(codec.EncodedFormat)) + { + return new ImageResizeResult(stream, ImageProcessState.Unsupported); + } + + using var original = SKBitmap.Decode(memoryBitmapStream); + if (original == null) + { + return new ImageResizeResult(stream, ImageProcessState.Unsupported); + } + + using var resized = ApplyResize(original, resizeArgs); + using var image = SKImage.FromBitmap(resized); + + var memoryStream = new MemoryStream(); + try + { + using var skData = image.Encode(codec.EncodedFormat, Options.Quality); + skData.SaveTo(memoryStream); + memoryStream.Position = 0; + return new ImageResizeResult(memoryStream, ImageProcessState.Done); + } + catch + { + memoryStream.Dispose(); + throw; + } + } + finally + { + memoryBitmapStream.Dispose(); + memorySkCodecStream.Dispose(); + } } - protected virtual async Task<(MemoryStream, MemoryStream)> CreateMemoryStream(Stream stream) + protected virtual async Task<(MemoryStream, MemoryStream)> CreateMemoryStream(Stream stream, CancellationToken cancellationToken = default) { - var streamPosition = stream.Position; + var streamPosition = stream.CanSeek ? stream.Position : 0; var memoryBitmapStream = new MemoryStream(); var memorySkCodecStream = new MemoryStream(); - await stream.CopyToAsync(memoryBitmapStream); - stream.Position = streamPosition; - await stream.CopyToAsync(memorySkCodecStream); - stream.Position = streamPosition; + try + { + await stream.CopyToAsync(memoryBitmapStream, cancellationToken); + + if (stream.CanSeek) + { + stream.Position = streamPosition; + } + + memoryBitmapStream.Position = 0; + await memoryBitmapStream.CopyToAsync(memorySkCodecStream, cancellationToken); - memoryBitmapStream.Position = 0; - memorySkCodecStream.Position = 0; + memoryBitmapStream.Position = 0; + memorySkCodecStream.Position = 0; - return (memoryBitmapStream, memorySkCodecStream); + return (memoryBitmapStream, memorySkCodecStream); + } + catch + { + memoryBitmapStream.Dispose(); + memorySkCodecStream.Dispose(); + throw; + } } protected virtual bool CanResize(string? mimeType) @@ -86,4 +128,150 @@ public class SkiaSharpImageResizerContributor : IImageResizerContributor, ITrans _ => false }; } + + protected virtual bool CanEncodeFormat(SKEncodedImageFormat format) + { + return format switch + { + SKEncodedImageFormat.Jpeg => true, + SKEncodedImageFormat.Png => true, + SKEncodedImageFormat.Webp => true, + _ => 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.Stretch : resizeArgs.Mode; + + switch (mode) + { + case ImageResizeMode.None: + 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.Abstractions.Tests/Volo/Abp/Imaging/Files/abp.gif b/framework/test/Volo.Abp.Imaging.Abstractions.Tests/Volo/Abp/Imaging/Files/abp.gif new file mode 100644 index 0000000000..27f6a1e4dc Binary files /dev/null and b/framework/test/Volo.Abp.Imaging.Abstractions.Tests/Volo/Abp/Imaging/Files/abp.gif differ diff --git a/framework/test/Volo.Abp.Imaging.Abstractions.Tests/Volo/Abp/Imaging/ImageFileHelper.cs b/framework/test/Volo.Abp.Imaging.Abstractions.Tests/Volo/Abp/Imaging/ImageFileHelper.cs index de249fb2e5..2696bcbd3b 100644 --- a/framework/test/Volo.Abp.Imaging.Abstractions.Tests/Volo/Abp/Imaging/ImageFileHelper.cs +++ b/framework/test/Volo.Abp.Imaging.Abstractions.Tests/Volo/Abp/Imaging/ImageFileHelper.cs @@ -19,7 +19,12 @@ public static class ImageFileHelper { return GetTestFileStream("abp.webp"); } - + + public static Stream GetGifTestFileStream() + { + return GetTestFileStream("abp.gif"); + } + private static Stream GetTestFileStream(string fileName) { var assembly = typeof(ImageFileHelper).Assembly; 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 new file mode 100644 index 0000000000..4c575d9f04 --- /dev/null +++ b/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageCompressorTests.cs @@ -0,0 +1,179 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Volo.Abp.Imaging; + +public class SkiaSharpImageCompressorTests : AbpImagingSkiaSharpTestBase +{ + public IImageCompressor ImageCompressor { get; } + + public SkiaSharpImageCompressorTests() + { + ImageCompressor = GetRequiredService(); + } + + protected override void AfterAddApplication(IServiceCollection services) + { + services.Configure(options => + { + options.Quality = 50; + }); + + base.AfterAddApplication(services); + } + + [Fact] + public async Task Should_Compress_Jpg() + { + await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); + var compressedImage = await ImageCompressor.CompressAsync(jpegImage); + + compressedImage.ShouldNotBeNull(); + compressedImage.State.ShouldBe(ImageProcessState.Done); + compressedImage.Result.Length.ShouldBeLessThan(jpegImage.Length); + compressedImage.Result.Dispose(); + } + + [Fact] + public async Task Should_Compress_Png() + { + await using var pngImage = ImageFileHelper.GetPngTestFileStream(); + var compressedImage = await ImageCompressor.CompressAsync(pngImage); + + compressedImage.ShouldNotBeNull(); + + if (compressedImage.State == ImageProcessState.Done) + { + compressedImage.Result.Length.ShouldBeLessThan(pngImage.Length); + } + else + { + compressedImage.State.ShouldBe(ImageProcessState.Canceled); + compressedImage.Result.Length.ShouldBe(pngImage.Length); + } + + compressedImage.Result.Dispose(); + } + + [Fact] + public async Task Should_Compress_Webp() + { + await using var webpImage = ImageFileHelper.GetWebpTestFileStream(); + var compressedImage = await ImageCompressor.CompressAsync(webpImage); + + compressedImage.ShouldNotBeNull(); + compressedImage.State.ShouldBe(ImageProcessState.Done); + compressedImage.Result.Length.ShouldBeLessThan(webpImage.Length); + compressedImage.Result.Dispose(); + } + + [Fact] + public async Task Should_Compress_Stream_And_Byte_Array_The_Same() + { + await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); + var byteArr = await jpegImage.GetAllBytesAsync(); + + var compressedImage1 = await ImageCompressor.CompressAsync(jpegImage); + var compressedImage2 = await ImageCompressor.CompressAsync(byteArr); + + compressedImage1.ShouldNotBeNull(); + compressedImage1.State.ShouldBe(ImageProcessState.Done); + + compressedImage2.ShouldNotBeNull(); + compressedImage2.State.ShouldBe(ImageProcessState.Done); + + compressedImage1.Result.Length.ShouldBeLessThan(jpegImage.Length); + compressedImage2.Result.LongLength.ShouldBeLessThan(jpegImage.Length); + + compressedImage1.Result.Length.ShouldBe(compressedImage2.Result.LongLength); + + compressedImage1.Result.Dispose(); + } + + [Fact] + public async Task Should_Return_Compressed_Stream_Positioned_At_Start() + { + await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); + var compressedImage = await ImageCompressor.CompressAsync(jpegImage); + + compressedImage.ShouldNotBeNull(); + compressedImage.State.ShouldBe(ImageProcessState.Done); + compressedImage.Result.Position.ShouldBe(0); + + using var copy = new MemoryStream(); + await compressedImage.Result.CopyToAsync(copy); + copy.Length.ShouldBe(compressedImage.Result.Length); + + compressedImage.Result.Dispose(); + } + + [Fact] + public async Task Should_Return_Unsupported_For_Gif_Stream() + { + await using var gifImage = ImageFileHelper.GetGifTestFileStream(); + var compressedImage = await ImageCompressor.CompressAsync(gifImage); + + compressedImage.ShouldNotBeNull(); + compressedImage.State.ShouldBe(ImageProcessState.Unsupported); + compressedImage.Result.ShouldBe(gifImage); + } + + [Fact] + public async Task Should_Return_Unsupported_For_Gif_Bytes() + { + await using var gifImage = ImageFileHelper.GetGifTestFileStream(); + var bytes = await gifImage.GetAllBytesAsync(); + var compressedImage = await ImageCompressor.CompressAsync(bytes); + + compressedImage.ShouldNotBeNull(); + 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 460c83bf5b..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; @@ -72,4 +73,120 @@ public class SkiaSharpImageResizerTests : AbpImagingSkiaSharpTestBase resizedImage1.Result.Dispose(); } + + [Fact] + public async Task Should_Return_Resized_Stream_Positioned_At_Start() + { + await using var jpegImage = ImageFileHelper.GetJpgTestFileStream(); + var resizedImage = await ImageResizer.ResizeAsync(jpegImage, new ImageResizeArgs(100, 100)); + + resizedImage.ShouldNotBeNull(); + resizedImage.State.ShouldBe(ImageProcessState.Done); + resizedImage.Result.Position.ShouldBe(0); + + using var copy = new MemoryStream(); + await resizedImage.Result.CopyToAsync(copy); + copy.Length.ShouldBe(resizedImage.Result.Length); + + resizedImage.Result.Dispose(); + } + + [Fact] + public async Task Should_Return_Unsupported_For_Gif_Stream() + { + await using var gifImage = ImageFileHelper.GetGifTestFileStream(); + var resizedImage = await ImageResizer.ResizeAsync(gifImage, new ImageResizeArgs(100, 100)); + + resizedImage.ShouldNotBeNull(); + resizedImage.State.ShouldBe(ImageProcessState.Unsupported); + resizedImage.Result.ShouldBe(gifImage); + } + + [Fact] + public async Task Should_Return_Unsupported_For_Gif_Bytes() + { + await using var gifImage = ImageFileHelper.GetGifTestFileStream(); + var bytes = await gifImage.GetAllBytesAsync(); + var resizedImage = await ImageResizer.ResizeAsync(bytes, new ImageResizeArgs(100, 100)); + + resizedImage.ShouldNotBeNull(); + 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(); + } }