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