Browse Source

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.
pull/25427/head
maliming 3 months ago
parent
commit
68bb49b081
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 2
      Directory.Packages.props
  2. 11
      framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpCompressOptions.cs
  3. 151
      framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageCompressorContributor.cs
  4. 95
      framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageCompressorTests.cs

2
Directory.Packages.props

@ -55,7 +55,7 @@
<PackageVersion Include="JetBrains.Annotations" Version="2025.2.2" />
<PackageVersion Include="LdapForNet" Version="2.7.15" />
<PackageVersion Include="LibGit2Sharp" Version="0.31.0" />
<PackageVersion Include="Magick.NET-Q16-AnyCPU" Version="14.9.1" />
<PackageVersion Include="Magick.NET-Q16-AnyCPU" Version="14.13.0" />
<PackageVersion Include="MailKit" Version="4.13.0" />
<PackageVersion Include="Markdig.Signed" Version="0.42.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />

11
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;
}
}

151
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<SkiaSharpCompressOptions> options)
{
Options = options.Value;
}
public virtual async Task<ImageCompressResult<Stream>> TryCompressAsync(
Stream stream,
string? mimeType = null,
CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(mimeType) && !CanCompress(mimeType))
{
return new ImageCompressResult<Stream>(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>(stream, ImageProcessState.Unsupported);
}
using var bitmap = SKBitmap.Decode(memoryBitmapStream);
if (bitmap == null)
{
return new ImageCompressResult<Stream>(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<Stream>(output, ImageProcessState.Done);
}
output.Dispose();
return new ImageCompressResult<Stream>(stream, ImageProcessState.Canceled);
}
catch
{
output.Dispose();
throw;
}
}
finally
{
memoryBitmapStream.Dispose();
memorySkCodecStream.Dispose();
}
}
public virtual async Task<ImageCompressResult<byte[]>> TryCompressAsync(
byte[] bytes,
string? mimeType = null,
CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(mimeType) && !CanCompress(mimeType))
{
return new ImageCompressResult<byte[]>(bytes, ImageProcessState.Unsupported);
}
using var ms = new MemoryStream(bytes);
var result = await TryCompressAsync(ms, mimeType, cancellationToken);
if (result.State != ImageProcessState.Done)
{
return new ImageCompressResult<byte[]>(bytes, result.State);
}
var newBytes = await result.Result.GetAllBytesAsync(cancellationToken);
result.Result.Dispose();
return new ImageCompressResult<byte[]>(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;
}
}
}

95
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<IImageCompressor>();
}
protected override void AfterAddApplication(IServiceCollection services)
{
services.Configure<SkiaSharpCompressOptions>(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();
}
}
Loading…
Cancel
Save