From 80fb47ba77b820827beb024fd9192fc8c15cf95a Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:21:06 +1000 Subject: [PATCH 1/2] feat(skiasharp): add a Skia bitmap loader package - Add Splat.SkiaSharp, an opt-in IBitmapLoader and IBitmap over SkiaSharp for net8.0 through net11.0, where BitmapLoader.Current has nothing to resolve today. - Take an explicit Linux native asset dependency: SkiaSharp itself only carries the macOS and Win32 assets on these target frameworks, so without it the package restores and then fails to load at run time on the hosts it exists to serve. - Preserve the aspect ratio when a decode size is requested, matching the desktop loader: both dimensions fit inside the box, one derives the other. - Apply the orientation the source recorded, and report it, so an image stored on its side decodes upright. - Add ToNative and FromNative that return the Skia bitmap rather than pretending to be a platform type, a Save overload for the formats the shared enumeration has no name for, and a choice of resampler. - Register through a module and a resolver extension, with no scanning. - Keep the native symbol files out of build and publish output, which they otherwise dominate. --- src/Directory.Packages.props | 3 + src/Splat.SkiaSharp/BitmapMixins.cs | 40 +++ .../Builder/SkiaSharpSplatModule.cs | 38 +++ .../MutableDependencyResolverExtensions.cs | 43 +++ .../PublicAPI/net10.0/PublicAPI.txt | 53 ++++ .../PublicAPI/net11.0/PublicAPI.txt | 53 ++++ .../PublicAPI/net8.0/PublicAPI.txt | 53 ++++ .../PublicAPI/net9.0/PublicAPI.txt | 53 ++++ src/Splat.SkiaSharp/SkiaBitmap.cs | 104 +++++++ src/Splat.SkiaSharp/SkiaBitmapLoader.cs | 264 ++++++++++++++++ src/Splat.SkiaSharp/Splat.SkiaSharp.csproj | 32 ++ .../build/Splat.SkiaSharp.targets | 34 ++ src/Splat.slnx | 2 + .../BitmapMixinsTests.cs | 93 ++++++ .../Splat.SkiaSharp.Tests/DecodeSizeTests.cs | 110 +++++++ .../EncodedOrientationTests.cs | 90 ++++++ .../SkiaBitmapLoaderTests.cs | 294 ++++++++++++++++++ .../Splat.SkiaSharp.Tests/SkiaBitmapTests.cs | 182 +++++++++++ .../SkiaSharpSplatModuleTests.cs | 118 +++++++ .../Splat.SkiaSharp.Tests.csproj | 18 ++ src/tests/Splat.SkiaSharp.Tests/TestImages.cs | 117 +++++++ 21 files changed, 1794 insertions(+) create mode 100644 src/Splat.SkiaSharp/BitmapMixins.cs create mode 100644 src/Splat.SkiaSharp/Builder/SkiaSharpSplatModule.cs create mode 100644 src/Splat.SkiaSharp/MutableDependencyResolverExtensions.cs create mode 100644 src/Splat.SkiaSharp/PublicAPI/net10.0/PublicAPI.txt create mode 100644 src/Splat.SkiaSharp/PublicAPI/net11.0/PublicAPI.txt create mode 100644 src/Splat.SkiaSharp/PublicAPI/net8.0/PublicAPI.txt create mode 100644 src/Splat.SkiaSharp/PublicAPI/net9.0/PublicAPI.txt create mode 100644 src/Splat.SkiaSharp/SkiaBitmap.cs create mode 100644 src/Splat.SkiaSharp/SkiaBitmapLoader.cs create mode 100644 src/Splat.SkiaSharp/Splat.SkiaSharp.csproj create mode 100644 src/Splat.SkiaSharp/build/Splat.SkiaSharp.targets create mode 100644 src/tests/Splat.SkiaSharp.Tests/BitmapMixinsTests.cs create mode 100644 src/tests/Splat.SkiaSharp.Tests/DecodeSizeTests.cs create mode 100644 src/tests/Splat.SkiaSharp.Tests/EncodedOrientationTests.cs create mode 100644 src/tests/Splat.SkiaSharp.Tests/SkiaBitmapLoaderTests.cs create mode 100644 src/tests/Splat.SkiaSharp.Tests/SkiaBitmapTests.cs create mode 100644 src/tests/Splat.SkiaSharp.Tests/SkiaSharpSplatModuleTests.cs create mode 100644 src/tests/Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj create mode 100644 src/tests/Splat.SkiaSharp.Tests/TestImages.cs 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..93d07eaf6 --- /dev/null +++ b/src/Splat.SkiaSharp/Splat.SkiaSharp.csproj @@ -0,0 +1,32 @@ + + + $(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..7d0460ca9 --- /dev/null +++ b/src/tests/Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj @@ -0,0 +1,18 @@ + + + + $(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; + } +} From 490be0c7c736a6680802eedb772a35376b6732ca Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:05:36 +1000 Subject: [PATCH 2/2] build(skiasharp): leave the native asset choice to the consumer - Drop the Linux native asset reference from the library. It flowed into every consumer's output regardless of what they target, and clashed with the one an application had already chosen for itself. - Name it in the test project instead, and only when building on Linux, since the base package already carries the Windows and macOS natives. --- src/Splat.SkiaSharp/Splat.SkiaSharp.csproj | 7 ------- .../Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Splat.SkiaSharp/Splat.SkiaSharp.csproj b/src/Splat.SkiaSharp/Splat.SkiaSharp.csproj index 93d07eaf6..26bab71de 100644 --- a/src/Splat.SkiaSharp/Splat.SkiaSharp.csproj +++ b/src/Splat.SkiaSharp/Splat.SkiaSharp.csproj @@ -10,13 +10,6 @@ - - diff --git a/src/tests/Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj b/src/tests/Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj index 7d0460ca9..ae62c6d3e 100644 --- a/src/tests/Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj +++ b/src/tests/Splat.SkiaSharp.Tests/Splat.SkiaSharp.Tests.csproj @@ -9,6 +9,7 @@ +