From 3b35c635fbe17c56dbaf0102488762dacb16435c Mon Sep 17 00:00:00 2001
From: Glenn Watson <5834289+glennawatson@users.noreply.github.com>
Date: Mon, 3 Aug 2026 03:11:36 +1000
Subject: [PATCH 1/2] fix(drawing): honour the requested size when decoding on
Android and Cocoa
- Decode Android bitmaps in two passes: read the header for the source size,
subsample inside the decode, then settle on the exact fitted size. The
decoder writes OutWidth and OutHeight to report what it found, so setting
them as inputs did nothing and every bitmap came back at full size.
- Decode Cocoa bitmaps through an ImageIO image source bounded by the longest
edge, so only the pixels that survive the request are produced. Load and
LoadFromResource previously named the dimensions and never read them.
- Fit a requested box without distorting the image: the edge with the smaller
scale factor binds and the other follows from the source proportions.
- Extract the arithmetic into BitmapDecodeSize, which has no platform
dependency, so the contract is unit tested on the desktop targets instead of
only on platforms no test runs on.
---
src/Splat.Drawing/Bitmaps/BitmapDecodeSize.cs | 149 ++++++++++
.../Bitmaps/PlatformBitmapLoaderHelpers.cs | 85 +++++-
.../Cocoa/Bitmaps/PlatformBitmapLoader.cs | 229 ++++++++++----
.../BitmapDecodeSizeCalculationTests.cs | 279 ++++++++++++++++++
4 files changed, 668 insertions(+), 74 deletions(-)
create mode 100644 src/Splat.Drawing/Bitmaps/BitmapDecodeSize.cs
create mode 100644 src/tests/Splat.Drawing.Tests/BitmapDecodeSizeCalculationTests.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));
+ }
+}
From eb5fde8e8371c0fc17f0eba56b95d6de08803bd5 Mon Sep 17 00:00:00 2001
From: Glenn Watson <5834289+glennawatson@users.noreply.github.com>
Date: Mon, 3 Aug 2026 08:52:35 +1000
Subject: [PATCH 2/2] build: exclude the unreachable platform folders from
coverage analysis
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. Coverage analysis counted them as uncovered new code and disagreed with the
coverage service, which reports only what the collected reports contain.
---
.github/workflows/sonarcloud.yml | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
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'