From 68bb49b081c7df3a3c10a77e1f1e77434dd54a82 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 14 May 2026 13:56:49 +0800 Subject: [PATCH] Add SkiaSharp image compressor and bump Magick.NET - Add SkiaSharpImageCompressorContributor and SkiaSharpCompressOptions so Volo.Abp.Imaging.SkiaSharp ships a compressor alongside its existing resizer. - Bump Magick.NET-Q16-AnyCPU 14.9.1 -> 14.13.0 to clear the NU1901/NU1902/NU1903 advisories. --- Directory.Packages.props | 2 +- .../Abp/Imaging/SkiaSharpCompressOptions.cs | 11 ++ .../SkiaSharpImageCompressorContributor.cs | 151 ++++++++++++++++++ .../Imaging/SkiaSharpImageCompressorTests.cs | 95 +++++++++++ 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpCompressOptions.cs create mode 100644 framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageCompressorContributor.cs create mode 100644 framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageCompressorTests.cs 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/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..87912db3fb --- /dev/null +++ b/framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageCompressorContributor.cs @@ -0,0 +1,151 @@ +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); + + 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 < stream.Length) + { + 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/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..0963a50fac --- /dev/null +++ b/framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageCompressorTests.cs @@ -0,0 +1,95 @@ +using System.IO; +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(); + } +}