diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index 8f5cb93c0..5b2729379 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -18,7 +18,10 @@ jobs:
sonarProjectKey: reactiveui_splat
sonarOrganization: reactiveui
sonarExclusions: '**/tests/**,**/Benchmarks/**,**/benchmarks/**,**/TestResults/**'
- sonarCoverageExclusions: '**/tests/**,**/Benchmarks/**,**/benchmarks/**,**/*Tests/**,**/*Tests.cs,**/Polyfills/**,**/Generated/**'
+ # The Android, Cocoa and .NET Framework platform folders compile only into target frameworks no
+ # test project builds, so nothing loads them on any machine or CI leg and they can never report
+ # coverage. Logic that needs covering is kept out of them.
+ sonarCoverageExclusions: '**/tests/**,**/Benchmarks/**,**/benchmarks/**,**/*Tests/**,**/*Tests.cs,**/Polyfills/**,**/Generated/**,**/Platforms/Android/**,**/Platforms/Cocoa/**,**/Platforms/net4/**'
# *.TypedArguments.cs holds the overload matrix each logging contract dictates - one overload per
# argument count, per level - so the repetition is the contract, not a copy that should be shared.
sonarCpdExclusions: '**/tests/**,**/Benchmarks/**,**/benchmarks/**,**/Polyfills/**,**/*.TypedArguments.cs'
diff --git a/src/Splat.Drawing/Bitmaps/BitmapDecodeSize.cs b/src/Splat.Drawing/Bitmaps/BitmapDecodeSize.cs
new file mode 100644
index 000000000..ea53b6064
--- /dev/null
+++ b/src/Splat.Drawing/Bitmaps/BitmapDecodeSize.cs
@@ -0,0 +1,149 @@
+// 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.
+
+namespace Splat;
+
+///
+/// Works out the pixel dimensions a platform decoder has to be asked for so that a caller-supplied width and height
+/// are honoured.
+///
+///
+///
+/// A requested width and height describe a box the image has to fit inside, not a shape it has to be stretched to.
+/// When both are supplied the edge that produces the smaller scale factor binds and the other edge follows from the
+/// source proportions; when one is supplied the other is derived the same way. That contract is shared by every
+/// platform loader so callers see one behaviour.
+///
+///
+/// Nothing here touches a platform imaging API, so the arithmetic is exercised directly by the tests while the
+/// platform loaders stay thin wrappers around it.
+///
+///
+internal static class BitmapDecodeSize
+{
+ /// The factor between one subsampling step and the next; decoders only accept powers of two.
+ private const int SubsamplingStep = 2;
+
+ /// The lowest orientation code, as recorded by an image container, that transposes the stored pixels.
+ private const int FirstTransposingOrientation = 5;
+
+ /// The highest orientation code, as recorded by an image container, that transposes the stored pixels.
+ private const int LastTransposingOrientation = 8;
+
+ /// Works out the exact pixel size to produce so the source fits the requested box without distortion.
+ /// The width of the source image, in pixels.
+ /// The height of the source image, in pixels.
+ /// The requested width, or when the caller did not constrain it.
+ /// The requested height, or when the caller did not constrain it.
+ ///
+ /// The dimensions to produce, or when the image should be produced at its source size
+ /// because nothing was requested or because the source could not be measured.
+ ///
+ internal static (int Width, int Height)? ChooseFittedSize(int sourceWidth, int sourceHeight, float? desiredWidth, float? desiredHeight)
+ {
+ if (desiredWidth is null && desiredHeight is null)
+ {
+ return null;
+ }
+
+ if (Math.Min(sourceWidth, sourceHeight) <= 0)
+ {
+ return null;
+ }
+
+ var width = ToWholePixels(desiredWidth);
+ var height = ToWholePixels(desiredHeight);
+
+ // The edge that shrinks the image the most is the one that keeps all of it inside the requested box; cross
+ // multiplying compares the two scale factors without dividing.
+ return height is null || (width is { } requestedWidth && (long)requestedWidth * sourceHeight <= (long)height.Value * sourceWidth)
+ ? (width!.Value, DeriveOppositeEdge(sourceHeight, width.Value, sourceWidth))
+ : (DeriveOppositeEdge(sourceWidth, height.Value, sourceHeight), height.Value);
+ }
+
+ ///
+ /// Works out the largest power-of-two subsampling factor a decoder can apply while still producing at least the
+ /// requested dimensions.
+ ///
+ ///
+ /// Subsampling happens inside the decode, so the full-size pixels are never materialised. It only lands on powers
+ /// of two, which is why an exact size still needs a final scale of whatever this leaves behind.
+ ///
+ /// The width of the source image, in pixels.
+ /// The height of the source image, in pixels.
+ /// The width wanted from the decode, in pixels.
+ /// The height wanted from the decode, in pixels.
+ /// The subsampling factor, which is always at least one.
+ internal static int ChooseSampleSize(int sourceWidth, int sourceHeight, int targetWidth, int targetHeight)
+ {
+ if (Math.Min(sourceWidth, sourceHeight) <= 0 || Math.Min(targetWidth, targetHeight) <= 0)
+ {
+ return 1;
+ }
+
+ var sampleSize = 1;
+ while (sourceWidth / (sampleSize * SubsamplingStep) >= targetWidth
+ && sourceHeight / (sampleSize * SubsamplingStep) >= targetHeight)
+ {
+ sampleSize *= SubsamplingStep;
+ }
+
+ return sampleSize;
+ }
+
+ /// Works out the longest edge a thumbnail decoder may produce so the image fits the requested box.
+ ///
+ /// A thumbnail decoder is constrained by a single number bounding the longer edge, and it keeps the proportions
+ /// itself, so the fitted box collapses to whichever of its edges is longer.
+ ///
+ /// The width of the source image, in pixels.
+ /// The height of the source image, in pixels.
+ /// The requested width, or when the caller did not constrain it.
+ /// The requested height, or when the caller did not constrain it.
+ /// The longest permitted edge, or when the image should be decoded at its source size.
+ internal static int? ChooseThumbnailPixelSize(int sourceWidth, int sourceHeight, float? desiredWidth, float? desiredHeight) =>
+ ChooseFittedSize(sourceWidth, sourceHeight, desiredWidth, desiredHeight) is { } fitted
+ ? Math.Max(fitted.Width, fitted.Height)
+ : null;
+
+ /// Reports the dimensions an image presents once the orientation recorded alongside its pixels is applied.
+ ///
+ /// A container may store a photograph rotated a quarter turn and record how to put it back. A decoder that applies
+ /// that rotation hands back transposed dimensions, so the box has to be fitted against the transposed size rather
+ /// than the stored one.
+ ///
+ /// The width of the stored pixels.
+ /// The height of the stored pixels.
+ /// The orientation code recorded by the container.
+ /// The dimensions the image presents once oriented.
+ internal static (int Width, int Height) OrientedPixelSize(int pixelWidth, int pixelHeight, int orientation) =>
+ orientation is >= FirstTransposingOrientation and <= LastTransposingOrientation
+ ? (pixelHeight, pixelWidth)
+ : (pixelWidth, pixelHeight);
+
+ /// Reduces a requested edge to a whole number of pixels that a decoder can act on.
+ /// The requested edge, or when the caller did not constrain it.
+ /// The edge in whole pixels, never below one, or when nothing was requested.
+ private static int? ToWholePixels(float? requested)
+ {
+ if (requested is not { } value)
+ {
+ return null;
+ }
+
+ return value >= int.MaxValue ? int.MaxValue : Math.Max(1, (int)value);
+ }
+
+ /// Derives the edge that was not requested from the one that binds, keeping the source proportions.
+ /// The source edge being derived, in pixels.
+ /// The requested edge that binds the result, in pixels.
+ /// The source edge matching , in pixels.
+ /// The derived edge, in whole pixels and never below one.
+ private static int DeriveOppositeEdge(int sourceOppositeEdge, int boundEdge, int sourceBoundEdge)
+ {
+ // Rounding down keeps the result inside the requested box rather than a fraction of a pixel outside it.
+ var scaled = (long)sourceOppositeEdge * boundEdge / sourceBoundEdge;
+ return (int)Math.Min(int.MaxValue, Math.Max(1, scaled));
+ }
+}
diff --git a/src/Splat.Drawing/Platforms/Android/Bitmaps/PlatformBitmapLoaderHelpers.cs b/src/Splat.Drawing/Platforms/Android/Bitmaps/PlatformBitmapLoaderHelpers.cs
index 2e4b97068..21ce7ad42 100644
--- a/src/Splat.Drawing/Platforms/Android/Bitmaps/PlatformBitmapLoaderHelpers.cs
+++ b/src/Splat.Drawing/Platforms/Android/Bitmaps/PlatformBitmapLoaderHelpers.cs
@@ -47,20 +47,7 @@ internal static class PlatformBitmapLoaderHelpers
AttemptStreamByteCorrection(sourceStream, logger);
}
- sourceStream.Position = 0;
- Bitmap? bitmap = null;
-
- if (desiredWidth is null || desiredHeight is null)
- {
- bitmap = await Task.Run(() => BitmapFactory.DecodeStream(sourceStream)).ConfigureAwait(false);
- }
- else
- {
- using var opts = new BitmapFactory.Options { OutWidth = (int)desiredWidth.Value, OutHeight = (int)desiredHeight.Value };
-
- using var noPadding = new Rect(0, 0, 0, 0);
- bitmap = await Task.Run(() => BitmapFactory.DecodeStream(sourceStream, noPadding, opts)).ConfigureAwait(true);
- }
+ var bitmap = await Task.Run(() => Decode(sourceStream, desiredWidth, desiredHeight)).ConfigureAwait(false);
return bitmap switch
{
@@ -132,4 +119,74 @@ internal static void AttemptStreamByteCorrection(Stream sourceStream, IEnableLog
sourceStream.Write([JpegEndOfImageMarkerByte1, JpegEndOfImageMarkerByte2]);
}
}
+
+ /// Decodes the stream, shrinking the image inside the decode when a size was asked for.
+ ///
+ /// The source dimensions are read from the header first so the decode can subsample: that keeps the full-size
+ /// pixels from ever being allocated, which is several times cheaper than decoding everything and scaling after.
+ /// Subsampling only lands on powers of two, so a final scale settles the image on the exact fitted size.
+ ///
+ /// The stream to decode the bitmap from.
+ /// The requested width, or when the caller did not constrain it.
+ /// The requested height, or when the caller did not constrain it.
+ /// The decoded bitmap, or when the stream does not hold an image the decoder accepts.
+ private static Bitmap? Decode(Stream sourceStream, float? desiredWidth, float? desiredHeight)
+ {
+ var (sourceWidth, sourceHeight) = ReadPixelSize(sourceStream);
+
+ if (BitmapDecodeSize.ChooseFittedSize(sourceWidth, sourceHeight, desiredWidth, desiredHeight) is not { } target)
+ {
+ return DecodeStream(sourceStream, null);
+ }
+
+ var sampleSize = BitmapDecodeSize.ChooseSampleSize(sourceWidth, sourceHeight, target.Width, target.Height);
+
+ using var options = new BitmapFactory.Options { InSampleSize = sampleSize };
+
+ var decoded = DecodeStream(sourceStream, options);
+
+ return decoded is null ? null : ScaleToFittedSize(decoded, target);
+ }
+
+ /// Reads the source dimensions from the stream's header without allocating any pixels.
+ /// The stream to inspect.
+ /// The source dimensions, which the decoder reports as non-positive when it cannot read the header.
+ private static (int Width, int Height) ReadPixelSize(Stream sourceStream)
+ {
+ using var bounds = new BitmapFactory.Options { InJustDecodeBounds = true };
+
+ // A bounds-only decode reports the dimensions through the options and hands back no bitmap.
+ DecodeStream(sourceStream, bounds)?.Dispose();
+
+ return (bounds.OutWidth, bounds.OutHeight);
+ }
+
+ /// Rewinds the stream and hands it to the decoder.
+ /// The stream to decode the bitmap from.
+ /// The decoder options, or to decode at the source size.
+ /// The decoded bitmap, or when the decoder produced none.
+ private static Bitmap? DecodeStream(Stream sourceStream, BitmapFactory.Options? options)
+ {
+ sourceStream.Position = 0;
+ return BitmapFactory.DecodeStream(sourceStream, null, options);
+ }
+
+ /// Settles a subsampled bitmap on the exact fitted size, releasing the intermediate.
+ /// The bitmap the decoder produced.
+ /// The dimensions the caller's request works out to.
+ /// The bitmap at the fitted size, or when the scale produced none.
+ private static Bitmap? ScaleToFittedSize(Bitmap decoded, (int Width, int Height) target)
+ {
+ if (decoded.Width == target.Width && decoded.Height == target.Height)
+ {
+ return decoded;
+ }
+
+ var scaled = Bitmap.CreateScaledBitmap(decoded, target.Width, target.Height, true);
+
+ decoded.Recycle();
+ decoded.Dispose();
+
+ return scaled;
+ }
}
diff --git a/src/Splat.Drawing/Platforms/Cocoa/Bitmaps/PlatformBitmapLoader.cs b/src/Splat.Drawing/Platforms/Cocoa/Bitmaps/PlatformBitmapLoader.cs
index c77187e0d..0bbae155a 100644
--- a/src/Splat.Drawing/Platforms/Cocoa/Bitmaps/PlatformBitmapLoader.cs
+++ b/src/Splat.Drawing/Platforms/Cocoa/Bitmaps/PlatformBitmapLoader.cs
@@ -2,13 +2,15 @@
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.
-#if UIKIT
+using CoreGraphics;
+
using Foundation;
+using ImageIO;
+
+#if UIKIT
using UIKit;
#else
-using Foundation;
-
using UIImage = AppKit.NSImage;
#endif
@@ -21,6 +23,18 @@ namespace Splat;
/// implementation.
public class PlatformBitmapLoader : IBitmapLoader
{
+ /// The index of the frame every load reads; only the primary image of a container is of interest.
+ private const int PrimaryImageIndex = 0;
+
+ /// The message logged when a stream does not yield an image.
+ private const string StreamFailureMessage = "Unable to parse bitmap from byte stream.";
+
+ /// The message logged when a resource does not yield an image.
+ private const string ResourceFailureMessage = "Unable to parse bitmap from resource.";
+
+ /// The message reported when the platform decoder does not hand back an image.
+ private const string DecodeFailureMessage = "Failed to load image";
+
///
public Task Load(Stream sourceStream, float? desiredWidth, float? desiredHeight)
{
@@ -28,82 +42,177 @@ public class PlatformBitmapLoader : IBitmapLoader
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
#if UIKIT
- NSRunLoop.InvokeInBackground(() =>
- {
- try
- {
- if (data is null)
- {
- throw new InvalidOperationException("Failed to load stream");
- }
+ NSRunLoop.InvokeInBackground(() => Publish(tcs, () => DecodeData(data, desiredWidth, desiredHeight), StreamFailureMessage));
+#else
+ Publish(tcs, () => DecodeData(data, desiredWidth, desiredHeight), StreamFailureMessage);
+#endif
- var bitmap = UIImage.LoadFromData(data) ?? throw new InvalidOperationException("Failed to load image");
- _ = tcs.TrySetResult(new CocoaBitmap(bitmap));
- }
- catch (Exception ex)
- {
- LogHost.Default.Debug(ex, "Unable to parse bitmap from byte stream.");
- _ = tcs.TrySetException(ex);
- }
- });
+ return tcs.Task;
+ }
+
+ ///
+ public Task LoadFromResource(string source, float? desiredWidth, float? desiredHeight)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+#if UIKIT
+ NSRunLoop.InvokeInBackground(() => Publish(tcs, () => DecodeResource(source, desiredWidth, desiredHeight), ResourceFailureMessage));
#else
+ NSRunLoop.Main.BeginInvokeOnMainThread(() => Publish(tcs, () => DecodeResource(source, desiredWidth, desiredHeight), ResourceFailureMessage));
+#endif
+ return tcs.Task;
+ }
+ ///
+ public IBitmap Create(float width, float height) => throw new NotSupportedException("Creating an empty bitmap is not supported by the Cocoa platform bitmap loader.");
+
+ /// Runs a decode and hands its outcome, successful or not, to the awaiting caller.
+ /// The completion source the caller is awaiting.
+ /// The decode to run.
+ /// The message to log if the decode throws.
+ private static void Publish(TaskCompletionSource completion, Func decode, string failureMessage)
+ {
try
{
- if (data is null)
- {
- throw new InvalidOperationException("Failed to load stream");
- }
-
- _ = tcs.TrySetResult(new CocoaBitmap(new(data)));
+ _ = completion.TrySetResult(new CocoaBitmap(decode()));
}
catch (Exception ex)
{
- LogHost.Default.Debug(ex, "Unable to parse bitmap from byte stream.");
- _ = tcs.TrySetException(ex);
+ LogHost.Default.Debug(ex, failureMessage);
+ _ = completion.TrySetException(ex);
}
-#endif
-
- return tcs.Task;
}
- ///
- public Task LoadFromResource(string source, float? desiredWidth, float? desiredHeight)
+ /// Decodes image data, shrinking it inside the decode when a size was asked for.
+ /// The encoded image, or when the stream could not be read.
+ /// The requested width, or when the caller did not constrain it.
+ /// The requested height, or when the caller did not constrain it.
+ /// The decoded image.
+ private static UIImage DecodeData(NSData? data, float? desiredWidth, float? desiredHeight)
{
- var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (data is null)
+ {
+ throw new InvalidOperationException("Failed to load stream");
+ }
-#if UIKIT
- NSRunLoop.InvokeInBackground(() =>
+ if (desiredWidth is null && desiredHeight is null)
{
- try
- {
- var bitmap = UIImage.FromBundle(source) ?? throw new InvalidOperationException($"Failed to load image from resource: {source}");
- _ = tcs.TrySetResult(new CocoaBitmap(bitmap));
- }
- catch (Exception ex)
- {
- LogHost.Default.Debug(ex, "Unable to parse bitmap from resource.");
- _ = tcs.TrySetException(ex);
- }
- });
+#if UIKIT
+ return UIImage.LoadFromData(data) ?? throw new InvalidOperationException(DecodeFailureMessage);
#else
- NSRunLoop.Main.BeginInvokeOnMainThread(() =>
+ return new(data);
+#endif
+ }
+
+ using var imageSource = CGImageSource.FromData(data) ?? throw new InvalidOperationException(DecodeFailureMessage);
+
+ return DecodeAtSize(imageSource, desiredWidth, desiredHeight);
+ }
+
+ /// Decodes a bundled image, shrinking it inside the decode when the bundle exposes it as a file.
+ ///
+ /// An image the bundle only offers through its asset catalogue has no file to read a header from, so it is loaded
+ /// by name at its natural size; everything else goes through the scaling decoder.
+ ///
+ /// The resource to load, as a relative path or a resource name.
+ /// The requested width, or when the caller did not constrain it.
+ /// The requested height, or when the caller did not constrain it.
+ /// The decoded image.
+ private static UIImage DecodeResource(string source, float? desiredWidth, float? desiredHeight)
+ {
+ if (desiredWidth is not null || desiredHeight is not null)
{
- try
- {
- var bitmap = UIImage.ImageNamed(source) ?? throw new InvalidOperationException($"Failed to load image from resource: {source}");
- _ = tcs.TrySetResult(new CocoaBitmap(bitmap));
- }
- catch (Exception ex)
+ using var imageSource = OpenBundleResource(source);
+
+ if (imageSource is not null)
{
- LogHost.Default.Debug(ex, "Unable to parse bitmap from resource.");
- _ = tcs.TrySetException(ex);
+ return DecodeAtSize(imageSource, desiredWidth, desiredHeight);
}
- });
+ }
+
+#if UIKIT
+ return UIImage.FromBundle(source) ?? throw new InvalidOperationException($"Failed to load image from resource: {source}");
+#else
+ return UIImage.ImageNamed(source) ?? throw new InvalidOperationException($"Failed to load image from resource: {source}");
#endif
- return tcs.Task;
}
- ///
- public IBitmap Create(float width, float height) => throw new NotSupportedException("Creating an empty bitmap is not supported by the Cocoa platform bitmap loader.");
+ /// Opens a bundled resource for decoding when the bundle exposes it as a file.
+ /// The resource to load, as a relative path or a resource name.
+ /// The opened image, or when the bundle has no file for the resource.
+ private static CGImageSource? OpenBundleResource(string source)
+ {
+ var url = NSBundle.MainBundle.GetUrlForResource(
+ Path.GetFileNameWithoutExtension(source),
+ Path.GetExtension(source).TrimStart('.'));
+
+ return url is null ? null : CGImageSource.FromUrl(url);
+ }
+
+ /// Decodes an image down to the size the caller's request works out to.
+ ///
+ /// The thumbnail decoder reads the header, then produces only the pixels that survive the requested bound, so the
+ /// full-size image is never materialised. It keeps the proportions itself, which is why a single bound expresses
+ /// the whole request.
+ ///
+ /// The opened image to decode.
+ /// The requested width, or when the caller did not constrain it.
+ /// The requested height, or when the caller did not constrain it.
+ /// The decoded image.
+ private static UIImage DecodeAtSize(CGImageSource imageSource, float? desiredWidth, float? desiredHeight)
+ {
+ var (sourceWidth, sourceHeight) = ReadPixelSize(imageSource);
+
+ return FromCoreGraphics(
+ BitmapDecodeSize.ChooseThumbnailPixelSize(sourceWidth, sourceHeight, desiredWidth, desiredHeight) is { } maxPixelSize
+ ? imageSource.CreateThumbnail(PrimaryImageIndex, ThumbnailOptions(maxPixelSize))
+ : imageSource.CreateImage(PrimaryImageIndex, new()));
+ }
+
+ /// Builds the instruction that bounds a thumbnail decode.
+ ///
+ /// The decoder is told to build the thumbnail from the image itself rather than reuse one the container happens
+ /// to embed, which would be whatever size its author chose, and to apply the recorded orientation.
+ ///
+ /// The longest edge the decoder may produce, in pixels.
+ /// The decoder options.
+ private static CGImageThumbnailOptions ThumbnailOptions(int maxPixelSize) =>
+ new() { CreateThumbnailFromImageAlways = true, CreateThumbnailWithTransform = true, MaxPixelSize = maxPixelSize };
+
+ /// Reads the dimensions an image presents, from its header rather than its pixels.
+ /// The opened image to inspect.
+ /// The dimensions, which are non-positive when the header could not be read.
+ private static (int Width, int Height) ReadPixelSize(CGImageSource imageSource)
+ {
+ var properties = imageSource.GetProperties(PrimaryImageIndex);
+
+ // The decoder is asked to apply the recorded orientation, so a quarter-turned photograph comes back with its
+ // stored dimensions transposed and the box has to be fitted against that.
+ return BitmapDecodeSize.OrientedPixelSize(
+ properties?.PixelWidth ?? 0,
+ properties?.PixelHeight ?? 0,
+ (int)(properties?.Orientation ?? 0));
+ }
+
+ /// Wraps a decoded Core Graphics image in the platform's image type.
+ /// The decoded image, or when the decoder produced none.
+ /// The platform image.
+ private static UIImage FromCoreGraphics(CGImage? image)
+ {
+ if (image is null)
+ {
+ throw new InvalidOperationException(DecodeFailureMessage);
+ }
+
+ // The platform image retains the Core Graphics image, so this reference to it is finished with either way.
+ using (image)
+ {
+#if UIKIT
+ return new(image);
+#else
+ // An empty size tells the image to take the dimensions of the pixels it was handed.
+ return new(image, CGSize.Empty);
+#endif
+ }
+ }
}
diff --git a/src/tests/Splat.Drawing.Tests/BitmapDecodeSizeCalculationTests.cs b/src/tests/Splat.Drawing.Tests/BitmapDecodeSizeCalculationTests.cs
new file mode 100644
index 000000000..070823ff9
--- /dev/null
+++ b/src/tests/Splat.Drawing.Tests/BitmapDecodeSizeCalculationTests.cs
@@ -0,0 +1,279 @@
+// 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.
+
+namespace Splat.Tests;
+
+/// Verifies the dimensions worked out for a caller who asks for a bitmap at a particular size.
+///
+/// The platform loaders each hand a decoder a different kind of instruction - a subsampling factor here, a bound on
+/// the longer edge there - but all of them derive it from this arithmetic, so pinning it down here pins down the
+/// behaviour on platforms whose decoders cannot be run in a test.
+///
+public sealed class BitmapDecodeSizeCalculationTests
+{
+ /// The width, in pixels, of the landscape source image the calculations are made against.
+ private const int LandscapeWidth = 4000;
+
+ /// The height, in pixels, of the landscape source image the calculations are made against.
+ private const int LandscapeHeight = 2000;
+
+ /// The edge, in pixels, of the square box the image is asked to fit inside.
+ private const int BoxEdge = 200;
+
+ /// Half the box edge; the landscape source fitted to lands on this height.
+ private const int HalfBoxEdge = 100;
+
+ /// A width that matches the landscape source proportions when paired with .
+ private const int AspectMatchingWidth = 400;
+
+ /// A request smaller than a single pixel, used to check the lower clamp.
+ private const float SubPixelWidth = 0.4F;
+
+ /// The smallest image a decoder can be asked for.
+ private const int SinglePixel = 1;
+
+ /// A requested height taller than the box is wide, so the height is what stops the subsampling.
+ private const int TallBoxHeight = 1000;
+
+ /// The subsampling factor for a box twenty times smaller than the source in both directions.
+ private const int TwentyFoldSampleSize = 16;
+
+ /// The subsampling factor for a request whose height leaves only one step of headroom.
+ private const int SingleStepSampleSize = 2;
+
+ /// The subsampling factor that keeps every pixel the decoder reads.
+ private const int NoSubsampling = 1;
+
+ /// An orientation code for pixels stored the way they are meant to be shown.
+ private const int UprightOrientation = 1;
+
+ /// An orientation code for pixels stored a quarter turn away from how they are meant to be shown.
+ private const int QuarterTurnedOrientation = 6;
+
+ /// An orientation code past the ones a container can record.
+ private const int OrientationPastTheKnownCodes = 9;
+
+ /// A degenerate source dimension, as a decoder reports when it cannot read a header.
+ private const int Unmeasurable = 0;
+
+ ///
+ /// The height derived for a landscape source whose requested width was capped at the largest addressable one;
+ /// the source is twice as wide as it is tall, so the height comes out at half the cap.
+ ///
+ private const int HalfTheLargestPixelCount = int.MaxValue / 2;
+
+ /// Verifies an unconstrained request leaves the image at its source size.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task NeitherDimensionRequested_LeavesTheSourceSize()
+ {
+ var fitted = BitmapDecodeSize.ChooseFittedSize(LandscapeWidth, LandscapeHeight, null, null);
+
+ await Assert.That(fitted).IsNull();
+ }
+
+ /// Verifies a source whose header could not be read is left at its source size.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task UnmeasurableSource_LeavesTheSourceSize()
+ {
+ var fitted = BitmapDecodeSize.ChooseFittedSize(Unmeasurable, Unmeasurable, BoxEdge, BoxEdge);
+
+ await Assert.That(fitted).IsNull();
+ }
+
+ /// Verifies a width-only request derives the height from the source proportions.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task WidthOnly_DerivesTheHeight()
+ {
+ var fitted = BitmapDecodeSize.ChooseFittedSize(LandscapeWidth, LandscapeHeight, BoxEdge, null);
+
+ await Assert.That(fitted).IsEqualTo((BoxEdge, HalfBoxEdge));
+ }
+
+ /// Verifies a height-only request derives the width from the source proportions.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task HeightOnly_DerivesTheWidth()
+ {
+ var fitted = BitmapDecodeSize.ChooseFittedSize(LandscapeWidth, LandscapeHeight, null, BoxEdge);
+
+ await Assert.That(fitted).IsEqualTo((AspectMatchingWidth, BoxEdge));
+ }
+
+ /// Verifies a source wider than the requested box is bound by its width, not stretched to both edges.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task SourceWiderThanTheBox_IsBoundByWidth()
+ {
+ // 4000x2000 into a 200x200 box: the width scales by 0.05 and the height by 0.1, so the width binds and the
+ // derived height lands on 100, inside the box.
+ var fitted = BitmapDecodeSize.ChooseFittedSize(LandscapeWidth, LandscapeHeight, BoxEdge, BoxEdge);
+
+ await Assert.That(fitted).IsEqualTo((BoxEdge, HalfBoxEdge));
+ }
+
+ /// Verifies a source taller than the requested box is bound by its height.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task SourceTallerThanTheBox_IsBoundByHeight()
+ {
+ // The portrait source is the landscape one rotated, so the height binds instead.
+ var fitted = BitmapDecodeSize.ChooseFittedSize(LandscapeHeight, LandscapeWidth, BoxEdge, BoxEdge);
+
+ await Assert.That(fitted).IsEqualTo((HalfBoxEdge, BoxEdge));
+ }
+
+ /// Verifies a request matching the source proportions reaches both requested edges exactly.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task RequestMatchingTheSourceProportions_ReachesBothEdges()
+ {
+ var fitted = BitmapDecodeSize.ChooseFittedSize(LandscapeWidth, LandscapeHeight, AspectMatchingWidth, BoxEdge);
+
+ await Assert.That(fitted).IsEqualTo((AspectMatchingWidth, BoxEdge));
+ }
+
+ /// Verifies a request smaller than a pixel still leaves an image a decoder can produce.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task RequestSmallerThanAPixel_LeavesASinglePixel()
+ {
+ var fitted = BitmapDecodeSize.ChooseFittedSize(LandscapeWidth, LandscapeHeight, SubPixelWidth, null);
+
+ await Assert.That(fitted).IsEqualTo((SinglePixel, SinglePixel));
+ }
+
+ /// Verifies a request larger than any addressable image is capped rather than wrapping round.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task RequestLargerThanAnyAddressableImage_IsCapped()
+ {
+ var fitted = BitmapDecodeSize.ChooseFittedSize(LandscapeWidth, LandscapeHeight, float.MaxValue, null);
+
+ await Assert.That(fitted).IsEqualTo((int.MaxValue, HalfTheLargestPixelCount));
+ }
+
+ /// Verifies a derived edge larger than any addressable image is capped as well.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task DerivedEdgeLargerThanAnyAddressableImage_IsCapped()
+ {
+ var fitted = BitmapDecodeSize.ChooseFittedSize(LandscapeHeight, LandscapeWidth, float.MaxValue, null);
+
+ await Assert.That(fitted).IsEqualTo((int.MaxValue, int.MaxValue));
+ }
+
+ /// Verifies a source whose header could not be read is decoded without subsampling.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task SubsamplingAnUnmeasurableSource_KeepsEveryPixel()
+ {
+ var sampleSize = BitmapDecodeSize.ChooseSampleSize(Unmeasurable, Unmeasurable, BoxEdge, HalfBoxEdge);
+
+ await Assert.That(sampleSize).IsEqualTo(NoSubsampling);
+ }
+
+ /// Verifies an empty target size is decoded without subsampling rather than sampled without end.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task SubsamplingToAnEmptyTarget_KeepsEveryPixel()
+ {
+ var sampleSize = BitmapDecodeSize.ChooseSampleSize(LandscapeWidth, LandscapeHeight, Unmeasurable, Unmeasurable);
+
+ await Assert.That(sampleSize).IsEqualTo(NoSubsampling);
+ }
+
+ /// Verifies a target far below the source is reached by repeated halving, stopping before it undershoots.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task SubsamplingToAMuchSmallerTarget_HalvesUntilAFurtherStepWouldUndershoot()
+ {
+ // Halving 4000x2000 five times would leave 125x62, below the requested 200x100, so it stops at four.
+ var sampleSize = BitmapDecodeSize.ChooseSampleSize(LandscapeWidth, LandscapeHeight, BoxEdge, HalfBoxEdge);
+
+ await Assert.That(sampleSize).IsEqualTo(TwentyFoldSampleSize);
+ }
+
+ /// Verifies the height stops the halving when it runs out of room before the width does.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task SubsamplingToATargetBoundByHeight_StopsOnTheHeight()
+ {
+ var sampleSize = BitmapDecodeSize.ChooseSampleSize(LandscapeWidth, LandscapeHeight, HalfBoxEdge, TallBoxHeight);
+
+ await Assert.That(sampleSize).IsEqualTo(SingleStepSampleSize);
+ }
+
+ /// Verifies a target at the source size is decoded without subsampling.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task SubsamplingToTheSourceSize_KeepsEveryPixel()
+ {
+ var sampleSize = BitmapDecodeSize.ChooseSampleSize(LandscapeWidth, LandscapeHeight, LandscapeWidth, LandscapeHeight);
+
+ await Assert.That(sampleSize).IsEqualTo(NoSubsampling);
+ }
+
+ /// Verifies an unconstrained request leaves a thumbnail decoder unbounded.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task ThumbnailWithNoRequestedSize_IsUnbounded()
+ {
+ var maxPixelSize = BitmapDecodeSize.ChooseThumbnailPixelSize(LandscapeWidth, LandscapeHeight, null, null);
+
+ await Assert.That(maxPixelSize).IsNull();
+ }
+
+ /// Verifies a thumbnail of a landscape source is bounded by the fitted width.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task ThumbnailOfALandscapeSource_IsBoundedByTheFittedWidth()
+ {
+ var maxPixelSize = BitmapDecodeSize.ChooseThumbnailPixelSize(LandscapeWidth, LandscapeHeight, BoxEdge, BoxEdge);
+
+ await Assert.That(maxPixelSize).IsEqualTo(BoxEdge);
+ }
+
+ /// Verifies a thumbnail of a portrait source is bounded by the fitted height.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task ThumbnailOfAPortraitSource_IsBoundedByTheFittedHeight()
+ {
+ var maxPixelSize = BitmapDecodeSize.ChooseThumbnailPixelSize(LandscapeHeight, LandscapeWidth, BoxEdge, BoxEdge);
+
+ await Assert.That(maxPixelSize).IsEqualTo(BoxEdge);
+ }
+
+ /// Verifies pixels stored the way they are shown keep their dimensions.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task UprightPixels_KeepTheirDimensions()
+ {
+ var oriented = BitmapDecodeSize.OrientedPixelSize(LandscapeWidth, LandscapeHeight, UprightOrientation);
+
+ await Assert.That(oriented).IsEqualTo((LandscapeWidth, LandscapeHeight));
+ }
+
+ /// Verifies pixels stored a quarter turn away are measured the way they will be shown.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task QuarterTurnedPixels_AreTransposed()
+ {
+ var oriented = BitmapDecodeSize.OrientedPixelSize(LandscapeWidth, LandscapeHeight, QuarterTurnedOrientation);
+
+ await Assert.That(oriented).IsEqualTo((LandscapeHeight, LandscapeWidth));
+ }
+
+ /// Verifies an orientation nobody records leaves the stored dimensions alone.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task PixelsWithAnUnrecognisedOrientation_KeepTheirDimensions()
+ {
+ var oriented = BitmapDecodeSize.OrientedPixelSize(LandscapeWidth, LandscapeHeight, OrientationPastTheKnownCodes);
+
+ await Assert.That(oriented).IsEqualTo((LandscapeWidth, LandscapeHeight));
+ }
+}