diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index f2f1eb3ff..a0436bd02 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -28,6 +28,9 @@ + + + diff --git a/src/Splat.SkiaSharp/BitmapMixins.cs b/src/Splat.SkiaSharp/BitmapMixins.cs new file mode 100644 index 000000000..a40308e24 --- /dev/null +++ b/src/Splat.SkiaSharp/BitmapMixins.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using SkiaSharp; + +namespace Splat.SkiaSharp; + +/// Converts between and the Skia bitmap behind it. +/// +/// Skia is not the host's imaging stack, and these conversions do not pretend otherwise: what comes +/// back is an , not a platform bitmap type. +/// +public static class BitmapMixins +{ + /// Extension members for . + /// The value the extension members operate on. + extension(IBitmap value) + { + /// Gets the Skia bitmap behind an this package produced. + /// The Skia bitmap, which the still owns. + /// The bitmap has been disposed. + /// The bitmap came from a different loader. + public SKBitmap ToNative() + { + ArgumentExceptionHelper.ThrowIfNull(value); + + return ((SkiaBitmap)value).Inner ?? throw new InvalidOperationException("The bitmap has been disposed"); + } + } + + /// Extension members for . + /// The value the extension members operate on. + extension(SKBitmap value) + { + /// Wraps a Skia bitmap as an , taking ownership of it. + /// The wrapped bitmap. + public IBitmap FromNative() => new SkiaBitmap(value); + } +} diff --git a/src/Splat.SkiaSharp/Builder/SkiaSharpSplatModule.cs b/src/Splat.SkiaSharp/Builder/SkiaSharpSplatModule.cs new file mode 100644 index 000000000..7197f1f7e --- /dev/null +++ b/src/Splat.SkiaSharp/Builder/SkiaSharpSplatModule.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using SkiaSharp; + +using Splat.SkiaSharp; + +namespace Splat.Builder; + +/// Registers Skia as the bitmap loader when the application is built. +/// +/// Registration is a plain method call rather than assembly scanning, so it survives trimming and +/// ahead-of-time compilation. +/// +/// The sampling to resize with, or for the loader's default. +public sealed class SkiaSharpSplatModule(SKSamplingOptions? sampling) : IModule +{ + /// Initializes a new instance of the class. + public SkiaSharpSplatModule() + : this(null) + { + } + + /// + public void Configure(IMutableDependencyResolver resolver) + { + ArgumentExceptionHelper.ThrowIfNull(resolver); + + if (sampling is null) + { + resolver.UseSkiaSharpBitmapLoader(); + return; + } + + resolver.UseSkiaSharpBitmapLoader(sampling.Value); + } +} diff --git a/src/Splat.SkiaSharp/MutableDependencyResolverExtensions.cs b/src/Splat.SkiaSharp/MutableDependencyResolverExtensions.cs new file mode 100644 index 000000000..763b26150 --- /dev/null +++ b/src/Splat.SkiaSharp/MutableDependencyResolverExtensions.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using SkiaSharp; + +namespace Splat.SkiaSharp; + +/// Registers Skia as the bitmap loader Splat resolves. +/// +/// The plain .NET target frameworks register no bitmap loader of their own, so +/// throws until something is registered. Registration is explicit: nothing scans assemblies for it. +/// +public static class MutableDependencyResolverExtensions +{ + /// Extension members for . + /// An instance of Mutable Dependency Resolver. + extension(IMutableDependencyResolver instance) + { + /// Registers as the . + /// + /// AppLocator.CurrentMutable.UseSkiaSharpBitmapLoader(); + /// + public void UseSkiaSharpBitmapLoader() + { + ArgumentExceptionHelper.ThrowIfNull(instance); + + instance.RegisterLazySingleton(static () => new SkiaBitmapLoader(), typeof(IBitmapLoader)); + } + + /// Registers as the , resizing with the given sampling. + /// The sampling to resize with. + /// + /// AppLocator.CurrentMutable.UseSkiaSharpBitmapLoader(new SKSamplingOptions(SKFilterMode.Nearest)); + /// + public void UseSkiaSharpBitmapLoader(SKSamplingOptions sampling) + { + ArgumentExceptionHelper.ThrowIfNull(instance); + + instance.RegisterLazySingleton(() => new SkiaBitmapLoader(sampling), typeof(IBitmapLoader)); + } + } +} diff --git a/src/Splat.SkiaSharp/PublicAPI/net10.0/PublicAPI.txt b/src/Splat.SkiaSharp/PublicAPI/net10.0/PublicAPI.txt new file mode 100644 index 000000000..ff54a450c --- /dev/null +++ b/src/Splat.SkiaSharp/PublicAPI/net10.0/PublicAPI.txt @@ -0,0 +1,53 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Splat.SkiaSharp.Tests")] +namespace Splat.Builder +{ + public sealed class SkiaSharpSplatModule : Splat.Builder.IModule + { + public SkiaSharpSplatModule() { } + public SkiaSharpSplatModule(SkiaSharp.SKSamplingOptions? sampling) { } + public void Configure(Splat.IMutableDependencyResolver resolver) { } + } +} +namespace Splat.SkiaSharp +{ + public static class BitmapMixins + { + extension(SkiaSharp.SKBitmap value) + { + public Splat.IBitmap FromNative() { } + } + extension(Splat.IBitmap value) + { + public SkiaSharp.SKBitmap ToNative() { } + } + } + public static class MutableDependencyResolverExtensions + { + extension(Splat.IMutableDependencyResolver instance) + { + public void UseSkiaSharpBitmapLoader() { } + public void UseSkiaSharpBitmapLoader(SkiaSharp.SKSamplingOptions sampling) { } + } + } + public sealed class SkiaBitmap : Splat.IBitmap + { + public SkiaBitmap(SkiaSharp.SKBitmap bitmap) { } + public SkiaBitmap(SkiaSharp.SKBitmap bitmap, SkiaSharp.SKEncodedOrigin encodedOrigin) { } + public SkiaSharp.SKEncodedOrigin EncodedOrigin { get; } + public float Height { get; } + public SkiaSharp.SKBitmap? Inner { get; } + public float Width { get; } + public void Dispose() { } + public System.Threading.Tasks.Task Save(SkiaSharp.SKEncodedImageFormat format, float quality, System.IO.Stream target) { } + public System.Threading.Tasks.Task Save(Splat.CompressedBitmapFormat format, float quality, System.IO.Stream target) { } + } + public sealed class SkiaBitmapLoader : Splat.IBitmapLoader + { + public SkiaBitmapLoader() { } + public SkiaBitmapLoader(SkiaSharp.SKSamplingOptions sampling) { } + public SkiaSharp.SKSamplingOptions Sampling { get; } + public Splat.IBitmap Create(float width, float height) { } + public System.Threading.Tasks.Task Load(System.IO.Stream sourceStream, float? desiredWidth, float? desiredHeight) { } + public System.Threading.Tasks.Task LoadFromResource(string source, float? desiredWidth, float? desiredHeight) { } + } +} diff --git a/src/Splat.SkiaSharp/PublicAPI/net11.0/PublicAPI.txt b/src/Splat.SkiaSharp/PublicAPI/net11.0/PublicAPI.txt new file mode 100644 index 000000000..ff54a450c --- /dev/null +++ b/src/Splat.SkiaSharp/PublicAPI/net11.0/PublicAPI.txt @@ -0,0 +1,53 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Splat.SkiaSharp.Tests")] +namespace Splat.Builder +{ + public sealed class SkiaSharpSplatModule : Splat.Builder.IModule + { + public SkiaSharpSplatModule() { } + public SkiaSharpSplatModule(SkiaSharp.SKSamplingOptions? sampling) { } + public void Configure(Splat.IMutableDependencyResolver resolver) { } + } +} +namespace Splat.SkiaSharp +{ + public static class BitmapMixins + { + extension(SkiaSharp.SKBitmap value) + { + public Splat.IBitmap FromNative() { } + } + extension(Splat.IBitmap value) + { + public SkiaSharp.SKBitmap ToNative() { } + } + } + public static class MutableDependencyResolverExtensions + { + extension(Splat.IMutableDependencyResolver instance) + { + public void UseSkiaSharpBitmapLoader() { } + public void UseSkiaSharpBitmapLoader(SkiaSharp.SKSamplingOptions sampling) { } + } + } + public sealed class SkiaBitmap : Splat.IBitmap + { + public SkiaBitmap(SkiaSharp.SKBitmap bitmap) { } + public SkiaBitmap(SkiaSharp.SKBitmap bitmap, SkiaSharp.SKEncodedOrigin encodedOrigin) { } + public SkiaSharp.SKEncodedOrigin EncodedOrigin { get; } + public float Height { get; } + public SkiaSharp.SKBitmap? Inner { get; } + public float Width { get; } + public void Dispose() { } + public System.Threading.Tasks.Task Save(SkiaSharp.SKEncodedImageFormat format, float quality, System.IO.Stream target) { } + public System.Threading.Tasks.Task Save(Splat.CompressedBitmapFormat format, float quality, System.IO.Stream target) { } + } + public sealed class SkiaBitmapLoader : Splat.IBitmapLoader + { + public SkiaBitmapLoader() { } + public SkiaBitmapLoader(SkiaSharp.SKSamplingOptions sampling) { } + public SkiaSharp.SKSamplingOptions Sampling { get; } + public Splat.IBitmap Create(float width, float height) { } + public System.Threading.Tasks.Task Load(System.IO.Stream sourceStream, float? desiredWidth, float? desiredHeight) { } + public System.Threading.Tasks.Task LoadFromResource(string source, float? desiredWidth, float? desiredHeight) { } + } +} diff --git a/src/Splat.SkiaSharp/PublicAPI/net8.0/PublicAPI.txt b/src/Splat.SkiaSharp/PublicAPI/net8.0/PublicAPI.txt new file mode 100644 index 000000000..ff54a450c --- /dev/null +++ b/src/Splat.SkiaSharp/PublicAPI/net8.0/PublicAPI.txt @@ -0,0 +1,53 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Splat.SkiaSharp.Tests")] +namespace Splat.Builder +{ + public sealed class SkiaSharpSplatModule : Splat.Builder.IModule + { + public SkiaSharpSplatModule() { } + public SkiaSharpSplatModule(SkiaSharp.SKSamplingOptions? sampling) { } + public void Configure(Splat.IMutableDependencyResolver resolver) { } + } +} +namespace Splat.SkiaSharp +{ + public static class BitmapMixins + { + extension(SkiaSharp.SKBitmap value) + { + public Splat.IBitmap FromNative() { } + } + extension(Splat.IBitmap value) + { + public SkiaSharp.SKBitmap ToNative() { } + } + } + public static class MutableDependencyResolverExtensions + { + extension(Splat.IMutableDependencyResolver instance) + { + public void UseSkiaSharpBitmapLoader() { } + public void UseSkiaSharpBitmapLoader(SkiaSharp.SKSamplingOptions sampling) { } + } + } + public sealed class SkiaBitmap : Splat.IBitmap + { + public SkiaBitmap(SkiaSharp.SKBitmap bitmap) { } + public SkiaBitmap(SkiaSharp.SKBitmap bitmap, SkiaSharp.SKEncodedOrigin encodedOrigin) { } + public SkiaSharp.SKEncodedOrigin EncodedOrigin { get; } + public float Height { get; } + public SkiaSharp.SKBitmap? Inner { get; } + public float Width { get; } + public void Dispose() { } + public System.Threading.Tasks.Task Save(SkiaSharp.SKEncodedImageFormat format, float quality, System.IO.Stream target) { } + public System.Threading.Tasks.Task Save(Splat.CompressedBitmapFormat format, float quality, System.IO.Stream target) { } + } + public sealed class SkiaBitmapLoader : Splat.IBitmapLoader + { + public SkiaBitmapLoader() { } + public SkiaBitmapLoader(SkiaSharp.SKSamplingOptions sampling) { } + public SkiaSharp.SKSamplingOptions Sampling { get; } + public Splat.IBitmap Create(float width, float height) { } + public System.Threading.Tasks.Task Load(System.IO.Stream sourceStream, float? desiredWidth, float? desiredHeight) { } + public System.Threading.Tasks.Task LoadFromResource(string source, float? desiredWidth, float? desiredHeight) { } + } +} diff --git a/src/Splat.SkiaSharp/PublicAPI/net9.0/PublicAPI.txt b/src/Splat.SkiaSharp/PublicAPI/net9.0/PublicAPI.txt new file mode 100644 index 000000000..ff54a450c --- /dev/null +++ b/src/Splat.SkiaSharp/PublicAPI/net9.0/PublicAPI.txt @@ -0,0 +1,53 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Splat.SkiaSharp.Tests")] +namespace Splat.Builder +{ + public sealed class SkiaSharpSplatModule : Splat.Builder.IModule + { + public SkiaSharpSplatModule() { } + public SkiaSharpSplatModule(SkiaSharp.SKSamplingOptions? sampling) { } + public void Configure(Splat.IMutableDependencyResolver resolver) { } + } +} +namespace Splat.SkiaSharp +{ + public static class BitmapMixins + { + extension(SkiaSharp.SKBitmap value) + { + public Splat.IBitmap FromNative() { } + } + extension(Splat.IBitmap value) + { + public SkiaSharp.SKBitmap ToNative() { } + } + } + public static class MutableDependencyResolverExtensions + { + extension(Splat.IMutableDependencyResolver instance) + { + public void UseSkiaSharpBitmapLoader() { } + public void UseSkiaSharpBitmapLoader(SkiaSharp.SKSamplingOptions sampling) { } + } + } + public sealed class SkiaBitmap : Splat.IBitmap + { + public SkiaBitmap(SkiaSharp.SKBitmap bitmap) { } + public SkiaBitmap(SkiaSharp.SKBitmap bitmap, SkiaSharp.SKEncodedOrigin encodedOrigin) { } + public SkiaSharp.SKEncodedOrigin EncodedOrigin { get; } + public float Height { get; } + public SkiaSharp.SKBitmap? Inner { get; } + public float Width { get; } + public void Dispose() { } + public System.Threading.Tasks.Task Save(SkiaSharp.SKEncodedImageFormat format, float quality, System.IO.Stream target) { } + public System.Threading.Tasks.Task Save(Splat.CompressedBitmapFormat format, float quality, System.IO.Stream target) { } + } + public sealed class SkiaBitmapLoader : Splat.IBitmapLoader + { + public SkiaBitmapLoader() { } + public SkiaBitmapLoader(SkiaSharp.SKSamplingOptions sampling) { } + public SkiaSharp.SKSamplingOptions Sampling { get; } + public Splat.IBitmap Create(float width, float height) { } + public System.Threading.Tasks.Task Load(System.IO.Stream sourceStream, float? desiredWidth, float? desiredHeight) { } + public System.Threading.Tasks.Task LoadFromResource(string source, float? desiredWidth, float? desiredHeight) { } + } +} diff --git a/src/Splat.SkiaSharp/SkiaBitmap.cs b/src/Splat.SkiaSharp/SkiaBitmap.cs new file mode 100644 index 000000000..f5167c00d --- /dev/null +++ b/src/Splat.SkiaSharp/SkiaBitmap.cs @@ -0,0 +1,104 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.IO; + +using SkiaSharp; + +namespace Splat.SkiaSharp; + +/// An whose pixels are held by Skia. +/// +/// The instance owns the bitmap it is given and releases it on , so a caller +/// that wants to keep drawing with the Skia bitmap afterwards has to hand over a copy. +/// +public sealed class SkiaBitmap : IBitmap +{ + /// The percentage Skia's encoders express quality in. + private const float EncoderQualityScale = 100F; + + /// The bitmap being wrapped, cleared once it has been disposed. + private SKBitmap? _inner; + + /// Initializes a new instance of the class. + /// The bitmap to take ownership of. + public SkiaBitmap(SKBitmap bitmap) + : this(bitmap, SKEncodedOrigin.TopLeft) + { + } + + /// Initializes a new instance of the class. + /// The bitmap to take ownership of, already in its upright orientation. + /// The orientation the source image recorded. + public SkiaBitmap(SKBitmap bitmap, SKEncodedOrigin encodedOrigin) + { + ArgumentExceptionHelper.ThrowIfNull(bitmap); + + _inner = bitmap; + EncodedOrigin = encodedOrigin; + } + + /// + public float Width => _inner?.Width ?? 0; + + /// + public float Height => _inner?.Height ?? 0; + + /// Gets the orientation the source image recorded, which the loader has already applied to the pixels. + /// + /// Anything other than means the encoded pixels were stored rotated or + /// mirrored; the value is kept so a caller re-encoding the image can decide what orientation to record. + /// + public SKEncodedOrigin EncodedOrigin { get; } + + /// Gets the Skia bitmap backing this instance, or once it has been disposed. + public SKBitmap? Inner => _inner; + + /// + public Task Save(CompressedBitmapFormat format, float quality, Stream target) => + Save(format == CompressedBitmapFormat.Jpeg ? SKEncodedImageFormat.Jpeg : SKEncodedImageFormat.Png, quality, target); + + /// Saves the image in any format Skia can encode, rather than only the two names. + /// The encoder to use. Which ones are built into the native library varies by platform. + /// A factor between 0 and 1, where 1 is the best quality. Lossless encoders ignore it. + /// The stream to write the encoded image to. + /// A task that completes once the encoded image has been written. + /// The native library has no encoder for the requested format. + public Task Save(SKEncodedImageFormat format, float quality, Stream target) + { + ArgumentExceptionHelper.ThrowIfNull(target); + + var bitmap = _inner; + ObjectDisposedExceptionHelper.ThrowIf(bitmap is null, this); + + return SaveCore(bitmap, format, quality, target); + } + + /// + public void Dispose() + { + _inner?.Dispose(); + _inner = null; + } + + /// Encodes the bitmap and writes it to the target stream. + /// The bitmap to encode. + /// The encoder to use. + /// A factor between 0 and 1, where 1 is the best quality. + /// The stream to write the encoded image to. + /// A task that completes once the encoded image has been written. + private static async Task SaveCore(SKBitmap bitmap, SKEncodedImageFormat format, float quality, Stream target) + { + using var data = bitmap.Encode(format, ToEncoderQuality(quality)) + ?? throw new BitmapLoaderException($"The native library has no encoder for {format}."); + await using var source = data.AsStream(); + + await source.CopyToAsync(target).ConfigureAwait(false); + } + + /// Converts the interface's 0-to-1 quality factor to the percentage Skia's encoders take. + /// The quality factor to convert. + /// The quality as a percentage. + private static int ToEncoderQuality(float quality) => (int)MathF.Round(Math.Clamp(quality, 0F, 1F) * EncoderQualityScale); +} diff --git a/src/Splat.SkiaSharp/SkiaBitmapLoader.cs b/src/Splat.SkiaSharp/SkiaBitmapLoader.cs new file mode 100644 index 000000000..678805b02 --- /dev/null +++ b/src/Splat.SkiaSharp/SkiaBitmapLoader.cs @@ -0,0 +1,264 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.IO; + +using SkiaSharp; + +namespace Splat.SkiaSharp; + +/// An that decodes and encodes through Skia. +/// +/// The plain .NET target frameworks have no imaging stack of their own, so this is the loader for +/// server, console, container and Linux hosts. It reads whatever the native library was built with +/// - PNG, JPEG, WebP and GIF everywhere, and more besides on some platforms. +/// +public sealed class SkiaBitmapLoader : IBitmapLoader +{ + /// The buffer size used when opening a file, matching the framework's own default. + private const int FileBufferSize = 4096; + + /// The sampling used for resizing when the caller does not choose one. + private static readonly SKSamplingOptions _defaultSampling = new(SKFilterMode.Linear, SKMipmapMode.Linear); + + /// The sampling this loader resizes with. + private readonly SKSamplingOptions _sampling; + + /// Initializes a new instance of the class. + public SkiaBitmapLoader() + : this(_defaultSampling) + { + } + + /// Initializes a new instance of the class. + /// The sampling to resize with. Skia offers nearest, linear and cubic resamplers. + public SkiaBitmapLoader(SKSamplingOptions sampling) => _sampling = sampling; + + /// Gets the sampling this loader resizes with. + public SKSamplingOptions Sampling => _sampling; + + /// + /// + /// Supplying both dimensions fits the image inside that box without distorting it; supplying one + /// derives the other from the image's proportions. Only some codecs - JPEG in practice - can decode + /// straight to a smaller size, so the rest are decoded whole and then resized. + /// + public async Task Load(Stream sourceStream, float? desiredWidth, float? desiredHeight) + { + ArgumentExceptionHelper.ThrowIfNull(sourceStream); + + using var data = await ReadAsync(sourceStream).ConfigureAwait(false); + + return Decode(data, desiredWidth, desiredHeight, _sampling); + } + + /// + /// + /// There is no bundle or resource URI scheme on these target frameworks, so the source names a file: + /// either an absolute path, or one relative to the directory the application was loaded from. + /// + public async Task LoadFromResource(string source, float? desiredWidth, float? desiredHeight) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + + var path = Path.IsPathRooted(source) ? source : Path.Combine(AppContext.BaseDirectory, source); + + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, FileBufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + + return await Load(stream, desiredWidth, desiredHeight).ConfigureAwait(false); + } + + /// + public IBitmap Create(float width, float height) + { + ArgumentOutOfRangeExceptionHelper.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeExceptionHelper.ThrowIfNegativeOrZero(height); + + return new SkiaBitmap(new SKBitmap(Math.Max(1, (int)width), Math.Max(1, (int)height))); + } + + /// Works out the size to decode to, honouring the image's proportions. + /// + /// Supplying both dimensions asks for a fit rather than a stretch, so the constraint that produces the + /// smaller scale factor binds and the other is derived from it. + /// + /// The size of the image as it will be displayed. + /// The requested width, or when the caller did not constrain it. + /// The requested height, or when the caller did not constrain it. + /// The size to produce, never smaller than one pixel in either direction. + internal static SKSizeI ChooseTargetSize(SKSizeI source, float? desiredWidth, float? desiredHeight) + { + var width = desiredWidth is null ? 0 : Math.Max(1, (int)desiredWidth.Value); + var height = desiredHeight is null ? 0 : Math.Max(1, (int)desiredHeight.Value); + + if (width == 0 && height == 0) + { + return source; + } + + if (width == 0) + { + return new(ScaleDimension(source.Width, height, source.Height), height); + } + + if (height == 0) + { + return new(width, ScaleDimension(source.Height, width, source.Width)); + } + + return (double)width / source.Width <= (double)height / source.Height + ? new SKSizeI(width, ScaleDimension(source.Height, width, source.Width)) + : new SKSizeI(ScaleDimension(source.Width, height, source.Height), height); + } + + /// Reports whether an orientation exchanges the image's width and height. + /// The orientation to test. + /// when the upright image is the encoded one turned on its side. + internal static bool SwapsDimensions(SKEncodedOrigin origin) => + origin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom; + + /// Turns the decoded pixels the right way up. + /// + /// Skia hands back the pixels exactly as they were stored and reports the orientation separately, so a + /// photograph taken side-on decodes side-on unless this is applied. Ownership of + /// passes to this method: it is either handed straight back or released once its pixels have been copied. + /// + /// The decoded bitmap, in the orientation it was encoded in. + /// The orientation the source image recorded. + /// The sampling to draw with. + /// The upright bitmap. + internal static SKBitmap ApplyEncodedOrigin(SKBitmap source, SKEncodedOrigin origin, SKSamplingOptions sampling) + { + if (origin == SKEncodedOrigin.TopLeft) + { + return source; + } + + var swaps = SwapsDimensions(origin); + var upright = new SKBitmap(source.Info.WithSize(swaps ? source.Height : source.Width, swaps ? source.Width : source.Height)); + var matrix = CreateOriginMatrix(origin, source.Width, source.Height); + + using (source) + { + using var canvas = new SKCanvas(upright); + canvas.SetMatrix(in matrix); + canvas.DrawBitmap(source, 0, 0, sampling); + } + + return upright; + } + + /// Builds the transform that maps encoded pixel positions to upright ones. + /// + /// The eight orientations are the four rotations and their mirror images, so each is a signed + /// permutation of the two axes with a translation that brings the result back into the first quadrant. + /// + /// The orientation the source image recorded. + /// The encoded width. + /// The encoded height. + /// The transform to draw the encoded pixels through. + internal static SKMatrix CreateOriginMatrix(SKEncodedOrigin origin, int width, int height) => origin switch + { + SKEncodedOrigin.TopRight => new(-1, 0, width, 0, 1, 0, 0, 0, 1), + SKEncodedOrigin.BottomRight => new(-1, 0, width, 0, -1, height, 0, 0, 1), + SKEncodedOrigin.BottomLeft => new(1, 0, 0, 0, -1, height, 0, 0, 1), + SKEncodedOrigin.LeftTop => new(0, 1, 0, 1, 0, 0, 0, 0, 1), + SKEncodedOrigin.RightTop => new(0, -1, height, 1, 0, 0, 0, 0, 1), + SKEncodedOrigin.RightBottom => new(0, -1, height, -1, 0, width, 0, 0, 1), + SKEncodedOrigin.LeftBottom => new(0, 1, 0, -1, 0, width, 0, 0, 1), + _ => SKMatrix.CreateIdentity(), + }; + + /// Decodes encoded image data to the requested size. + /// The encoded image. + /// The requested width, or when the caller did not constrain it. + /// The requested height, or when the caller did not constrain it. + /// The sampling to resize with. + /// The decoded image, or when no codec recognised the data. + internal static SkiaBitmap? Decode(SKData data, float? desiredWidth, float? desiredHeight, SKSamplingOptions sampling) + { + using var codec = CreateCodec(data); + if (codec is null) + { + return null; + } + + var origin = codec.EncodedOrigin; + var encoded = codec.Info.Size; + var oriented = SwapsDimensions(origin) ? new SKSizeI(encoded.Height, encoded.Width) : encoded; + var target = ChooseTargetSize(oriented, desiredWidth, desiredHeight); + + var decoded = DecodeAtLeast(codec, encoded, oriented, target); + + return new(ResizeTo(ApplyEncodedOrigin(decoded, origin, sampling), target, sampling), origin); + } + + /// Creates a codec for encoded image data. + /// + /// Skia annotates this as always succeeding, but it hands back nothing at all when no codec recognises + /// the data, which is the ordinary answer for a stream that does not hold an image. + /// + /// The encoded image. + /// The codec, or when no codec recognised the data. + private static SKCodec? CreateCodec(SKData data) => SKCodec.Create(data); + + /// Decodes at the closest size at or above the target that the codec can produce directly. + /// + /// Only some codecs - JPEG in practice - support a scaled decode; the rest report their full size and + /// are resized afterwards. Asking for a size the codec did not offer makes it refuse to decode at all, + /// so the size has to come from the codec rather than from the caller. + /// + /// The codec to decode with. + /// The encoded pixel dimensions. + /// The encoded dimensions as they will be displayed. + /// The size wanted, in display orientation. + /// The decoded bitmap. + private static SKBitmap DecodeAtLeast(SKCodec codec, SKSizeI encoded, SKSizeI oriented, SKSizeI target) + { + // Orientation only exchanges the two axes, so the linear scale factor is the same either way round. + var scale = (float)target.Width / oriented.Width; + var scaled = scale >= 1F ? encoded : codec.GetScaledDimensions(scale); + + return SKBitmap.Decode(codec, codec.Info.WithSize(scaled)); + } + + /// Resizes to the target size, when the bitmap is not already that size. + /// Ownership of passes to this method. + /// The bitmap to resize. + /// The size wanted. + /// The sampling to resize with. + /// The bitmap at the target size. + private static SKBitmap ResizeTo(SKBitmap source, SKSizeI target, SKSamplingOptions sampling) + { + if (source.Width == target.Width && source.Height == target.Height) + { + return source; + } + + using (source) + { + return source.Resize(target, sampling); + } + } + + /// Reads a stream into memory Skia can decode from. + /// The stream to read. + /// The stream's remaining content. + private static async Task ReadAsync(Stream source) + { + await using var buffer = new MemoryStream(); + + await source.CopyToAsync(buffer).ConfigureAwait(false); + + return SKData.CreateCopy(buffer.GetBuffer().AsSpan(0, (int)buffer.Length)); + } + + /// Scales one dimension by the ratio another was scaled by. + /// The dimension to scale. + /// The scaled size of the other dimension. + /// The original size of the other dimension. + /// The scaled dimension, never below one pixel. + private static int ScaleDimension(int value, int numerator, int denominator) => + Math.Max(1, (int)Math.Round((double)value * numerator / denominator)); +} diff --git a/src/Splat.SkiaSharp/Splat.SkiaSharp.csproj b/src/Splat.SkiaSharp/Splat.SkiaSharp.csproj new file mode 100644 index 000000000..26bab71de --- /dev/null +++ b/src/Splat.SkiaSharp/Splat.SkiaSharp.csproj @@ -0,0 +1,25 @@ + + + $(SplatModernTargets) + Splat.SkiaSharp + Splat + .NET Foundation and Contributors + SkiaSharp bitmap loading and saving for Splat on the plain .NET target frameworks, which have no platform imaging stack of their own + Splat.SkiaSharp + true + + + + + + + + + + + + + + + + diff --git a/src/Splat.SkiaSharp/build/Splat.SkiaSharp.targets b/src/Splat.SkiaSharp/build/Splat.SkiaSharp.targets new file mode 100644 index 000000000..3ad9ec370 --- /dev/null +++ b/src/Splat.SkiaSharp/build/Splat.SkiaSharp.targets @@ -0,0 +1,34 @@ + + + + + false + + + + + + + + + + + + + + + + diff --git a/src/Splat.slnx b/src/Splat.slnx index 3c3d70555..dbad1fd9c 100644 --- a/src/Splat.slnx +++ b/src/Splat.slnx @@ -11,6 +11,7 @@ + @@ -38,6 +39,7 @@ + diff --git a/src/tests/Splat.SkiaSharp.Tests/BitmapMixinsTests.cs b/src/tests/Splat.SkiaSharp.Tests/BitmapMixinsTests.cs new file mode 100644 index 000000000..f8c72dc5e --- /dev/null +++ b/src/tests/Splat.SkiaSharp.Tests/BitmapMixinsTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.IO; + +using SkiaSharp; + +namespace Splat.SkiaSharp.Tests; + +/// Unit tests for converting between and the Skia bitmap behind it. +public sealed class BitmapMixinsTests +{ + /// The width of the bitmaps the tests convert. + private const int BitmapWidth = 20; + + /// The height of the bitmaps the tests convert. + private const int BitmapHeight = 10; + + /// Verifies that a Skia bitmap becomes an of the same size. + /// A representing the asynchronous operation. + [Test] + public async Task FromNative_WrapsTheSkiaBitmap() + { + using var bitmap = TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight).FromNative(); + + using (Assert.Multiple()) + { + await Assert.That(bitmap.Width).IsEqualTo((float)BitmapWidth); + await Assert.That(bitmap.Height).IsEqualTo((float)BitmapHeight); + } + } + + /// Verifies that the conversion hands back the very bitmap that was wrapped. + /// A representing the asynchronous operation. + [Test] + public async Task ToNative_ReturnsTheWrappedSkiaBitmap() + { + var native = TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight); + using var bitmap = native.FromNative(); + + await Assert.That(bitmap.ToNative()).IsSameReferenceAs(native); + } + + /// Verifies that a released bitmap says so rather than handing out a dangling reference. + /// A representing the asynchronous operation. + [Test] + public async Task ToNative_AfterDispose_Throws() + { + var bitmap = TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight).FromNative(); + bitmap.Dispose(); + + await Assert.That(bitmap.ToNative).Throws(); + } + + /// Verifies that a bitmap has to be supplied. + /// A representing the asynchronous operation. + [Test] + public async Task ToNative_WithoutABitmap_Throws() + { + const IBitmap bitmap = null!; + + await Assert.That(static () => bitmap.ToNative()).Throws(); + } + + /// Verifies that a bitmap from a different loader is rejected rather than reinterpreted. + /// A representing the asynchronous operation. + [Test] + public async Task ToNative_WithAForeignBitmap_Throws() + { + using var bitmap = new ForeignBitmap(); + + await Assert.That(() => bitmap.ToNative()).Throws(); + } + + /// An from somewhere other than this package. + private sealed class ForeignBitmap : IBitmap + { + /// + public float Width => 0; + + /// + public float Height => 0; + + /// + public Task Save(CompressedBitmapFormat format, float quality, Stream target) => Task.CompletedTask; + + /// + public void Dispose() + { + } + } +} diff --git a/src/tests/Splat.SkiaSharp.Tests/DecodeSizeTests.cs b/src/tests/Splat.SkiaSharp.Tests/DecodeSizeTests.cs new file mode 100644 index 000000000..a3ed9644d --- /dev/null +++ b/src/tests/Splat.SkiaSharp.Tests/DecodeSizeTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using SkiaSharp; + +namespace Splat.SkiaSharp.Tests; + +/// Unit tests for the size a requested width and height decode to. +public sealed class DecodeSizeTests +{ + /// The width of the image the requests are made against. + private const int SourceWidth = 400; + + /// The height of the image the requests are made against. + private const int SourceHeight = 200; + + /// The size of the image the requests are made against. + private static readonly SKSizeI _source = new(SourceWidth, SourceHeight); + + /// Verifies that an unconstrained request keeps the image's own size. + /// A representing the asynchronous operation. + [Test] + public async Task ChooseTargetSize_Unconstrained_KeepsTheSourceSize() => + await Assert.That(SkiaBitmapLoader.ChooseTargetSize(_source, null, null)).IsEqualTo(_source); + + /// Verifies that the dimension the caller left out is derived from the image's proportions. + /// The requested width, or a negative number for no constraint. + /// The requested height, or a negative number for no constraint. + /// The width the request should produce. + /// The height the request should produce. + /// A representing the asynchronous operation. + [Test] + [Arguments(100F, -1F, 100, 50)] + [Arguments(-1F, 50F, 100, 50)] + [Arguments(300F, -1F, 300, 150)] + [Arguments(-1F, 150F, 300, 150)] + public async Task ChooseTargetSize_OneDimension_DerivesTheOther(float desiredWidth, float desiredHeight, int expectedWidth, int expectedHeight) + { + var target = SkiaBitmapLoader.ChooseTargetSize(_source, Requested(desiredWidth), Requested(desiredHeight)); + + using (Assert.Multiple()) + { + await Assert.That(target.Width).IsEqualTo(expectedWidth); + await Assert.That(target.Height).IsEqualTo(expectedHeight); + } + } + + /// Verifies that asking for both dimensions fits the image inside that box rather than stretching it. + /// The requested width. + /// The requested height. + /// The width the request should produce. + /// The height the request should produce. + /// A representing the asynchronous operation. + [Test] + [Arguments(100F, 100F, 100, 50)] + [Arguments(1000F, 50F, 100, 50)] + [Arguments(100F, 50F, 100, 50)] + [Arguments(800F, 800F, 800, 400)] + public async Task ChooseTargetSize_BothDimensions_FitsInsideTheBox(float desiredWidth, float desiredHeight, int expectedWidth, int expectedHeight) + { + var target = SkiaBitmapLoader.ChooseTargetSize(_source, desiredWidth, desiredHeight); + + using (Assert.Multiple()) + { + await Assert.That(target.Width).IsEqualTo(expectedWidth); + await Assert.That(target.Height).IsEqualTo(expectedHeight); + } + } + + /// Verifies that a request that would round away to nothing still produces a pixel. + /// The requested width. + /// A representing the asynchronous operation. + [Test] + [Arguments(0.4F)] + [Arguments(0F)] + [Arguments(-5F)] + [Arguments(1F)] + public async Task ChooseTargetSize_BelowOnePixel_ClampsToOnePixel(float desiredWidth) + { + var target = SkiaBitmapLoader.ChooseTargetSize(_source, desiredWidth, null); + + using (Assert.Multiple()) + { + await Assert.That(target.Width).IsEqualTo(1); + await Assert.That(target.Height).IsEqualTo(1); + } + } + + /// Verifies that a proportion that does not divide evenly rounds to the nearest pixel. + /// The width of the image the request is made against. + /// The height of the image the request is made against. + /// The requested width. + /// The height the request should produce. + /// A representing the asynchronous operation. + [Test] + [Arguments(3, 7, 2F, 5)] + [Arguments(7, 3, 2F, 1)] + public async Task ChooseTargetSize_UnevenProportions_RoundsToTheNearestPixel(int sourceWidth, int sourceHeight, float desiredWidth, int expectedHeight) + { + var target = SkiaBitmapLoader.ChooseTargetSize(new(sourceWidth, sourceHeight), desiredWidth, null); + + await Assert.That(target.Height).IsEqualTo(expectedHeight); + } + + /// Turns a negative test argument into the absence of a constraint. + /// The value from the test case. + /// The requested dimension, or when the case asked for none. + private static float? Requested(float value) => value < 0 ? null : value; +} diff --git a/src/tests/Splat.SkiaSharp.Tests/EncodedOrientationTests.cs b/src/tests/Splat.SkiaSharp.Tests/EncodedOrientationTests.cs new file mode 100644 index 000000000..10f9c2c27 --- /dev/null +++ b/src/tests/Splat.SkiaSharp.Tests/EncodedOrientationTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using SkiaSharp; + +namespace Splat.SkiaSharp.Tests; + +/// Unit tests for turning encoded pixels the right way up. +public sealed class EncodedOrientationTests +{ + /// The width of the bitmap the orientations are applied to. + private const int SourceWidth = 4; + + /// The height of the bitmap the orientations are applied to. + private const int SourceHeight = 2; + + /// The sampling used when redrawing, chosen so a pixel keeps its exact colour. + private static readonly SKSamplingOptions _nearest = new(SKFilterMode.Nearest); + + /// Verifies which orientations exchange the width and the height. + /// The orientation to test. + /// Whether the orientation turns the image on its side. + /// A representing the asynchronous operation. + [Test] + [Arguments(SKEncodedOrigin.TopLeft, false)] + [Arguments(SKEncodedOrigin.TopRight, false)] + [Arguments(SKEncodedOrigin.BottomRight, false)] + [Arguments(SKEncodedOrigin.BottomLeft, false)] + [Arguments(SKEncodedOrigin.LeftTop, true)] + [Arguments(SKEncodedOrigin.RightTop, true)] + [Arguments(SKEncodedOrigin.RightBottom, true)] + [Arguments(SKEncodedOrigin.LeftBottom, true)] + public async Task SwapsDimensions_ReportsTheSidewaysOrientations(SKEncodedOrigin origin, bool expected) => + await Assert.That(SkiaBitmapLoader.SwapsDimensions(origin)).IsEqualTo(expected); + + /// Verifies that an image already the right way up is left untouched. + /// A representing the asynchronous operation. + [Test] + public async Task ApplyEncodedOrigin_TopLeft_ReturnsTheSameBitmap() + { + using var source = TestImages.CreateCornerMarked(SourceWidth, SourceHeight); + + var upright = SkiaBitmapLoader.ApplyEncodedOrigin(source, SKEncodedOrigin.TopLeft, _nearest); + + await Assert.That(upright).IsSameReferenceAs(source); + } + + /// Verifies that an upright orientation asks for no transform at all. + /// A representing the asynchronous operation. + [Test] + public async Task CreateOriginMatrix_TopLeft_IsTheIdentity() + { + var matrix = SkiaBitmapLoader.CreateOriginMatrix(SKEncodedOrigin.TopLeft, SourceWidth, SourceHeight); + + await Assert.That(matrix).IsEqualTo(SKMatrix.CreateIdentity()); + } + + /// Verifies that each orientation moves the marked corner where the standard says it belongs. + /// + /// The four rotations and their mirror images each send the encoded top left corner to a different + /// corner of the upright image, so the marker's position identifies the transform on its own. + /// + /// The orientation to apply. + /// The width the upright image should have. + /// The height the upright image should have. + /// The column the marked corner should end up in. + /// The row the marked corner should end up in. + /// A representing the asynchronous operation. + [Test] + [Arguments(SKEncodedOrigin.TopLeft, SourceWidth, SourceHeight, 0, 0)] + [Arguments(SKEncodedOrigin.TopRight, SourceWidth, SourceHeight, 3, 0)] + [Arguments(SKEncodedOrigin.BottomRight, SourceWidth, SourceHeight, 3, 1)] + [Arguments(SKEncodedOrigin.BottomLeft, SourceWidth, SourceHeight, 0, 1)] + [Arguments(SKEncodedOrigin.LeftTop, SourceHeight, SourceWidth, 0, 0)] + [Arguments(SKEncodedOrigin.RightTop, SourceHeight, SourceWidth, 1, 0)] + [Arguments(SKEncodedOrigin.RightBottom, SourceHeight, SourceWidth, 1, 3)] + [Arguments(SKEncodedOrigin.LeftBottom, SourceHeight, SourceWidth, 0, 3)] + public async Task ApplyEncodedOrigin_MovesTheMarkedCorner(SKEncodedOrigin origin, int expectedWidth, int expectedHeight, int expectedX, int expectedY) + { + using var upright = SkiaBitmapLoader.ApplyEncodedOrigin(TestImages.CreateCornerMarked(SourceWidth, SourceHeight), origin, _nearest); + + using (Assert.Multiple()) + { + await Assert.That(upright.Width).IsEqualTo(expectedWidth); + await Assert.That(upright.Height).IsEqualTo(expectedHeight); + await Assert.That(TestImages.FindMarker(upright)).IsEqualTo((expectedX, expectedY)); + } + } +} diff --git a/src/tests/Splat.SkiaSharp.Tests/SkiaBitmapLoaderTests.cs b/src/tests/Splat.SkiaSharp.Tests/SkiaBitmapLoaderTests.cs new file mode 100644 index 000000000..8e80bc17e --- /dev/null +++ b/src/tests/Splat.SkiaSharp.Tests/SkiaBitmapLoaderTests.cs @@ -0,0 +1,294 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.IO; + +using SkiaSharp; + +namespace Splat.SkiaSharp.Tests; + +/// Unit tests for . +public sealed class SkiaBitmapLoaderTests +{ + /// The width of the images the tests decode. + private const int SourceWidth = 400; + + /// The height of the images the tests decode. + private const int SourceHeight = 200; + + /// How far a decoded image's proportions may drift from the source's. + private const float AspectTolerance = 0.01F; + + /// Verifies that a decode with no requested size keeps the image's own dimensions. + /// The encoder the fixture is written with. + /// A representing the asynchronous operation. + [Test] + [Arguments(SKEncodedImageFormat.Png)] + [Arguments(SKEncodedImageFormat.Jpeg)] + [Arguments(SKEncodedImageFormat.Webp)] + public async Task Load_WithoutADesiredSize_KeepsTheImageSize(SKEncodedImageFormat format) + { + var loader = new SkiaBitmapLoader(); + await using var source = TestImages.OpenStream(SourceWidth, SourceHeight, format); + + using var bitmap = await loader.Load(source, null, null); + + using (Assert.Multiple()) + { + await Assert.That(bitmap!.Width).IsEqualTo((float)SourceWidth); + await Assert.That(bitmap.Height).IsEqualTo((float)SourceHeight); + } + } + + /// Verifies that a requested size is honoured without distorting the image. + /// + /// A PNG has to be decoded whole and resized, while a JPEG can be decoded straight to a smaller + /// size, so both encoders are exercised through the same expectations. + /// + /// The encoder the fixture is written with. + /// The requested width, or a negative number for no constraint. + /// The requested height, or a negative number for no constraint. + /// The width the decode should produce. + /// The height the decode should produce. + /// A representing the asynchronous operation. + [Test] + [Arguments(SKEncodedImageFormat.Png, 100F, 100F, 100, 50)] + [Arguments(SKEncodedImageFormat.Png, 100F, -1F, 100, 50)] + [Arguments(SKEncodedImageFormat.Png, -1F, 50F, 100, 50)] + [Arguments(SKEncodedImageFormat.Png, 1000F, 50F, 100, 50)] + [Arguments(SKEncodedImageFormat.Png, 800F, 800F, 800, 400)] + [Arguments(SKEncodedImageFormat.Jpeg, 100F, 100F, 100, 50)] + [Arguments(SKEncodedImageFormat.Jpeg, 200F, -1F, 200, 100)] + [Arguments(SKEncodedImageFormat.Jpeg, -1F, 25F, 50, 25)] + [Arguments(SKEncodedImageFormat.Webp, 60F, 60F, 60, 30)] + public async Task Load_WithADesiredSize_KeepsTheAspectRatio( + SKEncodedImageFormat format, + float desiredWidth, + float desiredHeight, + int expectedWidth, + int expectedHeight) + { + var loader = new SkiaBitmapLoader(); + await using var source = TestImages.OpenStream(SourceWidth, SourceHeight, format); + + using var bitmap = await loader.Load(source, Requested(desiredWidth), Requested(desiredHeight)); + + using (Assert.Multiple()) + { + await Assert.That(bitmap!.Width).IsEqualTo((float)expectedWidth); + await Assert.That(bitmap.Height).IsEqualTo((float)expectedHeight); + await Assert.That(bitmap.Width / bitmap.Height).IsEqualTo((float)SourceWidth / SourceHeight).Within(AspectTolerance); + } + } + + /// Verifies that an image stored on its side comes back the right way up. + /// + /// A camera records the orientation rather than rotating the pixels, so the decoded size is the + /// encoded one with its axes exchanged, and a requested size describes the upright image. + /// + /// The orientation to record, numbered as the metadata standard numbers them. + /// The orientation the decoded bitmap should report. + /// The requested width, or a negative number for no constraint. + /// The width the decode should produce. + /// The height the decode should produce. + /// A representing the asynchronous operation. + [Test] + [Arguments(1, SKEncodedOrigin.TopLeft, -1F, 400, 200)] + [Arguments(3, SKEncodedOrigin.BottomRight, -1F, 400, 200)] + [Arguments(6, SKEncodedOrigin.RightTop, -1F, 200, 400)] + [Arguments(6, SKEncodedOrigin.RightTop, 100F, 100, 200)] + [Arguments(8, SKEncodedOrigin.LeftBottom, 50F, 50, 100)] + public async Task Load_WithARecordedOrientation_TurnsTheImageUpright( + int orientation, + SKEncodedOrigin expectedOrigin, + float desiredWidth, + int expectedWidth, + int expectedHeight) + { + var loader = new SkiaBitmapLoader(); + await using var source = new MemoryStream(TestImages.EncodeWithOrientation(SourceWidth, SourceHeight, orientation)); + + using var bitmap = await loader.Load(source, Requested(desiredWidth), null); + + using (Assert.Multiple()) + { + await Assert.That(bitmap!.Width).IsEqualTo((float)expectedWidth); + await Assert.That(bitmap.Height).IsEqualTo((float)expectedHeight); + await Assert.That(((SkiaBitmap)bitmap).EncodedOrigin).IsEqualTo(expectedOrigin); + } + } + + /// Verifies that a stream holding no image reports nothing rather than failing. + /// A representing the asynchronous operation. + [Test] + public async Task Load_WithoutAnImage_ReturnsNothing() + { + var loader = new SkiaBitmapLoader(); + await using var source = new MemoryStream("this is not an image"u8.ToArray()); + + var bitmap = await loader.Load(source, null, null); + + await Assert.That(bitmap).IsNull(); + } + + /// Verifies that the loader rejects a missing stream. + /// A representing the asynchronous operation. + [Test] + public async Task Load_WithoutAStream_Throws() + { + var loader = new SkiaBitmapLoader(); + + await Assert.That(async () => await loader.Load(null!, null, null)).Throws(); + } + + /// Verifies that the sampling the loader was built with is the one it reports. + /// A representing the asynchronous operation. + [Test] + public async Task Sampling_ReportsTheChosenResampler() + { + var chosen = new SKSamplingOptions(SKFilterMode.Nearest); + var fallback = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear); + + using (Assert.Multiple()) + { + await Assert.That(new SkiaBitmapLoader(chosen).Sampling).IsEqualTo(chosen); + await Assert.That(new SkiaBitmapLoader().Sampling).IsEqualTo(fallback); + } + } + + /// Verifies that a resource path relative to the application directory is resolved. + /// The requested width. + /// The width the decode should produce. + /// A representing the asynchronous operation. + [Test] + [Arguments(100F, 100)] + public async Task LoadFromResource_WithARelativePath_ResolvesAgainstTheApplicationDirectory(float desiredWidth, int expectedWidth) + { + var name = $"{Guid.NewGuid():N}.png"; + var path = Path.Combine(AppContext.BaseDirectory, name); + await File.WriteAllBytesAsync(path, TestImages.Encode(SourceWidth, SourceHeight, SKEncodedImageFormat.Png)); + + try + { + var loader = new SkiaBitmapLoader(); + + using var bitmap = await loader.LoadFromResource(name, desiredWidth, null); + + await Assert.That(bitmap!.Width).IsEqualTo((float)expectedWidth); + } + finally + { + File.Delete(path); + } + } + + /// Verifies that a resource named by an absolute path is read from that path. + /// The requested height. + /// The width the decode should produce. + /// The height the decode should produce. + /// A representing the asynchronous operation. + [Test] + [Arguments(50F, 100, 50)] + public async Task LoadFromResource_WithAnAbsolutePath_ReadsThatFile(float desiredHeight, int expectedWidth, int expectedHeight) + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.png"); + await File.WriteAllBytesAsync(path, TestImages.Encode(SourceWidth, SourceHeight, SKEncodedImageFormat.Png)); + + try + { + var loader = new SkiaBitmapLoader(); + + using var bitmap = await loader.LoadFromResource(path, null, desiredHeight); + + using (Assert.Multiple()) + { + await Assert.That(bitmap!.Width).IsEqualTo((float)expectedWidth); + await Assert.That(bitmap.Height).IsEqualTo((float)expectedHeight); + } + } + finally + { + File.Delete(path); + } + } + + /// Verifies that the loader rejects a resource name that identifies nothing. + /// The resource name to reject. + /// A representing the asynchronous operation. + [Test] + [Arguments(null)] + [Arguments("")] + [Arguments(" ")] + public async Task LoadFromResource_WithoutAName_Throws(string? source) + { + var loader = new SkiaBitmapLoader(); + + await Assert.That(async () => await loader.LoadFromResource(source!, null, null)).Throws(); + } + + /// Verifies that an empty canvas is created at the requested size. + /// The width to ask for. + /// The height to ask for. + /// The width the canvas should have. + /// The height the canvas should have. + /// A representing the asynchronous operation. + [Test] + [Arguments(64F, 32F, 64, 32)] + [Arguments(0.5F, 0.5F, 1, 1)] + public async Task Create_ProducesACanvasOfTheRequestedSize(float width, float height, int expectedWidth, int expectedHeight) + { + var loader = new SkiaBitmapLoader(); + + using var bitmap = loader.Create(width, height); + + using (Assert.Multiple()) + { + await Assert.That(bitmap.Width).IsEqualTo((float)expectedWidth); + await Assert.That(bitmap.Height).IsEqualTo((float)expectedHeight); + } + } + + /// Verifies that a canvas with no area is rejected rather than silently produced. + /// The width to ask for. + /// The height to ask for. + /// A representing the asynchronous operation. + [Test] + [Arguments(0F, 10F)] + [Arguments(10F, 0F)] + [Arguments(-1F, 10F)] + [Arguments(10F, -1F)] + public async Task Create_WithoutAnArea_Throws(float width, float height) + { + var loader = new SkiaBitmapLoader(); + + await Assert.That(() => loader.Create(width, height)).Throws(); + } + + /// Verifies that a bitmap decoded from a stream reaches call sites through the static accessor. + /// The requested width. + /// The width the decode should produce. + /// The height the decode should produce. + /// A representing the asynchronous operation. + [Test] + [Arguments(40F, 40, 20)] + [NotInParallel] // Mutates the global BitmapLoader.Current static state. + public async Task BitmapLoaderCurrent_OnceRegistered_DecodesThroughThisLoader(float desiredWidth, int expectedWidth, int expectedHeight) + { + BitmapLoader.Current = new SkiaBitmapLoader(); + await using var source = TestImages.OpenStream(SourceWidth, SourceHeight, SKEncodedImageFormat.Png); + + using var bitmap = await BitmapLoader.Current.Load(source, desiredWidth, null); + + using (Assert.Multiple()) + { + await Assert.That(bitmap!.Width).IsEqualTo((float)expectedWidth); + await Assert.That(bitmap.Height).IsEqualTo((float)expectedHeight); + } + } + + /// Turns a negative test argument into the absence of a constraint. + /// The value from the test case. + /// The requested dimension, or when the case asked for none. + private static float? Requested(float value) => value < 0 ? null : value; +} diff --git a/src/tests/Splat.SkiaSharp.Tests/SkiaBitmapTests.cs b/src/tests/Splat.SkiaSharp.Tests/SkiaBitmapTests.cs new file mode 100644 index 000000000..64b4d19dc --- /dev/null +++ b/src/tests/Splat.SkiaSharp.Tests/SkiaBitmapTests.cs @@ -0,0 +1,182 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.IO; + +using SkiaSharp; + +namespace Splat.SkiaSharp.Tests; + +/// Unit tests for . +public sealed class SkiaBitmapTests +{ + /// The width of the bitmaps the tests encode. + private const int BitmapWidth = 20; + + /// The height of the bitmaps the tests encode. + private const int BitmapHeight = 10; + + /// Verifies that the wrapper reports the dimensions of the bitmap it holds. + /// A representing the asynchronous operation. + [Test] + public async Task Dimensions_ComeFromTheWrappedBitmap() + { + using var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight)); + + using (Assert.Multiple()) + { + await Assert.That(bitmap.Width).IsEqualTo((float)BitmapWidth); + await Assert.That(bitmap.Height).IsEqualTo((float)BitmapHeight); + } + } + + /// Verifies that a disposed bitmap reports no size and hands out nothing. + /// A representing the asynchronous operation. + [Test] + public async Task Dispose_ReleasesTheWrappedBitmap() + { + var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight)); + + bitmap.Dispose(); + bitmap.Dispose(); + + using (Assert.Multiple()) + { + await Assert.That(bitmap.Width).IsEqualTo(0F); + await Assert.That(bitmap.Height).IsEqualTo(0F); + await Assert.That(bitmap.Inner).IsNull(); + } + } + + /// Verifies that a bitmap has to be supplied. + /// A representing the asynchronous operation. + [Test] + public async Task Constructor_WithoutABitmap_Throws() => + await Assert.That(static () => new SkiaBitmap(null!)).Throws(); + + /// Verifies that a bitmap with no recorded orientation reports the upright one. + /// A representing the asynchronous operation. + [Test] + public async Task EncodedOrigin_DefaultsToUpright() + { + using var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight)); + + await Assert.That(bitmap.EncodedOrigin).IsEqualTo(SKEncodedOrigin.TopLeft); + } + + /// Verifies that the orientation the source recorded is kept. + /// A representing the asynchronous operation. + [Test] + public async Task EncodedOrigin_KeepsWhatTheSourceRecorded() + { + using var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight), SKEncodedOrigin.RightTop); + + await Assert.That(bitmap.EncodedOrigin).IsEqualTo(SKEncodedOrigin.RightTop); + } + + /// Verifies that the two names produce readable images. + /// The format to save in. + /// The quality factor to save with. + /// The encoder the saved bytes should identify as. + /// A representing the asynchronous operation. + [Test] + [Arguments(CompressedBitmapFormat.Png, 0.8F, SKEncodedImageFormat.Png)] + [Arguments(CompressedBitmapFormat.Jpeg, 0.8F, SKEncodedImageFormat.Jpeg)] + public async Task Save_WritesTheRequestedCompressedFormat(CompressedBitmapFormat format, float quality, SKEncodedImageFormat expected) + { + using var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight)); + await using var target = new MemoryStream(); + + await bitmap.Save(format, quality, target); + + await AssertRoundTrips(target, expected); + } + + /// Verifies that the additive overload reaches formats the shared enumeration has no name for. + /// The encoder to save with. + /// A representing the asynchronous operation. + [Test] + [Arguments(SKEncodedImageFormat.Webp)] + [Arguments(SKEncodedImageFormat.Png)] + [Arguments(SKEncodedImageFormat.Jpeg)] + public async Task Save_WritesAnyFormatTheNativeLibraryCanEncode(SKEncodedImageFormat format) + { + using var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight)); + await using var target = new MemoryStream(); + + await bitmap.Save(format, 1F, target); + + await AssertRoundTrips(target, format); + } + + /// Verifies that a quality outside the interface's range is brought back into it. + /// The quality factor to save with. + /// A representing the asynchronous operation. + [Test] + [Arguments(-1F)] + [Arguments(0F)] + [Arguments(0.5F)] + [Arguments(1F)] + [Arguments(5F)] + public async Task Save_WithAQualityOutsideTheRange_StillEncodes(float quality) + { + using var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight)); + await using var target = new MemoryStream(); + + await bitmap.Save(CompressedBitmapFormat.Jpeg, quality, target); + + await AssertRoundTrips(target, SKEncodedImageFormat.Jpeg); + } + + /// Verifies that a format the native library only reads is reported rather than written empty. + /// A representing the asynchronous operation. + [Test] + public async Task Save_WithoutAnEncoder_Throws() + { + using var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight)); + await using var target = new MemoryStream(); + + await Assert.That(async () => await bitmap.Save(SKEncodedImageFormat.Astc, 1F, target)).Throws(); + } + + /// Verifies that a target stream has to be supplied. + /// A representing the asynchronous operation. + [Test] + public async Task Save_WithoutATarget_Throws() + { + using var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight)); + + await Assert.That(async () => await bitmap.Save(CompressedBitmapFormat.Png, 1F, null!)).Throws(); + } + + /// Verifies that saving a released bitmap says so rather than writing nothing. + /// A representing the asynchronous operation. + [Test] + public async Task Save_AfterDispose_Throws() + { + var bitmap = new SkiaBitmap(TestImages.CreateCornerMarked(BitmapWidth, BitmapHeight)); + bitmap.Dispose(); + + await using var target = new MemoryStream(); + + await Assert.That(async () => await bitmap.Save(CompressedBitmapFormat.Png, 1F, target)).Throws(); + } + + /// Asserts that the written bytes decode back to the bitmap that was saved. + /// The stream the image was written to. + /// The encoder the bytes should identify as. + /// A representing the asynchronous operation. + private static async Task AssertRoundTrips(MemoryStream target, SKEncodedImageFormat expected) + { + using var data = SKData.CreateCopy(target.GetBuffer().AsSpan(0, (int)target.Length)); + using var codec = SKCodec.Create(data); + + using (Assert.Multiple()) + { + await Assert.That(codec.EncodedFormat).IsEqualTo(expected); + await Assert.That(codec.Info.Width).IsEqualTo(BitmapWidth); + await Assert.That(codec.Info.Height).IsEqualTo(BitmapHeight); + } + } +} diff --git a/src/tests/Splat.SkiaSharp.Tests/SkiaSharpSplatModuleTests.cs b/src/tests/Splat.SkiaSharp.Tests/SkiaSharpSplatModuleTests.cs new file mode 100644 index 000000000..a4d497caa --- /dev/null +++ b/src/tests/Splat.SkiaSharp.Tests/SkiaSharpSplatModuleTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using SkiaSharp; + +using Splat.Builder; + +namespace Splat.SkiaSharp.Tests; + +/// Unit tests for registering Skia as the bitmap loader. +public sealed class SkiaSharpSplatModuleTests +{ + /// Verifies that the module registers a loader that can be resolved. + /// A representing the asynchronous operation. + [Test] + public async Task Configure_RegistersTheBitmapLoader() + { + using var resolver = new ModernDependencyResolver(); + + new SkiaSharpSplatModule().Configure(resolver); + + using (Assert.Multiple()) + { + await Assert.That(resolver.HasRegistration(typeof(IBitmapLoader))).IsTrue(); + await Assert.That(resolver.GetService()).IsTypeOf(); + } + } + + /// Verifies that a module built with a sampling passes it to the loader. + /// A representing the asynchronous operation. + [Test] + public async Task Configure_WithASampling_PassesItToTheLoader() + { + using var resolver = new ModernDependencyResolver(); + var sampling = new SKSamplingOptions(SKFilterMode.Nearest); + + new SkiaSharpSplatModule(sampling).Configure(resolver); + + await Assert.That(((SkiaBitmapLoader)resolver.GetService()!).Sampling).IsEqualTo(sampling); + } + + /// Verifies that configuring twice leaves a usable registration. + /// A representing the asynchronous operation. + [Test] + public async Task Configure_Twice_LeavesAUsableRegistration() + { + using var resolver = new ModernDependencyResolver(); + var module = new SkiaSharpSplatModule(); + + module.Configure(resolver); + module.Configure(resolver); + + await Assert.That(resolver.GetService()).IsNotNull(); + } + + /// Verifies that the module rejects a missing resolver. + /// A representing the asynchronous operation. + [Test] + public async Task Configure_WithoutAResolver_Throws() => + await Assert.That(static () => new SkiaSharpSplatModule().Configure(null!)).Throws(); + + /// Verifies that the registration extension registers a loader that can be resolved. + /// A representing the asynchronous operation. + [Test] + public async Task UseSkiaSharpBitmapLoader_RegistersTheBitmapLoader() + { + using var resolver = new ModernDependencyResolver(); + + resolver.UseSkiaSharpBitmapLoader(); + + await Assert.That(resolver.GetService()).IsTypeOf(); + } + + /// Verifies that the registration extension passes the chosen sampling to the loader. + /// The cubic resampler coefficient to build the sampling from. + /// A representing the asynchronous operation. + [Test] + [Arguments(0.33F)] + public async Task UseSkiaSharpBitmapLoader_WithASampling_PassesItToTheLoader(float resampler) + { + using var resolver = new ModernDependencyResolver(); + var sampling = new SKSamplingOptions(new SKCubicResampler(resampler, resampler)); + + resolver.UseSkiaSharpBitmapLoader(sampling); + + await Assert.That(((SkiaBitmapLoader)resolver.GetService()!).Sampling).IsEqualTo(sampling); + } + + /// Verifies that the registration extensions reject a missing resolver. + /// A representing the asynchronous operation. + [Test] + public async Task UseSkiaSharpBitmapLoader_WithoutAResolver_Throws() + { + const IMutableDependencyResolver resolver = null!; + var sampling = new SKSamplingOptions(SKFilterMode.Nearest); + + using (Assert.Multiple()) + { + await Assert.That(static () => resolver.UseSkiaSharpBitmapLoader()).Throws(); + await Assert.That(() => resolver.UseSkiaSharpBitmapLoader(sampling)).Throws(); + } + } + + /// Verifies that the module can be composed through the application builder. + /// A representing the asynchronous operation. + [Test] + [NotInParallel] // Mutates the builder's global built state. + public async Task UsingModule_RegistersTheBitmapLoaderOnTheBuildersResolver() + { + using var resolver = new ModernDependencyResolver(); + AppBuilder.ResetBuilderStateForTests(); + + _ = new AppBuilder(resolver).UsingModule(new SkiaSharpSplatModule()).Build(); + + await Assert.That(resolver.GetService()).IsTypeOf(); + } +} diff --git a/src/tests/Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj b/src/tests/Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj new file mode 100644 index 000000000..ae62c6d3e --- /dev/null +++ b/src/tests/Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj @@ -0,0 +1,19 @@ + + + + $(SplatModernTargets) + false + enable + Exe + + + + + + + + + + + diff --git a/src/tests/Splat.SkiaSharp.Tests/TestImages.cs b/src/tests/Splat.SkiaSharp.Tests/TestImages.cs new file mode 100644 index 000000000..a4aacd12e --- /dev/null +++ b/src/tests/Splat.SkiaSharp.Tests/TestImages.cs @@ -0,0 +1,117 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.IO; + +using SkiaSharp; + +namespace Splat.SkiaSharp.Tests; + +/// Builds the images the tests decode, so no binary fixtures have to be checked in. +internal static class TestImages +{ + /// The encoder quality the fixtures are written at. + private const int FixtureQuality = 90; + + /// The offset of the orientation value inside . + private const int OrientationValueOffset = 28; + + /// The offset a marker segment is spliced in at, which is just past the start-of-image marker. + private const int SegmentOffset = 2; + + /// + /// A JPEG application segment holding the smallest well-formed metadata block that records an orientation: + /// the Exif identifier, a little-endian header, and a single directory entry for the orientation tag. + /// + private static readonly byte[] _orientationSegment = + [ + 0xFF, 0xE1, 0x00, 0x22, + 0x45, 0x78, 0x69, 0x66, 0x00, 0x00, + 0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x01, 0x00, + 0x12, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + ]; + + /// Creates a white bitmap with a single red pixel in its top left corner. + /// The marked corner is what makes a rotation or a mirroring visible in an assertion. + /// The width to create. + /// The height to create. + /// The bitmap. + internal static SKBitmap CreateCornerMarked(int width, int height) + { + var bitmap = new SKBitmap(width, height); + + using var canvas = new SKCanvas(bitmap); + using var paint = new SKPaint { Color = SKColors.Red }; + + canvas.Clear(SKColors.White); + canvas.DrawRect(0, 0, 1, 1, paint); + + return bitmap; + } + + /// Encodes a corner-marked bitmap. + /// The width to create. + /// The height to create. + /// The encoder to use. + /// The encoded image. + internal static byte[] Encode(int width, int height, SKEncodedImageFormat format) + { + using var bitmap = CreateCornerMarked(width, height); + using var data = bitmap.Encode(format, FixtureQuality); + + return data.ToArray(); + } + + /// Encodes a corner-marked JPEG that records the given orientation. + /// + /// No encoder writes this metadata, so it is spliced in: the segment goes immediately after the + /// start-of-image marker, which is where a decoder looks for it. + /// + /// The width to create. + /// The height to create. + /// The orientation to record, numbered as the metadata standard numbers them. + /// The encoded image. + internal static byte[] EncodeWithOrientation(int width, int height, int orientation) + { + var jpeg = Encode(width, height, SKEncodedImageFormat.Jpeg); + var segment = _orientationSegment.AsSpan().ToArray(); + segment[OrientationValueOffset] = (byte)orientation; + + var tagged = new byte[jpeg.Length + segment.Length]; + jpeg.AsSpan(0, SegmentOffset).CopyTo(tagged); + segment.CopyTo(tagged.AsSpan(SegmentOffset)); + jpeg.AsSpan(SegmentOffset).CopyTo(tagged.AsSpan(SegmentOffset + segment.Length)); + + return tagged; + } + + /// Opens a stream over an encoded corner-marked bitmap. + /// The width to create. + /// The height to create. + /// The encoder to use. + /// The stream. + internal static MemoryStream OpenStream(int width, int height, SKEncodedImageFormat format) => + new(Encode(width, height, format)); + + /// Finds the single red pixel a corner-marked bitmap was built with. + /// The bitmap to search. + /// The pixel position, or when nothing in the bitmap is red. + internal static (int X, int Y)? FindMarker(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + { + for (var x = 0; x < bitmap.Width; x++) + { + if (bitmap.GetPixel(x, y) == SKColors.Red) + { + return (x, y); + } + } + } + + return null; + } +}