Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
149 changes: 149 additions & 0 deletions src/Splat.Drawing/Bitmaps/BitmapDecodeSize.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Works out the pixel dimensions a platform decoder has to be asked for so that a caller-supplied width and height
/// are honoured.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal static class BitmapDecodeSize
{
/// <summary>The factor between one subsampling step and the next; decoders only accept powers of two.</summary>
private const int SubsamplingStep = 2;

/// <summary>The lowest orientation code, as recorded by an image container, that transposes the stored pixels.</summary>
private const int FirstTransposingOrientation = 5;

/// <summary>The highest orientation code, as recorded by an image container, that transposes the stored pixels.</summary>
private const int LastTransposingOrientation = 8;

/// <summary>Works out the exact pixel size to produce so the source fits the requested box without distortion.</summary>
/// <param name="sourceWidth">The width of the source image, in pixels.</param>
/// <param name="sourceHeight">The height of the source image, in pixels.</param>
/// <param name="desiredWidth">The requested width, or <see langword="null"/> when the caller did not constrain it.</param>
/// <param name="desiredHeight">The requested height, or <see langword="null"/> when the caller did not constrain it.</param>
/// <returns>
/// The dimensions to produce, or <see langword="null"/> when the image should be produced at its source size
/// because nothing was requested or because the source could not be measured.
/// </returns>
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);
}

/// <summary>
/// Works out the largest power-of-two subsampling factor a decoder can apply while still producing at least the
/// requested dimensions.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="sourceWidth">The width of the source image, in pixels.</param>
/// <param name="sourceHeight">The height of the source image, in pixels.</param>
/// <param name="targetWidth">The width wanted from the decode, in pixels.</param>
/// <param name="targetHeight">The height wanted from the decode, in pixels.</param>
/// <returns>The subsampling factor, which is always at least one.</returns>
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;
}

/// <summary>Works out the longest edge a thumbnail decoder may produce so the image fits the requested box.</summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="sourceWidth">The width of the source image, in pixels.</param>
/// <param name="sourceHeight">The height of the source image, in pixels.</param>
/// <param name="desiredWidth">The requested width, or <see langword="null"/> when the caller did not constrain it.</param>
/// <param name="desiredHeight">The requested height, or <see langword="null"/> when the caller did not constrain it.</param>
/// <returns>The longest permitted edge, or <see langword="null"/> when the image should be decoded at its source size.</returns>
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;

/// <summary>Reports the dimensions an image presents once the orientation recorded alongside its pixels is applied.</summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="pixelWidth">The width of the stored pixels.</param>
/// <param name="pixelHeight">The height of the stored pixels.</param>
/// <param name="orientation">The orientation code recorded by the container.</param>
/// <returns>The dimensions the image presents once oriented.</returns>
internal static (int Width, int Height) OrientedPixelSize(int pixelWidth, int pixelHeight, int orientation) =>
orientation is >= FirstTransposingOrientation and <= LastTransposingOrientation
? (pixelHeight, pixelWidth)
: (pixelWidth, pixelHeight);

/// <summary>Reduces a requested edge to a whole number of pixels that a decoder can act on.</summary>
/// <param name="requested">The requested edge, or <see langword="null"/> when the caller did not constrain it.</param>
/// <returns>The edge in whole pixels, never below one, or <see langword="null"/> when nothing was requested.</returns>
private static int? ToWholePixels(float? requested)
{
if (requested is not { } value)
{
return null;
}

return value >= int.MaxValue ? int.MaxValue : Math.Max(1, (int)value);
}

/// <summary>Derives the edge that was not requested from the one that binds, keeping the source proportions.</summary>
/// <param name="sourceOppositeEdge">The source edge being derived, in pixels.</param>
/// <param name="boundEdge">The requested edge that binds the result, in pixels.</param>
/// <param name="sourceBoundEdge">The source edge matching <paramref name="boundEdge"/>, in pixels.</param>
/// <returns>The derived edge, in whole pixels and never below one.</returns>
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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -132,4 +119,74 @@ internal static void AttemptStreamByteCorrection(Stream sourceStream, IEnableLog
sourceStream.Write([JpegEndOfImageMarkerByte1, JpegEndOfImageMarkerByte2]);
}
}

/// <summary>Decodes the stream, shrinking the image inside the decode when a size was asked for.</summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="sourceStream">The stream to decode the bitmap from.</param>
/// <param name="desiredWidth">The requested width, or <see langword="null"/> when the caller did not constrain it.</param>
/// <param name="desiredHeight">The requested height, or <see langword="null"/> when the caller did not constrain it.</param>
/// <returns>The decoded bitmap, or <see langword="null"/> when the stream does not hold an image the decoder accepts.</returns>
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);
}

/// <summary>Reads the source dimensions from the stream's header without allocating any pixels.</summary>
/// <param name="sourceStream">The stream to inspect.</param>
/// <returns>The source dimensions, which the decoder reports as non-positive when it cannot read the header.</returns>
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);
}

/// <summary>Rewinds the stream and hands it to the decoder.</summary>
/// <param name="sourceStream">The stream to decode the bitmap from.</param>
/// <param name="options">The decoder options, or <see langword="null"/> to decode at the source size.</param>
/// <returns>The decoded bitmap, or <see langword="null"/> when the decoder produced none.</returns>
private static Bitmap? DecodeStream(Stream sourceStream, BitmapFactory.Options? options)
{
sourceStream.Position = 0;
return BitmapFactory.DecodeStream(sourceStream, null, options);
}

/// <summary>Settles a subsampled bitmap on the exact fitted size, releasing the intermediate.</summary>
/// <param name="decoded">The bitmap the decoder produced.</param>
/// <param name="target">The dimensions the caller's request works out to.</param>
/// <returns>The bitmap at the fitted size, or <see langword="null"/> when the scale produced none.</returns>
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;
}
}
Loading
Loading