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