Browse Source

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.
pull/25427/head
maliming 5 months ago
parent
commit
7e36451af4
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 3
      framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageCompressorContributor.cs
  2. 140
      framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageResizerContributor.cs
  3. 44
      framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageCompressorTests.cs
  4. 77
      framework/test/Volo.Abp.Imaging.SkiaSharp.Tests/Volo/Abp/Imaging/SkiaSharpImageResizerTests.cs

3
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<Stream>(output, ImageProcessState.Done);
}

140
framework/src/Volo.Abp.Imaging.SkiaSharp/Volo/Abp/Imaging/SkiaSharpImageResizerContributor.cs

@ -63,7 +63,7 @@ public class SkiaSharpImageResizerContributor : IImageResizerContributor, ITrans
return new ImageResizeResult<Stream>(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!");
}
}
}

44
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<IImageCompressorContributor>();
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<int> 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);
}
}
}

77
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();
}
}

Loading…
Cancel
Save