From 0ad2d89ebbb448aa34d000934b8157ca2fc0f4b6 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 19 Mar 2026 09:41:01 +0100 Subject: [PATCH 01/28] Make FilterQuality configurable for Resizetizer Add a FilterQuality property to ResizeImageInfo with a default of SKFilterQuality.High (preserving existing behavior). The property is parsed from MSBuild item metadata and threaded through SkiaSharpTools, SkiaSharpRasterTools, SkiaSharpSvgTools, SkiaSharpImaginaryTools, Resizer, SkiaSharpAppIconTools, and AndroidAdaptiveIconGenerator. Users can now configure FilterQuality per image in their project file: Fixes dotnet/maui#25750 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/AndroidAdaptiveIconGenerator.cs | 4 +- .../Resizetizer/src/ResizeImageInfo.cs | 13 ++++ src/SingleProject/Resizetizer/src/Resizer.cs | 2 +- .../Resizetizer/src/SkiaSharpAppIconTools.cs | 4 +- .../src/SkiaSharpImaginaryTools.cs | 2 +- .../Resizetizer/src/SkiaSharpRasterTools.cs | 8 +- .../Resizetizer/src/SkiaSharpSvgTools.cs | 8 +- .../Resizetizer/src/SkiaSharpTools.cs | 16 ++-- .../test/UnitTests/ResizeImageInfoTests.cs | 77 +++++++++++++++++++ .../UnitTests/SkiaSharpRasterToolsTests.cs | 61 +++++++++++++++ .../test/UnitTests/SkiaSharpSvgToolsTests.cs | 60 +++++++++++++++ 11 files changed, 237 insertions(+), 18 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs index 65c417fe9d53..52e84aecfea3 100644 --- a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs +++ b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs @@ -92,7 +92,7 @@ void ProcessBackground(List results, DirectoryInfo fullInterme if (backgroundExists) { // resize the background - var tools = SkiaSharpTools.Create(Info.IsVector, Info.Filename, dpi.Size, Info.Color, null, Logger); + var tools = SkiaSharpTools.Create(Info.IsVector, Info.Filename, dpi.Size, Info.Color, null, Info.FilterQuality, Logger); tools.Resize(dpi, destination, dpiSizeIsAbsolute: true); } else @@ -136,7 +136,7 @@ void ProcessForeground(List results, DirectoryInfo fullInterme if (foregroundExists) { // resize the forground - var tools = SkiaSharpTools.Create(Info.ForegroundIsVector, Info.ForegroundFilename, dpi.Size, null, Info.TintColor, Logger); + var tools = SkiaSharpTools.Create(Info.ForegroundIsVector, Info.ForegroundFilename, dpi.Size, null, Info.TintColor, Info.FilterQuality, Logger); tools.Resize(dpi, destination, Info.ForegroundScale, dpiSizeIsAbsolute: true); } else diff --git a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs index 79913d4b6bba..23adeeec5892 100644 --- a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs +++ b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs @@ -10,6 +10,10 @@ namespace Microsoft.Maui.Resizetizer { internal class ResizeImageInfo { +#pragma warning disable CS0618 // Type or member is obsolete + public static readonly SKFilterQuality DefaultFilterQuality = SKFilterQuality.High; +#pragma warning restore CS0618 // Type or member is obsolete + public string? ItemSpec { get; set; } public string? Alias { get; set; } @@ -54,6 +58,10 @@ internal class ResizeImageInfo public bool MonochromeIsVector => IsVectorFilename(MonochromeFilename); +#pragma warning disable CS0618 // Type or member is obsolete + public SKFilterQuality FilterQuality { get; set; } = DefaultFilterQuality; +#pragma warning restore CS0618 // Type or member is obsolete + private static bool IsVectorFilename(string? filename) => IsVectorExtension(Path.GetExtension(filename)); @@ -118,6 +126,11 @@ public static ResizeImageInfo Parse(ITaskItem image) if (float.TryParse(image.GetMetadata("ForegroundScale"), NumberStyles.Number, CultureInfo.InvariantCulture, out var fsc)) info.ForegroundScale = fsc; +#pragma warning disable CS0618 // Type or member is obsolete + if (Enum.TryParse(image.GetMetadata("FilterQuality"), out var filterQuality)) + info.FilterQuality = filterQuality; +#pragma warning restore CS0618 // Type or member is obsolete + var fgFile = image.GetMetadata("ForegroundFile"); if (!string.IsNullOrEmpty(fgFile)) { diff --git a/src/SingleProject/Resizetizer/src/Resizer.cs b/src/SingleProject/Resizetizer/src/Resizer.cs index cab7352050f6..cca042f9b92e 100644 --- a/src/SingleProject/Resizetizer/src/Resizer.cs +++ b/src/SingleProject/Resizetizer/src/Resizer.cs @@ -25,7 +25,7 @@ public Resizer(ResizeImageInfo info, string intermediateOutputPath, ILogger logg public SKSize? BaseSize => Info.BaseSize; protected SkiaSharpTools Tools => - tools ??= SkiaSharpTools.Create(Info.IsVector, Info.Filename, Info.BaseSize, Info.Color, Info.TintColor, Logger); + tools ??= SkiaSharpTools.Create(Info.IsVector, Info.Filename, Info.BaseSize, Info.Color, Info.TintColor, Info.FilterQuality, Logger); public string GetRasterFileDestination(DpiPath dpi, bool includeIntermediate = true, bool includeScale = true) => GetRasterFileDestination(Info, dpi, includeIntermediate ? IntermediateOutputPath : null, includeScale); diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpAppIconTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpAppIconTools.cs index 9969b4864cfa..fbf5fe013f18 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpAppIconTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpAppIconTools.cs @@ -21,9 +21,9 @@ public SkiaSharpAppIconTools(ResizeImageInfo info, ILogger? logger) throw new InvalidOperationException("An app icon needs at least one image."); if (hasBackground) - backgroundTools = SkiaSharpTools.Create(info.IsVector, info.Filename, null, null, null, logger); + backgroundTools = SkiaSharpTools.Create(info.IsVector, info.Filename, null, null, null, info.FilterQuality, logger); if (hasForeground) - foregroundTools = SkiaSharpTools.Create(info.ForegroundIsVector, info.ForegroundFilename, null, null, info.TintColor, logger); + foregroundTools = SkiaSharpTools.Create(info.ForegroundIsVector, info.ForegroundFilename, null, null, info.TintColor, info.FilterQuality, logger); } SkiaSharpTools? backgroundTools; diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpImaginaryTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpImaginaryTools.cs index e1d7e34b7b17..09e5bd4ffbcc 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpImaginaryTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpImaginaryTools.cs @@ -11,7 +11,7 @@ public SkiaSharpImaginaryTools(ResizeImageInfo info, ILogger logger) } public SkiaSharpImaginaryTools(SKColor? backgroundColor, ILogger logger) - : base(null, new SKSize(1, 1), backgroundColor, null, logger) + : base(null, new SKSize(1, 1), backgroundColor, null, ResizeImageInfo.DefaultFilterQuality, logger) { } diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpRasterTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpRasterTools.cs index 7a60574d1ae3..40972b42deae 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpRasterTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpRasterTools.cs @@ -9,12 +9,14 @@ internal class SkiaSharpRasterTools : SkiaSharpTools, IDisposable SKImage img; public SkiaSharpRasterTools(ResizeImageInfo info, ILogger logger) - : this(info.Filename, info.BaseSize, info.Color, info.TintColor, logger) + : this(info.Filename, info.BaseSize, info.Color, info.TintColor, info.FilterQuality, logger) { } - public SkiaSharpRasterTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, ILogger logger) - : base(filename, baseSize, backgroundColor, tintColor, logger) +#pragma warning disable CS0618 // Type or member is obsolete + public SkiaSharpRasterTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, SKFilterQuality filterQuality, ILogger logger) +#pragma warning restore CS0618 // Type or member is obsolete + : base(filename, baseSize, backgroundColor, tintColor, filterQuality, logger) { var sw = new Stopwatch(); sw.Start(); diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs index eb84b186321c..49d2ea4ed1e3 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs @@ -10,12 +10,14 @@ internal class SkiaSharpSvgTools : SkiaSharpTools, IDisposable SKSvg svg; public SkiaSharpSvgTools(ResizeImageInfo info, ILogger logger) - : this(info.Filename, info.BaseSize, info.Color, info.TintColor, logger) + : this(info.Filename, info.BaseSize, info.Color, info.TintColor, info.FilterQuality, logger) { } - public SkiaSharpSvgTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, ILogger logger) - : base(filename, baseSize, backgroundColor, tintColor, logger) +#pragma warning disable CS0618 // Type or member is obsolete + public SkiaSharpSvgTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, SKFilterQuality filterQuality, ILogger logger) +#pragma warning restore CS0618 // Type or member is obsolete + : base(filename, baseSize, backgroundColor, tintColor, filterQuality, logger) { var sw = new Stopwatch(); sw.Start(); diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs index 4e29cc317b7a..59e4ada35a2c 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs @@ -61,20 +61,24 @@ static SkiaSharpTools() span[0] = new SKPoint(); } - public static SkiaSharpTools Create(bool isVector, string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, ILogger logger) +#pragma warning disable CS0618 // Type or member is obsolete + public static SkiaSharpTools Create(bool isVector, string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, SKFilterQuality filterQuality, ILogger logger) +#pragma warning restore CS0618 // Type or member is obsolete => isVector - ? new SkiaSharpSvgTools(filename, baseSize, backgroundColor, tintColor, logger) as SkiaSharpTools - : new SkiaSharpRasterTools(filename, baseSize, backgroundColor, tintColor, logger); + ? new SkiaSharpSvgTools(filename, baseSize, backgroundColor, tintColor, filterQuality, logger) as SkiaSharpTools + : new SkiaSharpRasterTools(filename, baseSize, backgroundColor, tintColor, filterQuality, logger); public static SkiaSharpTools CreateImaginary(SKColor? backgroundColor, ILogger logger) => new SkiaSharpImaginaryTools(backgroundColor, logger); public SkiaSharpTools(ResizeImageInfo info, ILogger logger) - : this(info.Filename, info.BaseSize, info.Color, info.TintColor, logger) + : this(info.Filename, info.BaseSize, info.Color, info.TintColor, info.FilterQuality, logger) { } - public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, ILogger logger) +#pragma warning disable CS0618 // Type or member is obsolete + public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, SKFilterQuality filterQuality, ILogger logger) +#pragma warning restore CS0618 // Type or member is obsolete { Logger = logger; Filename = filename; @@ -84,7 +88,7 @@ public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColo { IsAntialias = true, #pragma warning disable CS0618 // Type or member is obsolete - FilterQuality = SKFilterQuality.High, + FilterQuality = filterQuality, #pragma warning restore CS0618 // Type or member is obsolete }; diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs index ee07b7e8a423..071e871b43d4 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs @@ -1,3 +1,7 @@ +using System.Collections.Generic; +using System.IO; +using Microsoft.Build.Utilities; +using SkiaSharp; using Xunit; namespace Microsoft.Maui.Resizetizer.Tests @@ -55,5 +59,78 @@ public void DoesNotCrashOnNullOrEmpty(string filename) Assert.False(info.IsVector); } } + +#pragma warning disable CS0618 // Type or member is obsolete + public class FilterQualityTests + { + [Fact] + public void DefaultFilterQualityIsHigh() + { + var info = new ResizeImageInfo(); + Assert.Equal(SKFilterQuality.High, info.FilterQuality); + } + + [Fact] + public void DefaultFilterQualityConstantIsHigh() + { + Assert.Equal(SKFilterQuality.High, ResizeImageInfo.DefaultFilterQuality); + } + + [Theory] + [InlineData(SKFilterQuality.None)] + [InlineData(SKFilterQuality.Low)] + [InlineData(SKFilterQuality.Medium)] + [InlineData(SKFilterQuality.High)] + public void FilterQualityCanBeSet(SKFilterQuality quality) + { + var info = new ResizeImageInfo + { + FilterQuality = quality + }; + + Assert.Equal(quality, info.FilterQuality); + } + + [Theory] + [InlineData("None", SKFilterQuality.None)] + [InlineData("Low", SKFilterQuality.Low)] + [InlineData("Medium", SKFilterQuality.Medium)] + [InlineData("High", SKFilterQuality.High)] + public void FilterQualityParsedFromTaskItem(string metadataValue, SKFilterQuality expected) + { + var path = Path.GetFullPath("images/camera.png"); + var item = new TaskItem(path, new Dictionary + { + ["FilterQuality"] = metadataValue + }); + + var info = ResizeImageInfo.Parse(item); + Assert.Equal(expected, info.FilterQuality); + } + + [Fact] + public void FilterQualityDefaultsToHighWhenNotSpecified() + { + var path = Path.GetFullPath("images/camera.png"); + var item = new TaskItem(path); + + var info = ResizeImageInfo.Parse(item); + Assert.Equal(SKFilterQuality.High, info.FilterQuality); + } + + [Fact] + public void FilterQualityDefaultsToHighForInvalidValue() + { + var path = Path.GetFullPath("images/camera.png"); + var item = new TaskItem(path, new Dictionary + { + ["FilterQuality"] = "InvalidValue" + }); + + var info = ResizeImageInfo.Parse(item); + Assert.Equal(SKFilterQuality.High, info.FilterQuality); + } + } +#pragma warning restore CS0618 // Type or member is obsolete } } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs index 6f70ccce9bd0..ae217fe797d4 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs @@ -252,5 +252,66 @@ public void ColorizedWithAlphaWithColorsReplacesColors() Assert.Equal(SKColors.Red.WithAlpha(127), pixmap.GetPixelColor(125, 137)); } } + +#pragma warning disable CS0618 // Type or member is obsolete + public class FilterQualityTests : IDisposable + { + readonly string DestinationFilename; + readonly TestLogger Logger; + + public FilterQualityTests() + { + DestinationFilename = Path.GetTempFileName(); + Logger = new TestLogger(); + } + + public void Dispose() + { + File.Delete(DestinationFilename); + } + + [Fact] + public void DefaultFilterQualityIsHigh() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.png"; + var tools = new SkiaSharpRasterTools(info, Logger); + + Assert.Equal(SKFilterQuality.High, tools.Paint.FilterQuality); + } + + [Theory] + [InlineData(SKFilterQuality.None)] + [InlineData(SKFilterQuality.Low)] + [InlineData(SKFilterQuality.Medium)] + [InlineData(SKFilterQuality.High)] + public void FilterQualityIsAppliedFromInfo(SKFilterQuality quality) + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.png"; + info.FilterQuality = quality; + var tools = new SkiaSharpRasterTools(info, Logger); + + Assert.Equal(quality, tools.Paint.FilterQuality); + } + + [Fact] + public void ResizeWithNoneFilterQualityProducesValidImage() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.png"; + info.FilterQuality = SKFilterQuality.None; + info.BaseSize = new SKSize(100, 100); + var tools = new SkiaSharpRasterTools(info, Logger); + var dpiPath = new DpiPath("", 1); + + tools.Resize(dpiPath, DestinationFilename); + + using var resultImage = SKBitmap.Decode(DestinationFilename); + Assert.Equal(100, resultImage.Width); + Assert.Equal(100, resultImage.Height); + } + } +#pragma warning restore CS0618 // Type or member is obsolete } } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs index b549619e894e..00f5d296fdbc 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs @@ -333,5 +333,65 @@ public void SvgImageWithDecodingIssue_12109() Assert.Equal((SKColor)0xffe26b00, pixmap.GetPixelColor(20, 34)); } } + +#pragma warning disable CS0618 // Type or member is obsolete + public class FilterQualityTests : IDisposable + { + readonly string DestinationFilename; + readonly TestLogger Logger; + + public FilterQualityTests() + { + DestinationFilename = Path.GetTempFileName(); + Logger = new TestLogger(); + } + + public void Dispose() + { + File.Delete(DestinationFilename); + } + + [Fact] + public void DefaultFilterQualityIsHigh() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.svg"; + var tools = new SkiaSharpSvgTools(info, Logger); + + Assert.Equal(SKFilterQuality.High, tools.Paint.FilterQuality); + } + + [Theory] + [InlineData(SKFilterQuality.None)] + [InlineData(SKFilterQuality.Low)] + [InlineData(SKFilterQuality.Medium)] + [InlineData(SKFilterQuality.High)] + public void FilterQualityIsAppliedFromInfo(SKFilterQuality quality) + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.svg"; + info.FilterQuality = quality; + var tools = new SkiaSharpSvgTools(info, Logger); + + Assert.Equal(quality, tools.Paint.FilterQuality); + } + + [Fact] + public void ResizeWithNoneFilterQualityProducesValidImage() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.svg"; + info.FilterQuality = SKFilterQuality.None; + var tools = new SkiaSharpSvgTools(info, Logger); + var dpiPath = new DpiPath("", 1); + + tools.Resize(dpiPath, DestinationFilename); + + using var resultImage = SKBitmap.Decode(DestinationFilename); + Assert.True(resultImage.Width > 0); + Assert.True(resultImage.Height > 0); + } + } +#pragma warning restore CS0618 // Type or member is obsolete } } From 92052cb802a3e880dbc0d77220b93805c44481c5 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 19 Mar 2026 09:52:32 +0100 Subject: [PATCH 02/28] Map FilterQuality to SamplingOptions and add functional tests The original implementation only set Paint.FilterQuality which is deprecated and ignored by newer SkiaSharp. This fix maps FilterQuality to the actual SamplingOptions used by DrawImage, making the setting functional: - None -> Nearest/None (nearest neighbor, smallest file size) - Low -> Linear/None (bilinear, no mipmaps) - Medium -> Linear/Linear (bilinear + mipmaps) - High/default -> Linear/Linear (preserves original hardcoded behavior) Added 13 functional tests proving the feature works end-to-end: - Pixel differentiation: None vs High produce different output (proves it works) - Regression: default produces identical output to explicit High - End-to-end MSBuild pipeline: FilterQuality metadata flows through ResizetizeImages task - All quality levels produce correctly sized output for both raster and SVG Full regression: 605 passed (568 baseline + 37 new), 20 pre-existing failures, 0 regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resizetizer/src/SkiaSharpTools.cs | 16 ++- .../test/UnitTests/ResizetizeImagesTests.cs | 107 ++++++++++++++++++ .../UnitTests/SkiaSharpRasterToolsTests.cs | 103 +++++++++++++++++ .../test/UnitTests/SkiaSharpSvgToolsTests.cs | 55 +++++++++ 4 files changed, 278 insertions(+), 3 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs index 59e4ada35a2c..c732c13508c9 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs @@ -98,9 +98,19 @@ public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColo Paint.ColorFilter = SKColorFilter.CreateBlendMode(tint, SKBlendMode.SrcIn); } - // Typically the Mitchell cubic resampler is for upsampling - // and the bilinear with mipmaps is for downsampling. - SamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear); + // Map FilterQuality to SamplingOptions (SKFilterQuality on Paint is deprecated + // and ignored by newer SkiaSharp; SamplingOptions controls actual resampling). + // Default (High) preserves the original bilinear+mipmaps behavior. +#pragma warning disable CS0618 // Type or member is obsolete + SamplingOptions = filterQuality switch + { + SKFilterQuality.None => new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None), + SKFilterQuality.Low => new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.None), + SKFilterQuality.Medium => new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), + // High and default preserve the original hardcoded behavior + _ => new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), + }; +#pragma warning restore CS0618 // Type or member is obsolete } public string Filename { get; } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs index 596eb82995b0..0692ac25fd59 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs @@ -508,6 +508,113 @@ public void SingleImageWithBaseSizeSucceeds(string alias, string outputName) AssertFileSize($"drawable-xhdpi/{outputName}.png", 88, 88); } +#pragma warning disable CS0618 // Type or member is obsolete + [Fact] + public void FilterQualityMetadataIsRespectedEndToEnd() + { + // Run the full MSBuild task pipeline with FilterQuality=None + var itemsNone = new[] + { + new TaskItem("images/camera.png", new Dictionary + { + ["BaseSize"] = "100", + ["Link"] = "camera_none", + ["FilterQuality"] = "None", + }), + }; + + var taskNone = GetNewTask(itemsNone); + var successNone = taskNone.Execute(); + Assert.True(successNone, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileSize("drawable-mdpi/camera_none.png", 100, 100); + + // Save the None output pixels + var noneFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_none.png"); + using var bmpNone = SKBitmap.Decode(noneFile); + var nonePixels = bmpNone.Pixels.ToArray(); + + // Run again with FilterQuality=High + var itemsHigh = new[] + { + new TaskItem("images/camera.png", new Dictionary + { + ["BaseSize"] = "100", + ["Link"] = "camera_high", + ["FilterQuality"] = "High", + }), + }; + + var taskHigh = GetNewTask(itemsHigh); + var successHigh = taskHigh.Execute(); + Assert.True(successHigh, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileSize("drawable-mdpi/camera_high.png", 100, 100); + + // Compare: None vs High should differ when downscaling + var highFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_high.png"); + using var bmpHigh = SKBitmap.Decode(highFile); + var highPixels = bmpHigh.Pixels.ToArray(); + + int differentPixels = 0; + for (int i = 0; i < nonePixels.Length; i++) + { + if (nonePixels[i] != highPixels[i]) + differentPixels++; + } + + Assert.True(differentPixels > 0, + "End-to-end: FilterQuality.None and FilterQuality.High must produce different pixel output"); + } + + [Fact] + public void DefaultFilterQualityPreservesBaselineBehavior() + { + // No FilterQuality metadata = default (High) behavior + var itemsDefault = new[] + { + new TaskItem("images/camera.png", new Dictionary + { + ["BaseSize"] = "100", + ["Link"] = "camera_default", + }), + }; + + var taskDefault = GetNewTask(itemsDefault); + var successDefault = taskDefault.Execute(); + Assert.True(successDefault, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileSize("drawable-mdpi/camera_default.png", 100, 100); + + var defaultFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_default.png"); + using var bmpDefault = SKBitmap.Decode(defaultFile); + var defaultPixels = bmpDefault.Pixels.ToArray(); + + // Explicit High should produce identical results + var itemsHigh = new[] + { + new TaskItem("images/camera.png", new Dictionary + { + ["BaseSize"] = "100", + ["Link"] = "camera_explicit_high", + ["FilterQuality"] = "High", + }), + }; + + var taskHigh = GetNewTask(itemsHigh); + var successHigh = taskHigh.Execute(); + Assert.True(successHigh, LogErrorEvents.FirstOrDefault()?.Message); + + var highFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_explicit_high.png"); + using var bmpHigh = SKBitmap.Decode(highFile); + var highPixels = bmpHigh.Pixels.ToArray(); + + // Every single pixel must match: default == explicit High + Assert.Equal(defaultPixels.Length, highPixels.Length); + for (int i = 0; i < defaultPixels.Length; i++) + { + Assert.Equal(defaultPixels[i], highPixels[i]); + } + } +#pragma warning restore CS0618 // Type or member is obsolete + [Theory] [InlineData("camera", null, "camera")] [InlineData("camera", "", "camera")] diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs index ae217fe797d4..fc93e951ac78 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs @@ -257,17 +257,20 @@ public void ColorizedWithAlphaWithColorsReplacesColors() public class FilterQualityTests : IDisposable { readonly string DestinationFilename; + readonly string DestinationFilename2; readonly TestLogger Logger; public FilterQualityTests() { DestinationFilename = Path.GetTempFileName(); + DestinationFilename2 = Path.GetTempFileName(); Logger = new TestLogger(); } public void Dispose() { File.Delete(DestinationFilename); + File.Delete(DestinationFilename2); } [Fact] @@ -311,6 +314,106 @@ public void ResizeWithNoneFilterQualityProducesValidImage() Assert.Equal(100, resultImage.Width); Assert.Equal(100, resultImage.Height); } + + [Fact] + public void DefaultFilterQualityProducesIdenticalOutputToHardcodedHigh() + { + // Resize with default (should be High) + var infoDefault = new ResizeImageInfo(); + infoDefault.Filename = "images/camera.png"; + infoDefault.BaseSize = new SKSize(200, 200); + var toolsDefault = new SkiaSharpRasterTools(infoDefault, Logger); + var dpiPath = new DpiPath("", 1); + toolsDefault.Resize(dpiPath, DestinationFilename); + + // Resize with explicit High + var infoHigh = new ResizeImageInfo(); + infoHigh.Filename = "images/camera.png"; + infoHigh.BaseSize = new SKSize(200, 200); + infoHigh.FilterQuality = SKFilterQuality.High; + var toolsHigh = new SkiaSharpRasterTools(infoHigh, Logger); + toolsHigh.Resize(dpiPath, DestinationFilename2); + + // Pixel-by-pixel comparison: must be identical + using var bmpDefault = SKBitmap.Decode(DestinationFilename); + using var bmpHigh = SKBitmap.Decode(DestinationFilename2); + + Assert.Equal(bmpDefault.Width, bmpHigh.Width); + Assert.Equal(bmpDefault.Height, bmpHigh.Height); + + for (int y = 0; y < bmpDefault.Height; y++) + { + for (int x = 0; x < bmpDefault.Width; x++) + { + Assert.Equal(bmpDefault.GetPixel(x, y), bmpHigh.GetPixel(x, y)); + } + } + } + + [Fact] + public void DifferentFilterQualitiesProduceDifferentPixelOutput() + { + // When downscaling a large image, None (nearest neighbor) vs High (bicubic) + // should produce measurably different pixel data + var dpiPath = new DpiPath("", 1); + + var infoNone = new ResizeImageInfo(); + infoNone.Filename = "images/camera.png"; + infoNone.BaseSize = new SKSize(100, 100); + infoNone.FilterQuality = SKFilterQuality.None; + var toolsNone = new SkiaSharpRasterTools(infoNone, Logger); + toolsNone.Resize(dpiPath, DestinationFilename); + + var infoHigh = new ResizeImageInfo(); + infoHigh.Filename = "images/camera.png"; + infoHigh.BaseSize = new SKSize(100, 100); + infoHigh.FilterQuality = SKFilterQuality.High; + var toolsHigh = new SkiaSharpRasterTools(infoHigh, Logger); + toolsHigh.Resize(dpiPath, DestinationFilename2); + + using var bmpNone = SKBitmap.Decode(DestinationFilename); + using var bmpHigh = SKBitmap.Decode(DestinationFilename2); + + // Same dimensions + Assert.Equal(bmpNone.Width, bmpHigh.Width); + Assert.Equal(bmpNone.Height, bmpHigh.Height); + + // Count pixels that differ between None and High quality + int differentPixels = 0; + for (int y = 0; y < bmpNone.Height; y++) + { + for (int x = 0; x < bmpNone.Width; x++) + { + if (bmpNone.GetPixel(x, y) != bmpHigh.GetPixel(x, y)) + differentPixels++; + } + } + + // The outputs MUST differ - this proves FilterQuality actually affects rendering + Assert.True(differentPixels > 0, + "FilterQuality.None and FilterQuality.High should produce different pixel output when downscaling"); + } + + [Theory] + [InlineData(SKFilterQuality.None)] + [InlineData(SKFilterQuality.Low)] + [InlineData(SKFilterQuality.Medium)] + [InlineData(SKFilterQuality.High)] + public void AllFilterQualitiesProduceCorrectlySizedOutput(SKFilterQuality quality) + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.png"; + info.BaseSize = new SKSize(256, 256); + info.FilterQuality = quality; + var tools = new SkiaSharpRasterTools(info, Logger); + var dpiPath = new DpiPath("", 1); + + tools.Resize(dpiPath, DestinationFilename); + + using var resultImage = SKBitmap.Decode(DestinationFilename); + Assert.Equal(256, resultImage.Width); + Assert.Equal(256, resultImage.Height); + } } #pragma warning restore CS0618 // Type or member is obsolete } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs index 00f5d296fdbc..c8877636365d 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs @@ -338,17 +338,20 @@ public void SvgImageWithDecodingIssue_12109() public class FilterQualityTests : IDisposable { readonly string DestinationFilename; + readonly string DestinationFilename2; readonly TestLogger Logger; public FilterQualityTests() { DestinationFilename = Path.GetTempFileName(); + DestinationFilename2 = Path.GetTempFileName(); Logger = new TestLogger(); } public void Dispose() { File.Delete(DestinationFilename); + File.Delete(DestinationFilename2); } [Fact] @@ -391,6 +394,58 @@ public void ResizeWithNoneFilterQualityProducesValidImage() Assert.True(resultImage.Width > 0); Assert.True(resultImage.Height > 0); } + + [Fact] + public void DefaultFilterQualityProducesIdenticalOutputToHardcodedHigh() + { + var dpiPath = new DpiPath("", 1); + + var infoDefault = new ResizeImageInfo(); + infoDefault.Filename = "images/camera.svg"; + var toolsDefault = new SkiaSharpSvgTools(infoDefault, Logger); + toolsDefault.Resize(dpiPath, DestinationFilename); + + var infoHigh = new ResizeImageInfo(); + infoHigh.Filename = "images/camera.svg"; + infoHigh.FilterQuality = SKFilterQuality.High; + var toolsHigh = new SkiaSharpSvgTools(infoHigh, Logger); + toolsHigh.Resize(dpiPath, DestinationFilename2); + + using var bmpDefault = SKBitmap.Decode(DestinationFilename); + using var bmpHigh = SKBitmap.Decode(DestinationFilename2); + + Assert.Equal(bmpDefault.Width, bmpHigh.Width); + Assert.Equal(bmpDefault.Height, bmpHigh.Height); + + for (int y = 0; y < bmpDefault.Height; y++) + { + for (int x = 0; x < bmpDefault.Width; x++) + { + Assert.Equal(bmpDefault.GetPixel(x, y), bmpHigh.GetPixel(x, y)); + } + } + } + + [Theory] + [InlineData(SKFilterQuality.None)] + [InlineData(SKFilterQuality.Low)] + [InlineData(SKFilterQuality.Medium)] + [InlineData(SKFilterQuality.High)] + public void AllFilterQualitiesProduceCorrectlySizedOutput(SKFilterQuality quality) + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.svg"; + info.BaseSize = new SKSize(256, 256); + info.FilterQuality = quality; + var tools = new SkiaSharpSvgTools(info, Logger); + var dpiPath = new DpiPath("", 1); + + tools.Resize(dpiPath, DestinationFilename); + + using var resultImage = SKBitmap.Decode(DestinationFilename); + Assert.Equal(256, resultImage.Width); + Assert.Equal(256, resultImage.Height); + } } #pragma warning restore CS0618 // Type or member is obsolete } From 83e0abba652f4736683481f90c4da20e147e5f35 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 19 Mar 2026 10:18:05 +0100 Subject: [PATCH 03/28] Replace SKFilterQuality with custom ResizeQuality enum Per mattleibow's review on PR #25686, replace the obsolete SKFilterQuality enum with a clean MAUI-specific ResizeQuality enum that hides SkiaSharp internals: - Auto: Default behavior (bilinear + mipmaps, preserves backward compat) - Best: Mitchell cubic resampler (highest quality) - Fastest: Nearest neighbor (smallest file size, fastest processing) MSBuild metadata name changed from FilterQuality to ResizeQuality. All 25 new tests pass. 593 total passed (568 baseline + 25 new). Same 20 pre-existing failures. Zero regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/AndroidAdaptiveIconGenerator.cs | 4 +- .../Resizetizer/src/ResizeImageInfo.cs | 14 +-- .../Resizetizer/src/ResizeQuality.cs | 27 +++++ src/SingleProject/Resizetizer/src/Resizer.cs | 2 +- .../Resizetizer/src/SkiaSharpAppIconTools.cs | 4 +- .../src/SkiaSharpImaginaryTools.cs | 2 +- .../Resizetizer/src/SkiaSharpRasterTools.cs | 8 +- .../Resizetizer/src/SkiaSharpSvgTools.cs | 8 +- .../Resizetizer/src/SkiaSharpTools.cs | 33 ++--- .../test/UnitTests/ResizeImageInfoTests.cs | 52 ++++---- .../test/UnitTests/ResizetizeImagesTests.cs | 88 +++++++------- .../UnitTests/SkiaSharpRasterToolsTests.cs | 114 ++++++++---------- .../test/UnitTests/SkiaSharpSvgToolsTests.cs | 61 ++++------ 13 files changed, 191 insertions(+), 226 deletions(-) create mode 100644 src/SingleProject/Resizetizer/src/ResizeQuality.cs diff --git a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs index 52e84aecfea3..dd2f23f92eb1 100644 --- a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs +++ b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs @@ -92,7 +92,7 @@ void ProcessBackground(List results, DirectoryInfo fullInterme if (backgroundExists) { // resize the background - var tools = SkiaSharpTools.Create(Info.IsVector, Info.Filename, dpi.Size, Info.Color, null, Info.FilterQuality, Logger); + var tools = SkiaSharpTools.Create(Info.IsVector, Info.Filename, dpi.Size, Info.Color, null, Info.Quality, Logger); tools.Resize(dpi, destination, dpiSizeIsAbsolute: true); } else @@ -136,7 +136,7 @@ void ProcessForeground(List results, DirectoryInfo fullInterme if (foregroundExists) { // resize the forground - var tools = SkiaSharpTools.Create(Info.ForegroundIsVector, Info.ForegroundFilename, dpi.Size, null, Info.TintColor, Info.FilterQuality, Logger); + var tools = SkiaSharpTools.Create(Info.ForegroundIsVector, Info.ForegroundFilename, dpi.Size, null, Info.TintColor, Info.Quality, Logger); tools.Resize(dpi, destination, Info.ForegroundScale, dpiSizeIsAbsolute: true); } else diff --git a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs index 23adeeec5892..35ebbb181418 100644 --- a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs +++ b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs @@ -10,9 +10,7 @@ namespace Microsoft.Maui.Resizetizer { internal class ResizeImageInfo { -#pragma warning disable CS0618 // Type or member is obsolete - public static readonly SKFilterQuality DefaultFilterQuality = SKFilterQuality.High; -#pragma warning restore CS0618 // Type or member is obsolete + public const ResizeQuality DefaultResizeQuality = ResizeQuality.Auto; public string? ItemSpec { get; set; } @@ -58,9 +56,7 @@ internal class ResizeImageInfo public bool MonochromeIsVector => IsVectorFilename(MonochromeFilename); -#pragma warning disable CS0618 // Type or member is obsolete - public SKFilterQuality FilterQuality { get; set; } = DefaultFilterQuality; -#pragma warning restore CS0618 // Type or member is obsolete + public ResizeQuality Quality { get; set; } = DefaultResizeQuality; private static bool IsVectorFilename(string? filename) => IsVectorExtension(Path.GetExtension(filename)); @@ -126,10 +122,8 @@ public static ResizeImageInfo Parse(ITaskItem image) if (float.TryParse(image.GetMetadata("ForegroundScale"), NumberStyles.Number, CultureInfo.InvariantCulture, out var fsc)) info.ForegroundScale = fsc; -#pragma warning disable CS0618 // Type or member is obsolete - if (Enum.TryParse(image.GetMetadata("FilterQuality"), out var filterQuality)) - info.FilterQuality = filterQuality; -#pragma warning restore CS0618 // Type or member is obsolete + if (Enum.TryParse(image.GetMetadata("ResizeQuality"), out var quality)) + info.Quality = quality; var fgFile = image.GetMetadata("ForegroundFile"); if (!string.IsNullOrEmpty(fgFile)) diff --git a/src/SingleProject/Resizetizer/src/ResizeQuality.cs b/src/SingleProject/Resizetizer/src/ResizeQuality.cs new file mode 100644 index 000000000000..0504152e2054 --- /dev/null +++ b/src/SingleProject/Resizetizer/src/ResizeQuality.cs @@ -0,0 +1,27 @@ +namespace Microsoft.Maui.Resizetizer +{ + /// + /// Controls the quality of image resampling during resize operations. + /// + internal enum ResizeQuality + { + /// + /// Default behavior, preserving existing image output. + /// Uses bilinear interpolation with mipmaps. + /// + Auto = 0, + + /// + /// Highest quality output using Mitchell cubic resampler. + /// Best for upscaling or when visual fidelity is critical. + /// + Best = 1, + + /// + /// Fastest processing with nearest-neighbor interpolation. + /// Produces smaller file sizes but may appear pixelated when scaling. + /// Ideal for pixel art or when file size matters more than quality. + /// + Fastest = 2, + } +} diff --git a/src/SingleProject/Resizetizer/src/Resizer.cs b/src/SingleProject/Resizetizer/src/Resizer.cs index cca042f9b92e..d26e1d02ca59 100644 --- a/src/SingleProject/Resizetizer/src/Resizer.cs +++ b/src/SingleProject/Resizetizer/src/Resizer.cs @@ -25,7 +25,7 @@ public Resizer(ResizeImageInfo info, string intermediateOutputPath, ILogger logg public SKSize? BaseSize => Info.BaseSize; protected SkiaSharpTools Tools => - tools ??= SkiaSharpTools.Create(Info.IsVector, Info.Filename, Info.BaseSize, Info.Color, Info.TintColor, Info.FilterQuality, Logger); + tools ??= SkiaSharpTools.Create(Info.IsVector, Info.Filename, Info.BaseSize, Info.Color, Info.TintColor, Info.Quality, Logger); public string GetRasterFileDestination(DpiPath dpi, bool includeIntermediate = true, bool includeScale = true) => GetRasterFileDestination(Info, dpi, includeIntermediate ? IntermediateOutputPath : null, includeScale); diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpAppIconTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpAppIconTools.cs index fbf5fe013f18..ac41f3116776 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpAppIconTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpAppIconTools.cs @@ -21,9 +21,9 @@ public SkiaSharpAppIconTools(ResizeImageInfo info, ILogger? logger) throw new InvalidOperationException("An app icon needs at least one image."); if (hasBackground) - backgroundTools = SkiaSharpTools.Create(info.IsVector, info.Filename, null, null, null, info.FilterQuality, logger); + backgroundTools = SkiaSharpTools.Create(info.IsVector, info.Filename, null, null, null, info.Quality, logger); if (hasForeground) - foregroundTools = SkiaSharpTools.Create(info.ForegroundIsVector, info.ForegroundFilename, null, null, info.TintColor, info.FilterQuality, logger); + foregroundTools = SkiaSharpTools.Create(info.ForegroundIsVector, info.ForegroundFilename, null, null, info.TintColor, info.Quality, logger); } SkiaSharpTools? backgroundTools; diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpImaginaryTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpImaginaryTools.cs index 09e5bd4ffbcc..8d605df7b91d 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpImaginaryTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpImaginaryTools.cs @@ -11,7 +11,7 @@ public SkiaSharpImaginaryTools(ResizeImageInfo info, ILogger logger) } public SkiaSharpImaginaryTools(SKColor? backgroundColor, ILogger logger) - : base(null, new SKSize(1, 1), backgroundColor, null, ResizeImageInfo.DefaultFilterQuality, logger) + : base(null, new SKSize(1, 1), backgroundColor, null, ResizeImageInfo.DefaultResizeQuality, logger) { } diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpRasterTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpRasterTools.cs index 40972b42deae..424a9386f665 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpRasterTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpRasterTools.cs @@ -9,14 +9,12 @@ internal class SkiaSharpRasterTools : SkiaSharpTools, IDisposable SKImage img; public SkiaSharpRasterTools(ResizeImageInfo info, ILogger logger) - : this(info.Filename, info.BaseSize, info.Color, info.TintColor, info.FilterQuality, logger) + : this(info.Filename, info.BaseSize, info.Color, info.TintColor, info.Quality, logger) { } -#pragma warning disable CS0618 // Type or member is obsolete - public SkiaSharpRasterTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, SKFilterQuality filterQuality, ILogger logger) -#pragma warning restore CS0618 // Type or member is obsolete - : base(filename, baseSize, backgroundColor, tintColor, filterQuality, logger) + public SkiaSharpRasterTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, ResizeQuality quality, ILogger logger) + : base(filename, baseSize, backgroundColor, tintColor, quality, logger) { var sw = new Stopwatch(); sw.Start(); diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs index 49d2ea4ed1e3..a466cdd923ab 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs @@ -10,14 +10,12 @@ internal class SkiaSharpSvgTools : SkiaSharpTools, IDisposable SKSvg svg; public SkiaSharpSvgTools(ResizeImageInfo info, ILogger logger) - : this(info.Filename, info.BaseSize, info.Color, info.TintColor, info.FilterQuality, logger) + : this(info.Filename, info.BaseSize, info.Color, info.TintColor, info.Quality, logger) { } -#pragma warning disable CS0618 // Type or member is obsolete - public SkiaSharpSvgTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, SKFilterQuality filterQuality, ILogger logger) -#pragma warning restore CS0618 // Type or member is obsolete - : base(filename, baseSize, backgroundColor, tintColor, filterQuality, logger) + public SkiaSharpSvgTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, ResizeQuality quality, ILogger logger) + : base(filename, baseSize, backgroundColor, tintColor, quality, logger) { var sw = new Stopwatch(); sw.Start(); diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs index c732c13508c9..fec7b466ff95 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs @@ -61,24 +61,20 @@ static SkiaSharpTools() span[0] = new SKPoint(); } -#pragma warning disable CS0618 // Type or member is obsolete - public static SkiaSharpTools Create(bool isVector, string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, SKFilterQuality filterQuality, ILogger logger) -#pragma warning restore CS0618 // Type or member is obsolete + public static SkiaSharpTools Create(bool isVector, string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, ResizeQuality quality, ILogger logger) => isVector - ? new SkiaSharpSvgTools(filename, baseSize, backgroundColor, tintColor, filterQuality, logger) as SkiaSharpTools - : new SkiaSharpRasterTools(filename, baseSize, backgroundColor, tintColor, filterQuality, logger); + ? new SkiaSharpSvgTools(filename, baseSize, backgroundColor, tintColor, quality, logger) as SkiaSharpTools + : new SkiaSharpRasterTools(filename, baseSize, backgroundColor, tintColor, quality, logger); public static SkiaSharpTools CreateImaginary(SKColor? backgroundColor, ILogger logger) => new SkiaSharpImaginaryTools(backgroundColor, logger); public SkiaSharpTools(ResizeImageInfo info, ILogger logger) - : this(info.Filename, info.BaseSize, info.Color, info.TintColor, info.FilterQuality, logger) + : this(info.Filename, info.BaseSize, info.Color, info.TintColor, info.Quality, logger) { } -#pragma warning disable CS0618 // Type or member is obsolete - public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, SKFilterQuality filterQuality, ILogger logger) -#pragma warning restore CS0618 // Type or member is obsolete + public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColor, SKColor? tintColor, ResizeQuality quality, ILogger logger) { Logger = logger; Filename = filename; @@ -87,9 +83,6 @@ public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColo Paint = new SKPaint { IsAntialias = true, -#pragma warning disable CS0618 // Type or member is obsolete - FilterQuality = filterQuality, -#pragma warning restore CS0618 // Type or member is obsolete }; if (tintColor is SKColor tint) @@ -98,19 +91,15 @@ public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColo Paint.ColorFilter = SKColorFilter.CreateBlendMode(tint, SKBlendMode.SrcIn); } - // Map FilterQuality to SamplingOptions (SKFilterQuality on Paint is deprecated - // and ignored by newer SkiaSharp; SamplingOptions controls actual resampling). - // Default (High) preserves the original bilinear+mipmaps behavior. -#pragma warning disable CS0618 // Type or member is obsolete - SamplingOptions = filterQuality switch + // Map ResizeQuality to SKSamplingOptions which controls actual resampling. + // Auto preserves the original bilinear+mipmaps behavior for backward compatibility. + SamplingOptions = quality switch { - SKFilterQuality.None => new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None), - SKFilterQuality.Low => new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.None), - SKFilterQuality.Medium => new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), - // High and default preserve the original hardcoded behavior + ResizeQuality.Best => new SKSamplingOptions(SKCubicResampler.Mitchell), + ResizeQuality.Fastest => new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None), + // Auto and default preserve the original hardcoded behavior _ => new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), }; -#pragma warning restore CS0618 // Type or member is obsolete } public string Filename { get; } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs index 071e871b43d4..99c504a913b2 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs @@ -1,7 +1,7 @@ +using System; using System.Collections.Generic; using System.IO; using Microsoft.Build.Utilities; -using SkiaSharp; using Xunit; namespace Microsoft.Maui.Resizetizer.Tests @@ -60,77 +60,75 @@ public void DoesNotCrashOnNullOrEmpty(string filename) } } -#pragma warning disable CS0618 // Type or member is obsolete - public class FilterQualityTests + public class ResizeQualityTests { [Fact] - public void DefaultFilterQualityIsHigh() + public void DefaultQualityIsAuto() { var info = new ResizeImageInfo(); - Assert.Equal(SKFilterQuality.High, info.FilterQuality); + Assert.Equal(ResizeQuality.Auto, info.Quality); } [Fact] - public void DefaultFilterQualityConstantIsHigh() + public void DefaultQualityConstantIsAuto() { - Assert.Equal(SKFilterQuality.High, ResizeImageInfo.DefaultFilterQuality); + Assert.Equal(ResizeQuality.Auto, ResizeImageInfo.DefaultResizeQuality); } [Theory] - [InlineData(SKFilterQuality.None)] - [InlineData(SKFilterQuality.Low)] - [InlineData(SKFilterQuality.Medium)] - [InlineData(SKFilterQuality.High)] - public void FilterQualityCanBeSet(SKFilterQuality quality) + [InlineData("Auto")] + [InlineData("Best")] + [InlineData("Fastest")] + public void QualityCanBeSet(string qualityName) { + var quality = Enum.Parse(qualityName); var info = new ResizeImageInfo { - FilterQuality = quality + Quality = quality }; - Assert.Equal(quality, info.FilterQuality); + Assert.Equal(quality, info.Quality); } [Theory] - [InlineData("None", SKFilterQuality.None)] - [InlineData("Low", SKFilterQuality.Low)] - [InlineData("Medium", SKFilterQuality.Medium)] - [InlineData("High", SKFilterQuality.High)] - public void FilterQualityParsedFromTaskItem(string metadataValue, SKFilterQuality expected) + [InlineData("Auto")] + [InlineData("Best")] + [InlineData("Fastest")] + public void QualityParsedFromTaskItem(string metadataValue) { + var expected = Enum.Parse(metadataValue); var path = Path.GetFullPath("images/camera.png"); var item = new TaskItem(path, new Dictionary { - ["FilterQuality"] = metadataValue + ["ResizeQuality"] = metadataValue }); var info = ResizeImageInfo.Parse(item); - Assert.Equal(expected, info.FilterQuality); + Assert.Equal(expected, info.Quality); } [Fact] - public void FilterQualityDefaultsToHighWhenNotSpecified() + public void QualityDefaultsToAutoWhenNotSpecified() { var path = Path.GetFullPath("images/camera.png"); var item = new TaskItem(path); var info = ResizeImageInfo.Parse(item); - Assert.Equal(SKFilterQuality.High, info.FilterQuality); + Assert.Equal(ResizeQuality.Auto, info.Quality); } [Fact] - public void FilterQualityDefaultsToHighForInvalidValue() + public void QualityDefaultsToAutoForInvalidValue() { var path = Path.GetFullPath("images/camera.png"); var item = new TaskItem(path, new Dictionary { - ["FilterQuality"] = "InvalidValue" + ["ResizeQuality"] = "InvalidValue" }); var info = ResizeImageInfo.Parse(item); - Assert.Equal(SKFilterQuality.High, info.FilterQuality); + Assert.Equal(ResizeQuality.Auto, info.Quality); } } -#pragma warning restore CS0618 // Type or member is obsolete } } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs index 0692ac25fd59..963476f78f0d 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs @@ -508,67 +508,66 @@ public void SingleImageWithBaseSizeSucceeds(string alias, string outputName) AssertFileSize($"drawable-xhdpi/{outputName}.png", 88, 88); } -#pragma warning disable CS0618 // Type or member is obsolete [Fact] - public void FilterQualityMetadataIsRespectedEndToEnd() + public void ResizeQualityMetadataIsRespectedEndToEnd() { - // Run the full MSBuild task pipeline with FilterQuality=None - var itemsNone = new[] + // Run the full MSBuild task pipeline with ResizeQuality=Fastest + var itemsFastest = new[] { new TaskItem("images/camera.png", new Dictionary { ["BaseSize"] = "100", - ["Link"] = "camera_none", - ["FilterQuality"] = "None", + ["Link"] = "camera_fastest", + ["ResizeQuality"] = "Fastest", }), }; - var taskNone = GetNewTask(itemsNone); - var successNone = taskNone.Execute(); - Assert.True(successNone, LogErrorEvents.FirstOrDefault()?.Message); - AssertFileSize("drawable-mdpi/camera_none.png", 100, 100); + var taskFastest = GetNewTask(itemsFastest); + var successFastest = taskFastest.Execute(); + Assert.True(successFastest, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileSize("drawable-mdpi/camera_fastest.png", 100, 100); - // Save the None output pixels - var noneFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_none.png"); - using var bmpNone = SKBitmap.Decode(noneFile); - var nonePixels = bmpNone.Pixels.ToArray(); + // Save the Fastest output pixels + var fastestFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_fastest.png"); + using var bmpFastest = SKBitmap.Decode(fastestFile); + var fastestPixels = bmpFastest.Pixels.ToArray(); - // Run again with FilterQuality=High - var itemsHigh = new[] + // Run again with ResizeQuality=Auto (default) + var itemsAuto = new[] { new TaskItem("images/camera.png", new Dictionary { ["BaseSize"] = "100", - ["Link"] = "camera_high", - ["FilterQuality"] = "High", + ["Link"] = "camera_auto", + ["ResizeQuality"] = "Auto", }), }; - var taskHigh = GetNewTask(itemsHigh); - var successHigh = taskHigh.Execute(); - Assert.True(successHigh, LogErrorEvents.FirstOrDefault()?.Message); - AssertFileSize("drawable-mdpi/camera_high.png", 100, 100); + var taskAuto = GetNewTask(itemsAuto); + var successAuto = taskAuto.Execute(); + Assert.True(successAuto, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileSize("drawable-mdpi/camera_auto.png", 100, 100); - // Compare: None vs High should differ when downscaling - var highFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_high.png"); - using var bmpHigh = SKBitmap.Decode(highFile); - var highPixels = bmpHigh.Pixels.ToArray(); + // Compare: Fastest vs Auto should differ when downscaling + var autoFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_auto.png"); + using var bmpAuto = SKBitmap.Decode(autoFile); + var autoPixels = bmpAuto.Pixels.ToArray(); int differentPixels = 0; - for (int i = 0; i < nonePixels.Length; i++) + for (int i = 0; i < fastestPixels.Length; i++) { - if (nonePixels[i] != highPixels[i]) + if (fastestPixels[i] != autoPixels[i]) differentPixels++; } Assert.True(differentPixels > 0, - "End-to-end: FilterQuality.None and FilterQuality.High must produce different pixel output"); + "End-to-end: Fastest and Auto must produce different pixel output"); } [Fact] - public void DefaultFilterQualityPreservesBaselineBehavior() + public void DefaultResizeQualityPreservesBaselineBehavior() { - // No FilterQuality metadata = default (High) behavior + // No ResizeQuality metadata = default (Auto) behavior var itemsDefault = new[] { new TaskItem("images/camera.png", new Dictionary @@ -587,33 +586,32 @@ public void DefaultFilterQualityPreservesBaselineBehavior() using var bmpDefault = SKBitmap.Decode(defaultFile); var defaultPixels = bmpDefault.Pixels.ToArray(); - // Explicit High should produce identical results - var itemsHigh = new[] + // Explicit Auto should produce identical results + var itemsAuto = new[] { new TaskItem("images/camera.png", new Dictionary { ["BaseSize"] = "100", - ["Link"] = "camera_explicit_high", - ["FilterQuality"] = "High", + ["Link"] = "camera_explicit_auto", + ["ResizeQuality"] = "Auto", }), }; - var taskHigh = GetNewTask(itemsHigh); - var successHigh = taskHigh.Execute(); - Assert.True(successHigh, LogErrorEvents.FirstOrDefault()?.Message); + var taskAuto = GetNewTask(itemsAuto); + var successAuto = taskAuto.Execute(); + Assert.True(successAuto, LogErrorEvents.FirstOrDefault()?.Message); - var highFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_explicit_high.png"); - using var bmpHigh = SKBitmap.Decode(highFile); - var highPixels = bmpHigh.Pixels.ToArray(); + var autoFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_explicit_auto.png"); + using var bmpAuto = SKBitmap.Decode(autoFile); + var autoPixels = bmpAuto.Pixels.ToArray(); - // Every single pixel must match: default == explicit High - Assert.Equal(defaultPixels.Length, highPixels.Length); + // Every single pixel must match: default == explicit Auto + Assert.Equal(defaultPixels.Length, autoPixels.Length); for (int i = 0; i < defaultPixels.Length; i++) { - Assert.Equal(defaultPixels[i], highPixels[i]); + Assert.Equal(defaultPixels[i], autoPixels[i]); } } -#pragma warning restore CS0618 // Type or member is obsolete [Theory] [InlineData("camera", null, "camera")] diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs index fc93e951ac78..cae0ef76c34d 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs @@ -253,14 +253,13 @@ public void ColorizedWithAlphaWithColorsReplacesColors() } } -#pragma warning disable CS0618 // Type or member is obsolete - public class FilterQualityTests : IDisposable + public class ResizeQualityTests : IDisposable { readonly string DestinationFilename; readonly string DestinationFilename2; readonly TestLogger Logger; - public FilterQualityTests() + public ResizeQualityTests() { DestinationFilename = Path.GetTempFileName(); DestinationFilename2 = Path.GetTempFileName(); @@ -274,36 +273,21 @@ public void Dispose() } [Fact] - public void DefaultFilterQualityIsHigh() + public void DefaultQualityIsAuto() { var info = new ResizeImageInfo(); info.Filename = "images/camera.png"; var tools = new SkiaSharpRasterTools(info, Logger); - Assert.Equal(SKFilterQuality.High, tools.Paint.FilterQuality); - } - - [Theory] - [InlineData(SKFilterQuality.None)] - [InlineData(SKFilterQuality.Low)] - [InlineData(SKFilterQuality.Medium)] - [InlineData(SKFilterQuality.High)] - public void FilterQualityIsAppliedFromInfo(SKFilterQuality quality) - { - var info = new ResizeImageInfo(); - info.Filename = "images/camera.png"; - info.FilterQuality = quality; - var tools = new SkiaSharpRasterTools(info, Logger); - - Assert.Equal(quality, tools.Paint.FilterQuality); + Assert.True(tools.Paint.IsAntialias); } [Fact] - public void ResizeWithNoneFilterQualityProducesValidImage() + public void ResizeWithFastestQualityProducesValidImage() { var info = new ResizeImageInfo(); info.Filename = "images/camera.png"; - info.FilterQuality = SKFilterQuality.None; + info.Quality = ResizeQuality.Fastest; info.BaseSize = new SKSize(100, 100); var tools = new SkiaSharpRasterTools(info, Logger); var dpiPath = new DpiPath("", 1); @@ -316,9 +300,9 @@ public void ResizeWithNoneFilterQualityProducesValidImage() } [Fact] - public void DefaultFilterQualityProducesIdenticalOutputToHardcodedHigh() + public void DefaultQualityProducesIdenticalOutputToExplicitAuto() { - // Resize with default (should be High) + // Resize with default (no Quality set) var infoDefault = new ResizeImageInfo(); infoDefault.Filename = "images/camera.png"; infoDefault.BaseSize = new SKSize(200, 200); @@ -326,85 +310,82 @@ public void DefaultFilterQualityProducesIdenticalOutputToHardcodedHigh() var dpiPath = new DpiPath("", 1); toolsDefault.Resize(dpiPath, DestinationFilename); - // Resize with explicit High - var infoHigh = new ResizeImageInfo(); - infoHigh.Filename = "images/camera.png"; - infoHigh.BaseSize = new SKSize(200, 200); - infoHigh.FilterQuality = SKFilterQuality.High; - var toolsHigh = new SkiaSharpRasterTools(infoHigh, Logger); - toolsHigh.Resize(dpiPath, DestinationFilename2); + // Resize with explicit Auto + var infoAuto = new ResizeImageInfo(); + infoAuto.Filename = "images/camera.png"; + infoAuto.BaseSize = new SKSize(200, 200); + infoAuto.Quality = ResizeQuality.Auto; + var toolsAuto = new SkiaSharpRasterTools(infoAuto, Logger); + toolsAuto.Resize(dpiPath, DestinationFilename2); // Pixel-by-pixel comparison: must be identical using var bmpDefault = SKBitmap.Decode(DestinationFilename); - using var bmpHigh = SKBitmap.Decode(DestinationFilename2); + using var bmpAuto = SKBitmap.Decode(DestinationFilename2); - Assert.Equal(bmpDefault.Width, bmpHigh.Width); - Assert.Equal(bmpDefault.Height, bmpHigh.Height); + Assert.Equal(bmpDefault.Width, bmpAuto.Width); + Assert.Equal(bmpDefault.Height, bmpAuto.Height); for (int y = 0; y < bmpDefault.Height; y++) { for (int x = 0; x < bmpDefault.Width; x++) { - Assert.Equal(bmpDefault.GetPixel(x, y), bmpHigh.GetPixel(x, y)); + Assert.Equal(bmpDefault.GetPixel(x, y), bmpAuto.GetPixel(x, y)); } } } [Fact] - public void DifferentFilterQualitiesProduceDifferentPixelOutput() + public void DifferentQualitiesProduceDifferentPixelOutput() { - // When downscaling a large image, None (nearest neighbor) vs High (bicubic) + // When downscaling, Fastest (nearest neighbor) vs Auto (bilinear+mipmaps) // should produce measurably different pixel data var dpiPath = new DpiPath("", 1); - var infoNone = new ResizeImageInfo(); - infoNone.Filename = "images/camera.png"; - infoNone.BaseSize = new SKSize(100, 100); - infoNone.FilterQuality = SKFilterQuality.None; - var toolsNone = new SkiaSharpRasterTools(infoNone, Logger); - toolsNone.Resize(dpiPath, DestinationFilename); + var infoFastest = new ResizeImageInfo(); + infoFastest.Filename = "images/camera.png"; + infoFastest.BaseSize = new SKSize(100, 100); + infoFastest.Quality = ResizeQuality.Fastest; + var toolsFastest = new SkiaSharpRasterTools(infoFastest, Logger); + toolsFastest.Resize(dpiPath, DestinationFilename); - var infoHigh = new ResizeImageInfo(); - infoHigh.Filename = "images/camera.png"; - infoHigh.BaseSize = new SKSize(100, 100); - infoHigh.FilterQuality = SKFilterQuality.High; - var toolsHigh = new SkiaSharpRasterTools(infoHigh, Logger); - toolsHigh.Resize(dpiPath, DestinationFilename2); + var infoAuto = new ResizeImageInfo(); + infoAuto.Filename = "images/camera.png"; + infoAuto.BaseSize = new SKSize(100, 100); + infoAuto.Quality = ResizeQuality.Auto; + var toolsAuto = new SkiaSharpRasterTools(infoAuto, Logger); + toolsAuto.Resize(dpiPath, DestinationFilename2); - using var bmpNone = SKBitmap.Decode(DestinationFilename); - using var bmpHigh = SKBitmap.Decode(DestinationFilename2); + using var bmpFastest = SKBitmap.Decode(DestinationFilename); + using var bmpAuto = SKBitmap.Decode(DestinationFilename2); - // Same dimensions - Assert.Equal(bmpNone.Width, bmpHigh.Width); - Assert.Equal(bmpNone.Height, bmpHigh.Height); + Assert.Equal(bmpFastest.Width, bmpAuto.Width); + Assert.Equal(bmpFastest.Height, bmpAuto.Height); - // Count pixels that differ between None and High quality int differentPixels = 0; - for (int y = 0; y < bmpNone.Height; y++) + for (int y = 0; y < bmpFastest.Height; y++) { - for (int x = 0; x < bmpNone.Width; x++) + for (int x = 0; x < bmpFastest.Width; x++) { - if (bmpNone.GetPixel(x, y) != bmpHigh.GetPixel(x, y)) + if (bmpFastest.GetPixel(x, y) != bmpAuto.GetPixel(x, y)) differentPixels++; } } - // The outputs MUST differ - this proves FilterQuality actually affects rendering Assert.True(differentPixels > 0, - "FilterQuality.None and FilterQuality.High should produce different pixel output when downscaling"); + "Fastest and Auto should produce different pixel output when downscaling"); } [Theory] - [InlineData(SKFilterQuality.None)] - [InlineData(SKFilterQuality.Low)] - [InlineData(SKFilterQuality.Medium)] - [InlineData(SKFilterQuality.High)] - public void AllFilterQualitiesProduceCorrectlySizedOutput(SKFilterQuality quality) + [InlineData("Auto")] + [InlineData("Best")] + [InlineData("Fastest")] + public void AllQualitiesProduceCorrectlySizedOutput(string qualityName) { + var quality = Enum.Parse(qualityName); var info = new ResizeImageInfo(); info.Filename = "images/camera.png"; info.BaseSize = new SKSize(256, 256); - info.FilterQuality = quality; + info.Quality = quality; var tools = new SkiaSharpRasterTools(info, Logger); var dpiPath = new DpiPath("", 1); @@ -415,6 +396,5 @@ public void AllFilterQualitiesProduceCorrectlySizedOutput(SKFilterQuality qualit Assert.Equal(256, resultImage.Height); } } -#pragma warning restore CS0618 // Type or member is obsolete } } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs index c8877636365d..b32c0bfce7e9 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs @@ -334,14 +334,13 @@ public void SvgImageWithDecodingIssue_12109() } } -#pragma warning disable CS0618 // Type or member is obsolete - public class FilterQualityTests : IDisposable + public class ResizeQualityTests : IDisposable { readonly string DestinationFilename; readonly string DestinationFilename2; readonly TestLogger Logger; - public FilterQualityTests() + public ResizeQualityTests() { DestinationFilename = Path.GetTempFileName(); DestinationFilename2 = Path.GetTempFileName(); @@ -355,36 +354,21 @@ public void Dispose() } [Fact] - public void DefaultFilterQualityIsHigh() + public void DefaultQualityIsAuto() { var info = new ResizeImageInfo(); info.Filename = "images/camera.svg"; var tools = new SkiaSharpSvgTools(info, Logger); - Assert.Equal(SKFilterQuality.High, tools.Paint.FilterQuality); - } - - [Theory] - [InlineData(SKFilterQuality.None)] - [InlineData(SKFilterQuality.Low)] - [InlineData(SKFilterQuality.Medium)] - [InlineData(SKFilterQuality.High)] - public void FilterQualityIsAppliedFromInfo(SKFilterQuality quality) - { - var info = new ResizeImageInfo(); - info.Filename = "images/camera.svg"; - info.FilterQuality = quality; - var tools = new SkiaSharpSvgTools(info, Logger); - - Assert.Equal(quality, tools.Paint.FilterQuality); + Assert.True(tools.Paint.IsAntialias); } [Fact] - public void ResizeWithNoneFilterQualityProducesValidImage() + public void ResizeWithFastestQualityProducesValidImage() { var info = new ResizeImageInfo(); info.Filename = "images/camera.svg"; - info.FilterQuality = SKFilterQuality.None; + info.Quality = ResizeQuality.Fastest; var tools = new SkiaSharpSvgTools(info, Logger); var dpiPath = new DpiPath("", 1); @@ -396,7 +380,7 @@ public void ResizeWithNoneFilterQualityProducesValidImage() } [Fact] - public void DefaultFilterQualityProducesIdenticalOutputToHardcodedHigh() + public void DefaultQualityProducesIdenticalOutputToExplicitAuto() { var dpiPath = new DpiPath("", 1); @@ -405,38 +389,38 @@ public void DefaultFilterQualityProducesIdenticalOutputToHardcodedHigh() var toolsDefault = new SkiaSharpSvgTools(infoDefault, Logger); toolsDefault.Resize(dpiPath, DestinationFilename); - var infoHigh = new ResizeImageInfo(); - infoHigh.Filename = "images/camera.svg"; - infoHigh.FilterQuality = SKFilterQuality.High; - var toolsHigh = new SkiaSharpSvgTools(infoHigh, Logger); - toolsHigh.Resize(dpiPath, DestinationFilename2); + var infoAuto = new ResizeImageInfo(); + infoAuto.Filename = "images/camera.svg"; + infoAuto.Quality = ResizeQuality.Auto; + var toolsAuto = new SkiaSharpSvgTools(infoAuto, Logger); + toolsAuto.Resize(dpiPath, DestinationFilename2); using var bmpDefault = SKBitmap.Decode(DestinationFilename); - using var bmpHigh = SKBitmap.Decode(DestinationFilename2); + using var bmpAuto = SKBitmap.Decode(DestinationFilename2); - Assert.Equal(bmpDefault.Width, bmpHigh.Width); - Assert.Equal(bmpDefault.Height, bmpHigh.Height); + Assert.Equal(bmpDefault.Width, bmpAuto.Width); + Assert.Equal(bmpDefault.Height, bmpAuto.Height); for (int y = 0; y < bmpDefault.Height; y++) { for (int x = 0; x < bmpDefault.Width; x++) { - Assert.Equal(bmpDefault.GetPixel(x, y), bmpHigh.GetPixel(x, y)); + Assert.Equal(bmpDefault.GetPixel(x, y), bmpAuto.GetPixel(x, y)); } } } [Theory] - [InlineData(SKFilterQuality.None)] - [InlineData(SKFilterQuality.Low)] - [InlineData(SKFilterQuality.Medium)] - [InlineData(SKFilterQuality.High)] - public void AllFilterQualitiesProduceCorrectlySizedOutput(SKFilterQuality quality) + [InlineData("Auto")] + [InlineData("Best")] + [InlineData("Fastest")] + public void AllQualitiesProduceCorrectlySizedOutput(string qualityName) { + var quality = Enum.Parse(qualityName); var info = new ResizeImageInfo(); info.Filename = "images/camera.svg"; info.BaseSize = new SKSize(256, 256); - info.FilterQuality = quality; + info.Quality = quality; var tools = new SkiaSharpSvgTools(info, Logger); var dpiPath = new DpiPath("", 1); @@ -447,6 +431,5 @@ public void AllFilterQualitiesProduceCorrectlySizedOutput(SKFilterQuality qualit Assert.Equal(256, resultImage.Height); } } -#pragma warning restore CS0618 // Type or member is obsolete } } From 41deb7df684f0fc1b59532af0931e8e2ed14147a Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 19 Mar 2026 10:26:07 +0100 Subject: [PATCH 04/28] Address review: case-insensitive parsing + Best quality test - Enum.TryParse now uses ignoreCase: true, matching MSBuild convention for metadata parsing (consistent with Resize, TintColor, etc.) - Added 3 case-insensitive parsing tests (auto, FASTEST, best) - Added Best vs Auto pixel differentiation test proving Mitchell cubic path is exercised and produces different output than bilinear 29 new tests, 597 total passed, 0 regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resizetizer/src/ResizeImageInfo.cs | 2 +- .../test/UnitTests/ResizeImageInfoTests.cs | 17 ++++++++ .../UnitTests/SkiaSharpRasterToolsTests.cs | 40 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs index 35ebbb181418..53796b722e63 100644 --- a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs +++ b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs @@ -122,7 +122,7 @@ public static ResizeImageInfo Parse(ITaskItem image) if (float.TryParse(image.GetMetadata("ForegroundScale"), NumberStyles.Number, CultureInfo.InvariantCulture, out var fsc)) info.ForegroundScale = fsc; - if (Enum.TryParse(image.GetMetadata("ResizeQuality"), out var quality)) + if (Enum.TryParse(image.GetMetadata("ResizeQuality"), ignoreCase: true, out var quality)) info.Quality = quality; var fgFile = image.GetMetadata("ForegroundFile"); diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs index 99c504a913b2..46f50f45370a 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs @@ -129,6 +129,23 @@ public void QualityDefaultsToAutoForInvalidValue() var info = ResizeImageInfo.Parse(item); Assert.Equal(ResizeQuality.Auto, info.Quality); } + + [Theory] + [InlineData("auto")] + [InlineData("FASTEST")] + [InlineData("best")] + public void QualityParsingIsCaseInsensitive(string metadataValue) + { + var expected = Enum.Parse(metadataValue, ignoreCase: true); + var path = Path.GetFullPath("images/camera.png"); + var item = new TaskItem(path, new Dictionary + { + ["ResizeQuality"] = metadataValue + }); + + var info = ResizeImageInfo.Parse(item); + Assert.Equal(expected, info.Quality); + } } } } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs index cae0ef76c34d..6491e2d59a06 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs @@ -375,6 +375,46 @@ public void DifferentQualitiesProduceDifferentPixelOutput() "Fastest and Auto should produce different pixel output when downscaling"); } + [Fact] + public void BestQualityProducesDifferentPixelOutputThanAuto() + { + // Best uses Mitchell cubic vs Auto uses bilinear+mipmaps + var dpiPath = new DpiPath("", 1); + + var infoBest = new ResizeImageInfo(); + infoBest.Filename = "images/camera.png"; + infoBest.BaseSize = new SKSize(100, 100); + infoBest.Quality = ResizeQuality.Best; + var toolsBest = new SkiaSharpRasterTools(infoBest, Logger); + toolsBest.Resize(dpiPath, DestinationFilename); + + var infoAuto = new ResizeImageInfo(); + infoAuto.Filename = "images/camera.png"; + infoAuto.BaseSize = new SKSize(100, 100); + infoAuto.Quality = ResizeQuality.Auto; + var toolsAuto = new SkiaSharpRasterTools(infoAuto, Logger); + toolsAuto.Resize(dpiPath, DestinationFilename2); + + using var bmpBest = SKBitmap.Decode(DestinationFilename); + using var bmpAuto = SKBitmap.Decode(DestinationFilename2); + + Assert.Equal(bmpBest.Width, bmpAuto.Width); + Assert.Equal(bmpBest.Height, bmpAuto.Height); + + int differentPixels = 0; + for (int y = 0; y < bmpBest.Height; y++) + { + for (int x = 0; x < bmpBest.Width; x++) + { + if (bmpBest.GetPixel(x, y) != bmpAuto.GetPixel(x, y)) + differentPixels++; + } + } + + Assert.True(differentPixels > 0, + "Best (Mitchell cubic) and Auto (bilinear) should produce different pixel output"); + } + [Theory] [InlineData("Auto")] [InlineData("Best")] From b2975d5da226fd872b39c3dc5030961c528547dd Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 19 Mar 2026 10:34:01 +0100 Subject: [PATCH 05/28] Fix test gaps: SamplingOptions correctness, SVG pixel diffs, Best smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace meaningless IsAntialias assertions with SamplingOptions correctness checks - Add per-quality SamplingOptions mapping tests (Auto→Linear/Linear, Best→Mitchell, Fastest→Nearest/None) - Add SVG pixel differentiation test (Fastest vs Auto on downscale) - Add Best quality smoke tests for both raster and SVG - Fix SVG tests to use BaseSize for exact dimension assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../UnitTests/SkiaSharpRasterToolsTests.cs | 62 +++++++++++- .../test/UnitTests/SkiaSharpSvgToolsTests.cs | 96 ++++++++++++++++++- 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs index 6491e2d59a06..5ee8ca2a6a56 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs @@ -273,13 +273,54 @@ public void Dispose() } [Fact] - public void DefaultQualityIsAuto() + public void DefaultQualityMapsToLinearMipmapSampling() { var info = new ResizeImageInfo(); info.Filename = "images/camera.png"; var tools = new SkiaSharpRasterTools(info, Logger); - Assert.True(tools.Paint.IsAntialias); + Assert.Equal( + new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), + tools.SamplingOptions); + } + + [Fact] + public void AutoQualityMapsToLinearMipmapSampling() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.png"; + info.Quality = ResizeQuality.Auto; + var tools = new SkiaSharpRasterTools(info, Logger); + + Assert.Equal( + new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), + tools.SamplingOptions); + } + + [Fact] + public void BestQualityMapsToMitchellCubicSampling() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.png"; + info.Quality = ResizeQuality.Best; + var tools = new SkiaSharpRasterTools(info, Logger); + + Assert.Equal( + new SKSamplingOptions(SKCubicResampler.Mitchell), + tools.SamplingOptions); + } + + [Fact] + public void FastestQualityMapsToNearestNeighborSampling() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.png"; + info.Quality = ResizeQuality.Fastest; + var tools = new SkiaSharpRasterTools(info, Logger); + + Assert.Equal( + new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None), + tools.SamplingOptions); } [Fact] @@ -299,6 +340,23 @@ public void ResizeWithFastestQualityProducesValidImage() Assert.Equal(100, resultImage.Height); } + [Fact] + public void ResizeWithBestQualityProducesValidImage() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.png"; + info.Quality = ResizeQuality.Best; + info.BaseSize = new SKSize(100, 100); + var tools = new SkiaSharpRasterTools(info, Logger); + var dpiPath = new DpiPath("", 1); + + tools.Resize(dpiPath, DestinationFilename); + + using var resultImage = SKBitmap.Decode(DestinationFilename); + Assert.Equal(100, resultImage.Width); + Assert.Equal(100, resultImage.Height); + } + [Fact] public void DefaultQualityProducesIdenticalOutputToExplicitAuto() { diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs index b32c0bfce7e9..768f35689f66 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs @@ -354,13 +354,41 @@ public void Dispose() } [Fact] - public void DefaultQualityIsAuto() + public void DefaultQualityMapsToLinearMipmapSampling() { var info = new ResizeImageInfo(); info.Filename = "images/camera.svg"; var tools = new SkiaSharpSvgTools(info, Logger); - Assert.True(tools.Paint.IsAntialias); + Assert.Equal( + new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear), + tools.SamplingOptions); + } + + [Fact] + public void BestQualityMapsToMitchellCubicSampling() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.svg"; + info.Quality = ResizeQuality.Best; + var tools = new SkiaSharpSvgTools(info, Logger); + + Assert.Equal( + new SKSamplingOptions(SKCubicResampler.Mitchell), + tools.SamplingOptions); + } + + [Fact] + public void FastestQualityMapsToNearestNeighborSampling() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.svg"; + info.Quality = ResizeQuality.Fastest; + var tools = new SkiaSharpSvgTools(info, Logger); + + Assert.Equal( + new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None), + tools.SamplingOptions); } [Fact] @@ -369,14 +397,32 @@ public void ResizeWithFastestQualityProducesValidImage() var info = new ResizeImageInfo(); info.Filename = "images/camera.svg"; info.Quality = ResizeQuality.Fastest; + info.BaseSize = new SKSize(100, 100); + var tools = new SkiaSharpSvgTools(info, Logger); + var dpiPath = new DpiPath("", 1); + + tools.Resize(dpiPath, DestinationFilename); + + using var resultImage = SKBitmap.Decode(DestinationFilename); + Assert.Equal(100, resultImage.Width); + Assert.Equal(100, resultImage.Height); + } + + [Fact] + public void ResizeWithBestQualityProducesValidImage() + { + var info = new ResizeImageInfo(); + info.Filename = "images/camera.svg"; + info.Quality = ResizeQuality.Best; + info.BaseSize = new SKSize(100, 100); var tools = new SkiaSharpSvgTools(info, Logger); var dpiPath = new DpiPath("", 1); tools.Resize(dpiPath, DestinationFilename); using var resultImage = SKBitmap.Decode(DestinationFilename); - Assert.True(resultImage.Width > 0); - Assert.True(resultImage.Height > 0); + Assert.Equal(100, resultImage.Width); + Assert.Equal(100, resultImage.Height); } [Fact] @@ -386,11 +432,13 @@ public void DefaultQualityProducesIdenticalOutputToExplicitAuto() var infoDefault = new ResizeImageInfo(); infoDefault.Filename = "images/camera.svg"; + infoDefault.BaseSize = new SKSize(100, 100); var toolsDefault = new SkiaSharpSvgTools(infoDefault, Logger); toolsDefault.Resize(dpiPath, DestinationFilename); var infoAuto = new ResizeImageInfo(); infoAuto.Filename = "images/camera.svg"; + infoAuto.BaseSize = new SKSize(100, 100); infoAuto.Quality = ResizeQuality.Auto; var toolsAuto = new SkiaSharpSvgTools(infoAuto, Logger); toolsAuto.Resize(dpiPath, DestinationFilename2); @@ -410,6 +458,46 @@ public void DefaultQualityProducesIdenticalOutputToExplicitAuto() } } + [Fact] + public void DifferentQualitiesProduceDifferentPixelOutput() + { + // SVG downscaling: Fastest (nearest) vs Auto (bilinear+mipmaps) + var dpiPath = new DpiPath("", 1); + + var infoFastest = new ResizeImageInfo(); + infoFastest.Filename = "images/camera.svg"; + infoFastest.BaseSize = new SKSize(100, 100); + infoFastest.Quality = ResizeQuality.Fastest; + var toolsFastest = new SkiaSharpSvgTools(infoFastest, Logger); + toolsFastest.Resize(dpiPath, DestinationFilename); + + var infoAuto = new ResizeImageInfo(); + infoAuto.Filename = "images/camera.svg"; + infoAuto.BaseSize = new SKSize(100, 100); + infoAuto.Quality = ResizeQuality.Auto; + var toolsAuto = new SkiaSharpSvgTools(infoAuto, Logger); + toolsAuto.Resize(dpiPath, DestinationFilename2); + + using var bmpFastest = SKBitmap.Decode(DestinationFilename); + using var bmpAuto = SKBitmap.Decode(DestinationFilename2); + + Assert.Equal(bmpFastest.Width, bmpAuto.Width); + Assert.Equal(bmpFastest.Height, bmpAuto.Height); + + int differentPixels = 0; + for (int y = 0; y < bmpFastest.Height; y++) + { + for (int x = 0; x < bmpFastest.Width; x++) + { + if (bmpFastest.GetPixel(x, y) != bmpAuto.GetPixel(x, y)) + differentPixels++; + } + } + + Assert.True(differentPixels > 0, + "SVG: Fastest and Auto should produce different pixel output when downscaling"); + } + [Theory] [InlineData("Auto")] [InlineData("Best")] From 9f96a531e963b584f68820cf69d1657451b3e830 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 19 Mar 2026 10:48:13 +0100 Subject: [PATCH 06/28] Address review: add pixel array length guard, fix Fastest XML doc - Assert pixel array lengths match before comparison loop to avoid IndexOutOfRangeException - Remove misleading 'smaller file sizes' claim from Fastest doc (PNG encoding is fixed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SingleProject/Resizetizer/src/ResizeQuality.cs | 4 ++-- .../Resizetizer/test/UnitTests/ResizetizeImagesTests.cs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/ResizeQuality.cs b/src/SingleProject/Resizetizer/src/ResizeQuality.cs index 0504152e2054..a734a9ab2995 100644 --- a/src/SingleProject/Resizetizer/src/ResizeQuality.cs +++ b/src/SingleProject/Resizetizer/src/ResizeQuality.cs @@ -19,8 +19,8 @@ internal enum ResizeQuality /// /// Fastest processing with nearest-neighbor interpolation. - /// Produces smaller file sizes but may appear pixelated when scaling. - /// Ideal for pixel art or when file size matters more than quality. + /// May appear pixelated when scaling. Ideal for pixel art + /// or when build speed matters more than visual fidelity. /// Fastest = 2, } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs index 963476f78f0d..a98761c73a1b 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs @@ -553,6 +553,8 @@ public void ResizeQualityMetadataIsRespectedEndToEnd() using var bmpAuto = SKBitmap.Decode(autoFile); var autoPixels = bmpAuto.Pixels.ToArray(); + Assert.Equal(fastestPixels.Length, autoPixels.Length); + int differentPixels = 0; for (int i = 0; i < fastestPixels.Length; i++) { From 29a4d923569d7591307da42153db3f44fea67a60 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 19 Mar 2026 11:07:12 +0100 Subject: [PATCH 07/28] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/SingleProject/Resizetizer/src/ResizeQuality.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/ResizeQuality.cs b/src/SingleProject/Resizetizer/src/ResizeQuality.cs index a734a9ab2995..ca74cce55508 100644 --- a/src/SingleProject/Resizetizer/src/ResizeQuality.cs +++ b/src/SingleProject/Resizetizer/src/ResizeQuality.cs @@ -13,8 +13,8 @@ internal enum ResizeQuality /// /// Highest quality output using Mitchell cubic resampler. - /// Best for upscaling or when visual fidelity is critical. - /// + /// Provides highest quality for both upscaling and downscaling + /// when visual fidelity is critical. Best = 1, /// From a0d315fa928fc39900aee24f8625cd6bdb62d006 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Tue, 7 Apr 2026 16:23:08 +0200 Subject: [PATCH 08/28] Fix missing XML doc tag and add ResizeQuality to build invalidation - Add missing closing tag on Best enum member XML doc - Add ResizeQuality to MSBuild inputs tracking so changing quality settings triggers a rebuild instead of serving stale cached images Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SingleProject/Resizetizer/src/ResizeQuality.cs | 1 + .../buildTransitive/Microsoft.Maui.Resizetizer.After.targets | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SingleProject/Resizetizer/src/ResizeQuality.cs b/src/SingleProject/Resizetizer/src/ResizeQuality.cs index ca74cce55508..0acdc92baa21 100644 --- a/src/SingleProject/Resizetizer/src/ResizeQuality.cs +++ b/src/SingleProject/Resizetizer/src/ResizeQuality.cs @@ -15,6 +15,7 @@ internal enum ResizeQuality /// Highest quality output using Mitchell cubic resampler. /// Provides highest quality for both upscaling and downscaling /// when visual fidelity is critical. + /// Best = 1, /// diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index 13fdd6b1c8d6..7252e757a102 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -326,7 +326,7 @@ From aae81946a91b75cb1cdc4fee06286849132b08a4 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Mon, 8 Jun 2026 15:34:55 +0200 Subject: [PATCH 09/28] Fix ResizeQuality propagation for monochrome adaptive icons Pass the configured ResizeQuality into the monochrome adaptive icon resizer so the updated SkiaSharpTools.Create signature is satisfied and monochrome icons use the same quality setting as the other adaptive icon layers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Resizetizer/src/AndroidAdaptiveIconGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs index dd2f23f92eb1..471a8ced45b1 100644 --- a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs +++ b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs @@ -179,7 +179,7 @@ void ProcessMonochrome(List results, DirectoryInfo fullInterme if (monochromeExists) { - var tools = SkiaSharpTools.Create(Info.MonochromeIsVector, Info.MonochromeFilename, dpi.Size, null, null, Logger); + var tools = SkiaSharpTools.Create(Info.MonochromeIsVector, Info.MonochromeFilename, dpi.Size, null, null, Info.Quality, Logger); tools.Resize(dpi, destination, dpiSizeIsAbsolute: true); } else From 04ad952ad2b090f995b655c634c5a11dc9268225 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 1 Jul 2026 09:50:22 +0200 Subject: [PATCH 10/28] Address ResizeQuality incremental review feedback Include ResizeQuality in splash inputs and make app icon output freshness checks account for Resizetizer inputs metadata so metadata-only quality changes regenerate stale outputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/AndroidAdaptiveIconGenerator.cs | 23 ++++---- .../Resizetizer/src/ResizeQuality.cs | 4 +- src/SingleProject/Resizetizer/src/Resizer.cs | 53 ++++++++++++++---- .../Resizetizer/src/ResizetizeImages.cs | 13 +++-- .../Resizetizer/src/WindowsIconGenerator.cs | 10 ++-- .../Microsoft.Maui.Resizetizer.After.targets | 2 +- .../Resizetizer/test/UnitTests/BaseTest.cs | 15 ++++- .../UnitTests/GenerateSplashAssetsTests.cs | 41 +++++++++++++- .../test/UnitTests/ResizetizeImagesTests.cs | 56 +++++++++++++++++++ 9 files changed, 177 insertions(+), 40 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs index 471a8ced45b1..10676b3690e0 100644 --- a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs +++ b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs @@ -7,18 +7,21 @@ namespace Microsoft.Maui.Resizetizer { internal class AndroidAdaptiveIconGenerator { - public AndroidAdaptiveIconGenerator(ResizeImageInfo info, string appIconName, string intermediateOutputPath, ILogger logger) + public AndroidAdaptiveIconGenerator(ResizeImageInfo info, string appIconName, string intermediateOutputPath, string inputsFile, ILogger logger) { Info = info; Logger = logger; IntermediateOutputPath = intermediateOutputPath; AppIconName = appIconName; + InputsFile = inputsFile; } public ResizeImageInfo Info { get; } public string IntermediateOutputPath { get; } + public string InputsFile { get; } + public ILogger Logger { get; private set; } public string AppIconName { get; } @@ -65,7 +68,7 @@ public IEnumerable Generate() void ProcessBackground(List results, DirectoryInfo fullIntermediateOutputPath) { var backgroundFile = Info.Filename; - var (backgroundExists, backgroundModified) = Utils.FileExists(backgroundFile); + var (backgroundExists, _) = Utils.FileExists(backgroundFile); var backgroundDestFilename = AppIconName + "_background.png"; if (backgroundExists) @@ -77,12 +80,10 @@ void ProcessBackground(List results, DirectoryInfo fullInterme { var dir = Path.Combine(fullIntermediateOutputPath.FullName, dpi.Path); var destination = Path.Combine(dir, backgroundDestFilename); - var (destinationExists, destinationModified) = Utils.FileExists(destination); Directory.CreateDirectory(dir); - if (destinationModified > backgroundModified) + if (Resizer.IsUpToDate(new[] { backgroundFile }, destination, InputsFile, Logger, backgroundFile)) { - Logger.Log($"Skipping `{backgroundFile}` => `{destination}` file is up to date."); results.Add(new ResizedImageInfo { Dpi = dpi, Filename = destination }); continue; } @@ -109,7 +110,7 @@ void ProcessBackground(List results, DirectoryInfo fullInterme void ProcessForeground(List results, DirectoryInfo fullIntermediateOutputPath) { var foregroundFile = Info.ForegroundFilename; - var (foregroundExists, foregroundModified) = Utils.FileExists(foregroundFile); + var (foregroundExists, _) = Utils.FileExists(foregroundFile); var foregroundDestFilename = AppIconName + "_foreground.png"; if (foregroundExists) @@ -121,12 +122,10 @@ void ProcessForeground(List results, DirectoryInfo fullInterme { var dir = Path.Combine(fullIntermediateOutputPath.FullName, dpi.Path); var destination = Path.Combine(dir, foregroundDestFilename); - var (destinationExists, destinationModified) = Utils.FileExists(destination); Directory.CreateDirectory(dir); - if (destinationModified > foregroundModified) + if (Resizer.IsUpToDate(new[] { foregroundFile }, destination, InputsFile, Logger, foregroundFile)) { - Logger.Log($"Skipping `{foregroundFile}` => `{destination}` file is up to date."); results.Add(new ResizedImageInfo { Dpi = dpi, Filename = destination }); continue; } @@ -153,7 +152,7 @@ void ProcessForeground(List results, DirectoryInfo fullInterme void ProcessMonochrome(List results, DirectoryInfo fullIntermediateOutputPath) { var monochromeFile = Info.MonochromeFilename; - var (monochromeExists, monochromeModified) = Utils.FileExists(monochromeFile); + var (monochromeExists, _) = Utils.FileExists(monochromeFile); var monochromeDestFilename = AppIconName + "_monochrome.png"; if (monochromeExists) @@ -165,12 +164,10 @@ void ProcessMonochrome(List results, DirectoryInfo fullInterme { var dir = Path.Combine(fullIntermediateOutputPath.FullName, dpi.Path); var destination = Path.Combine(dir, monochromeDestFilename); - var (destinationExists, destinationModified) = Utils.FileExists(destination); Directory.CreateDirectory(dir); - if (destinationModified > monochromeModified) + if (Resizer.IsUpToDate(new[] { monochromeFile }, destination, InputsFile, Logger, monochromeFile)) { - Logger.Log($"Skipping `{monochromeFile}` => `{destination}` file is up to date."); results.Add(new ResizedImageInfo { Dpi = dpi, Filename = destination }); continue; } diff --git a/src/SingleProject/Resizetizer/src/ResizeQuality.cs b/src/SingleProject/Resizetizer/src/ResizeQuality.cs index 0acdc92baa21..04d2326e043f 100644 --- a/src/SingleProject/Resizetizer/src/ResizeQuality.cs +++ b/src/SingleProject/Resizetizer/src/ResizeQuality.cs @@ -13,8 +13,8 @@ internal enum ResizeQuality /// /// Highest quality output using Mitchell cubic resampler. - /// Provides highest quality for both upscaling and downscaling - /// when visual fidelity is critical. + /// Provides high-fidelity scaling when preserving smooth detail is critical. + /// Auto preserves the existing mipmapped downscale behavior. /// Best = 1, diff --git a/src/SingleProject/Resizetizer/src/Resizer.cs b/src/SingleProject/Resizetizer/src/Resizer.cs index d26e1d02ca59..92cd607fbaa6 100644 --- a/src/SingleProject/Resizetizer/src/Resizer.cs +++ b/src/SingleProject/Resizetizer/src/Resizer.cs @@ -1,4 +1,6 @@ -using System.IO; +using System; +using System.Collections.Generic; +using System.IO; using SkiaSharp; namespace Microsoft.Maui.Resizetizer @@ -75,21 +77,52 @@ public ResizedImageInfo CopyFile(DpiPath dpi, string inputsFile) return new ResizedImageInfo { Filename = destination, Dpi = dpi }; } - static bool IsUpToDate(string inputFile, string outputFile, string inputsFile, ILogger logger) + internal static bool IsUpToDate(string inputFile, string outputFile, string inputsFile, ILogger logger) + { + var fileInputs = string.IsNullOrEmpty(inputsFile) ? null : new FileInfo(inputsFile); + if (fileInputs?.Exists != true) + return false; + + return IsUpToDate(new[] { inputFile }, outputFile, inputsFile, logger, inputFile); + } + + internal static bool IsUpToDate(IEnumerable inputFiles, string outputFile, string inputsFile, ILogger logger, string inputDescription = null) { - var fileIn = new FileInfo(inputFile); var fileOut = new FileInfo(outputFile); - var fileInputs = inputsFile is null ? null : new FileInfo(inputsFile); + if (!fileOut.Exists) + return false; + + var newestInput = DateTime.MinValue; + var hasInput = false; - if (fileIn.Exists && fileOut.Exists && fileInputs?.Exists == true - && fileIn.LastWriteTimeUtc <= fileOut.LastWriteTimeUtc - && fileInputs.LastWriteTimeUtc <= fileOut.LastWriteTimeUtc) + foreach (var inputFile in inputFiles) { - logger.Log($"Skipping '{inputFile}' as output '{outputFile}' is already up to date."); - return true; + if (string.IsNullOrEmpty(inputFile)) + continue; + + var fileIn = new FileInfo(inputFile); + if (!fileIn.Exists) + continue; + + hasInput = true; + if (fileIn.LastWriteTimeUtc > newestInput) + newestInput = fileIn.LastWriteTimeUtc; } - return false; + var fileInputs = string.IsNullOrEmpty(inputsFile) ? null : new FileInfo(inputsFile); + if (fileInputs?.Exists == true) + { + hasInput = true; + if (fileInputs.LastWriteTimeUtc > newestInput) + newestInput = fileInputs.LastWriteTimeUtc; + } + + if (!hasInput || newestInput > fileOut.LastWriteTimeUtc) + return false; + + var description = string.IsNullOrEmpty(inputDescription) ? "inputs" : inputDescription; + logger.Log($"Skipping '{description}' as output '{outputFile}' is already up to date."); + return true; } public ResizedImageInfo Resize(DpiPath dpi, string inputsFile) diff --git a/src/SingleProject/Resizetizer/src/ResizetizeImages.cs b/src/SingleProject/Resizetizer/src/ResizetizeImages.cs index 928b6a282e76..45ca3d2b8268 100644 --- a/src/SingleProject/Resizetizer/src/ResizetizeImages.cs +++ b/src/SingleProject/Resizetizer/src/ResizetizeImages.cs @@ -178,7 +178,7 @@ void ProcessAppIcon(ResizeImageInfo img, ConcurrentBag resized appIconName = appIconName.ToLowerInvariant(); - var adaptiveIconGen = new AndroidAdaptiveIconGenerator(img, appIconName, IntermediateOutputPath, this); + var adaptiveIconGen = new AndroidAdaptiveIconGenerator(img, appIconName, IntermediateOutputPath, InputsFile, this); var iconsGenerated = adaptiveIconGen.Generate(); foreach (var iconGenerated in iconsGenerated) @@ -199,7 +199,7 @@ void ProcessAppIcon(ResizeImageInfo img, ConcurrentBag resized { LogDebugMessage($"Windows Icon Generator"); - var windowsIconGen = new WindowsIconGenerator(img, IntermediateOutputPath, this); + var windowsIconGen = new WindowsIconGenerator(img, IntermediateOutputPath, InputsFile, this); resizedImages.Add(windowsIconGen.Generate()); } @@ -207,6 +207,7 @@ void ProcessAppIcon(ResizeImageInfo img, ConcurrentBag resized LogDebugMessage($"Generating App Icon Bitmaps for DPIs"); var appTool = new SkiaSharpAppIconTools(img, this); + var appIconSourceFiles = GetAppIconSourceFiles(img); LogDebugMessage($"App Icon: Intermediate Path " + IntermediateOutputPath); @@ -216,14 +217,11 @@ void ProcessAppIcon(ResizeImageInfo img, ConcurrentBag resized var destination = Resizer.GetRasterFileDestination(img, dpi, IntermediateOutputPath) .Replace("{name}", appIconName); - var (sourceExists, sourceModified) = Utils.FileExists(img.Filename); - var (destinationExists, destinationModified) = Utils.FileExists(destination); LogDebugMessage($"App Icon Destination: " + destination); - if (destinationModified > sourceModified) + if (Resizer.IsUpToDate(appIconSourceFiles, destination, InputsFile, Logger, img.ItemSpec)) { - Logger.Log($"Skipping `{img.Filename}` => `{destination}` file is up to date."); resizedImages.Add(new ResizedImageInfo() { Dpi = dpi, Filename = destination }); continue; } @@ -233,6 +231,9 @@ void ProcessAppIcon(ResizeImageInfo img, ConcurrentBag resized } } + static string[] GetAppIconSourceFiles(ResizeImageInfo img) => + new[] { img.Filename, img.ForegroundFilename }; + void ProcessImageResize(ResizeImageInfo img, DpiPath[] dpis, ConcurrentBag resizedImages) { var resizer = new Resizer(img, IntermediateOutputPath, this); diff --git a/src/SingleProject/Resizetizer/src/WindowsIconGenerator.cs b/src/SingleProject/Resizetizer/src/WindowsIconGenerator.cs index 612b2006fd8d..96f480731899 100644 --- a/src/SingleProject/Resizetizer/src/WindowsIconGenerator.cs +++ b/src/SingleProject/Resizetizer/src/WindowsIconGenerator.cs @@ -9,15 +9,17 @@ namespace Microsoft.Maui.Resizetizer /// internal class WindowsIconGenerator { - public WindowsIconGenerator(ResizeImageInfo info, string intermediateOutputPath, ILogger logger) + public WindowsIconGenerator(ResizeImageInfo info, string intermediateOutputPath, string inputsFile, ILogger logger) { Info = info; Logger = logger; IntermediateOutputPath = intermediateOutputPath; + InputsFile = inputsFile; } public ResizeImageInfo Info { get; private set; } public string IntermediateOutputPath { get; private set; } + public string InputsFile { get; private set; } public ILogger Logger { get; private set; } public ResizedImageInfo Generate() @@ -28,17 +30,13 @@ public ResizedImageInfo Generate() string destination = Path.Combine(destinationFolder, $"{fileName}.ico"); Directory.CreateDirectory(destinationFolder); - var (sourceExists, sourceModified) = Utils.FileExists(Info.Filename); - var (destinationExists, destinationModified) = Utils.FileExists(destination); - Logger.Log($"Generating ICO: {destination}"); var tools = new SkiaSharpAppIconTools(Info, Logger); var dpi = new DpiPath(fileName, 1.0m, size: new SKSize(64, 64)); - if (destinationModified > sourceModified) + if (Resizer.IsUpToDate(new[] { Info.Filename, Info.ForegroundFilename }, destination, InputsFile, Logger, Info.ItemSpec)) { - Logger.Log($"Skipping `{Info.Filename}` => `{destination}` file is up to date."); return new ResizedImageInfo { Dpi = dpi, Filename = destination }; } diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index 7252e757a102..78ea63dbdf03 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -338,7 +338,7 @@ diff --git a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs index 668a64c46a12..d5787fbf873e 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs @@ -62,6 +62,19 @@ protected void AssertFileExists(string file) Assert.True(File.Exists(file), $"File did not exist: {file}"); } + protected SKColor[] ReadPixels(string file) + { + file = Path.Combine(DestinationDirectory, file); + using var bitmap = SKBitmap.Decode(file); + return bitmap.Pixels.ToArray(); + } + + protected static void AssertPixelsDiffer(SKColor[] expected, SKColor[] actual, string because) + { + Assert.Equal(expected.Length, actual.Length); + Assert.True(expected.Where((pixel, index) => pixel != actual[index]).Any(), because); + } + protected void AssertFileNotExists(string file) { file = Path.Combine(DestinationDirectory, file); @@ -188,7 +201,7 @@ private string GetTestImageFileName(object[] args, string methodName, string ext return Path.Combine(TestImagesFolderName, name, methodName, filename); } - private static string GetTestProjectRoot() + protected static string GetTestProjectRoot() { var cwd = Directory.GetCurrentDirectory(); diff --git a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetsTests.cs index 7e8eccad3054..4f3ae5ff8f49 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetsTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using Microsoft.Build.Framework; @@ -54,6 +55,44 @@ public void FileIsGenerated(string image, string color) AssertFile($"{image}SplashScreen.scale-200.png", 1240, 600); } + [Fact] + public void ResizeQualityMetadataIsRespected() + { + var fastestSplash = new TaskItem("images/camera.png", new Dictionary + { + ["Link"] = "camera_fastest", + ["ResizeQuality"] = "Fastest", + }); + + var task = GetNewTask(fastestSplash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + var fastestPixels = ReadPixels("camera_fastestSplashScreen.scale-100.png"); + + var autoSplash = new TaskItem("images/camera.png", new Dictionary + { + ["Link"] = "camera_auto", + ["ResizeQuality"] = "Auto", + }); + + task = GetNewTask(autoSplash); + success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertPixelsDiffer(fastestPixels, ReadPixels("camera_autoSplashScreen.scale-100.png"), + "Splash output should honor ResizeQuality metadata."); + } + + [Fact] + public void SplashInputsIncludeResizeQualityMetadata() + { + var targetsFile = Path.GetFullPath(Path.Combine(GetTestProjectRoot(), "../../src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets")); + var targets = File.ReadAllText(targetsFile); + + Assert.Contains("FileHash=%(FileHash);ResizeQuality=%(ResizeQuality)", targets, StringComparison.Ordinal); + } + [Theory] [InlineData(null, "appiconfg")] [InlineData("images/CustomAlias.svg", "CustomAlias")] diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs index a98761c73a1b..8fe582acf8ee 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs @@ -35,6 +35,7 @@ protected ResizetizeImages GetNewTask(string type, params ITaskItem[] items) => protected ITaskItem GetCopiedResource(ResizetizeImages task, string path) => task.CopiedResources.Single(c => c.ItemSpec.Replace('\\', '/').EndsWith(path, StringComparison.Ordinal)); + } public abstract class ExecuteForPlatformApp : ExecuteForApp @@ -566,6 +567,61 @@ public void ResizeQualityMetadataIsRespectedEndToEnd() "End-to-end: Fastest and Auto must produce different pixel output"); } + [Fact] + public void AppIconResizeQualityMetadataChangeRegeneratesIncrementalOutputs() + { + var inputsFile = Path.Combine(DestinationDirectory, "mauiimage.inputs"); + var appIconFile = "mipmap-mdpi/quality_icon.png"; + var adaptiveForegroundFile = "mipmap-mdpi/quality_icon_foreground.png"; + + Directory.CreateDirectory(DestinationDirectory); + File.WriteAllText(inputsFile, "ResizeQuality=Fastest"); + var itemsFastest = new[] + { + new TaskItem("images/camera.png", new Dictionary + { + ["IsAppIcon"] = bool.TrueString, + ["Link"] = "quality_icon", + ["ResizeQuality"] = "Fastest", + }), + }; + + var task = GetNewTask(itemsFastest); + task.InputsFile = inputsFile; + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + var fastestAppIconPixels = ReadPixels(appIconFile); + var fastestAdaptiveForegroundPixels = ReadPixels(adaptiveForegroundFile); + var appIconOutput = Path.Combine(DestinationDirectory, appIconFile); + + File.WriteAllText(inputsFile, "ResizeQuality=Auto"); + File.SetLastWriteTimeUtc(inputsFile, File.GetLastWriteTimeUtc(appIconOutput).AddSeconds(2)); + + LogErrorEvents.Clear(); + LogMessageEvents.Clear(); + + var itemsAuto = new[] + { + new TaskItem("images/camera.png", new Dictionary + { + ["IsAppIcon"] = bool.TrueString, + ["Link"] = "quality_icon", + ["ResizeQuality"] = "Auto", + }), + }; + + task = GetNewTask(itemsAuto); + task.InputsFile = inputsFile; + success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + + AssertPixelsDiffer(fastestAppIconPixels, ReadPixels(appIconFile), + "App icon output should regenerate when only ResizeQuality metadata changes."); + AssertPixelsDiffer(fastestAdaptiveForegroundPixels, ReadPixels(adaptiveForegroundFile), + "Adaptive icon foreground output should regenerate when only ResizeQuality metadata changes."); + } + [Fact] public void DefaultResizeQualityPreservesBaselineBehavior() { From 24bf0a23104586f46a07391fd4d3ae944c64e395 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 1 Jul 2026 10:31:26 +0200 Subject: [PATCH 11/28] Fix ResizeQuality splash input test for Helix Read the copied Resizetizer targets file from the test output directory instead of the source tree so the coverage works in Helix payloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs | 2 +- .../Resizetizer/test/UnitTests/GenerateSplashAssetsTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs index d5787fbf873e..a0d5237a6868 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs @@ -201,7 +201,7 @@ private string GetTestImageFileName(object[] args, string methodName, string ext return Path.Combine(TestImagesFolderName, name, methodName, filename); } - protected static string GetTestProjectRoot() + private static string GetTestProjectRoot() { var cwd = Directory.GetCurrentDirectory(); diff --git a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetsTests.cs index 4f3ae5ff8f49..07f3571e6379 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetsTests.cs @@ -87,7 +87,7 @@ public void ResizeQualityMetadataIsRespected() [Fact] public void SplashInputsIncludeResizeQualityMetadata() { - var targetsFile = Path.GetFullPath(Path.Combine(GetTestProjectRoot(), "../../src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets")); + var targetsFile = Path.Combine(AppContext.BaseDirectory, "nuget", "buildTransitive", "Microsoft.Maui.Resizetizer.After.targets"); var targets = File.ReadAllText(targetsFile); Assert.Contains("FileHash=%(FileHash);ResizeQuality=%(ResizeQuality)", targets, StringComparison.Ordinal); From 1400dcdf852e9eddd662fc1a3eb418a7a777a3fa Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 2 Jul 2026 11:06:40 +0200 Subject: [PATCH 12/28] Address ResizeQuality review comments Guard ResizeQuality parsing against undefined numeric enum values and clarify the Best XML docs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Resizetizer/src/ResizeImageInfo.cs | 3 ++- src/SingleProject/Resizetizer/src/ResizeQuality.cs | 2 +- .../test/UnitTests/ResizeImageInfoTests.cs | 13 +++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs index 53796b722e63..7d5579e6e345 100644 --- a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs +++ b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs @@ -122,7 +122,8 @@ public static ResizeImageInfo Parse(ITaskItem image) if (float.TryParse(image.GetMetadata("ForegroundScale"), NumberStyles.Number, CultureInfo.InvariantCulture, out var fsc)) info.ForegroundScale = fsc; - if (Enum.TryParse(image.GetMetadata("ResizeQuality"), ignoreCase: true, out var quality)) + if (Enum.TryParse(image.GetMetadata("ResizeQuality"), ignoreCase: true, out var quality) && + Enum.IsDefined(typeof(ResizeQuality), quality)) info.Quality = quality; var fgFile = image.GetMetadata("ForegroundFile"); diff --git a/src/SingleProject/Resizetizer/src/ResizeQuality.cs b/src/SingleProject/Resizetizer/src/ResizeQuality.cs index 04d2326e043f..b178a982acf8 100644 --- a/src/SingleProject/Resizetizer/src/ResizeQuality.cs +++ b/src/SingleProject/Resizetizer/src/ResizeQuality.cs @@ -14,7 +14,7 @@ internal enum ResizeQuality /// /// Highest quality output using Mitchell cubic resampler. /// Provides high-fidelity scaling when preserving smooth detail is critical. - /// Auto preserves the existing mipmapped downscale behavior. + /// Uses cubic resampling for smoother results than the default bilinear output. /// Best = 1, diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs index 46f50f45370a..416b458961c8 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizeImageInfoTests.cs @@ -130,6 +130,19 @@ public void QualityDefaultsToAutoForInvalidValue() Assert.Equal(ResizeQuality.Auto, info.Quality); } + [Fact] + public void QualityDefaultsToAutoForUndefinedNumericValue() + { + var path = Path.GetFullPath("images/camera.png"); + var item = new TaskItem(path, new Dictionary + { + ["ResizeQuality"] = "3" + }); + + var info = ResizeImageInfo.Parse(item); + Assert.Equal(ResizeQuality.Auto, info.Quality); + } + [Theory] [InlineData("auto")] [InlineData("FASTEST")] From 10271d098f19aae00ffd49bb1b73c6728a8be91f Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 8 Jul 2026 17:01:02 +0200 Subject: [PATCH 13/28] Address ResizeQuality incremental review follow-up Require the generated inputs file for multi-source up-to-date checks, update incremental tests to model that metadata file, and clean up ResizeQuality docs punctuation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Resizetizer/src/ResizeQuality.cs | 4 +- src/SingleProject/Resizetizer/src/Resizer.cs | 20 +++------- .../test/UnitTests/ResizerTests.cs | 40 +++++++++++++++++++ .../test/UnitTests/ResizetizeImagesTests.cs | 7 ++++ 4 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs diff --git a/src/SingleProject/Resizetizer/src/ResizeQuality.cs b/src/SingleProject/Resizetizer/src/ResizeQuality.cs index b178a982acf8..eb21fd7968a6 100644 --- a/src/SingleProject/Resizetizer/src/ResizeQuality.cs +++ b/src/SingleProject/Resizetizer/src/ResizeQuality.cs @@ -20,8 +20,8 @@ internal enum ResizeQuality /// /// Fastest processing with nearest-neighbor interpolation. - /// May appear pixelated when scaling. Ideal for pixel art - /// or when build speed matters more than visual fidelity. + /// May appear pixelated when scaling, but is ideal for pixel art. + /// Use it when build speed matters more than visual fidelity. /// Fastest = 2, } diff --git a/src/SingleProject/Resizetizer/src/Resizer.cs b/src/SingleProject/Resizetizer/src/Resizer.cs index 92cd607fbaa6..b33d74222947 100644 --- a/src/SingleProject/Resizetizer/src/Resizer.cs +++ b/src/SingleProject/Resizetizer/src/Resizer.cs @@ -79,21 +79,21 @@ public ResizedImageInfo CopyFile(DpiPath dpi, string inputsFile) internal static bool IsUpToDate(string inputFile, string outputFile, string inputsFile, ILogger logger) { - var fileInputs = string.IsNullOrEmpty(inputsFile) ? null : new FileInfo(inputsFile); - if (fileInputs?.Exists != true) - return false; - return IsUpToDate(new[] { inputFile }, outputFile, inputsFile, logger, inputFile); } internal static bool IsUpToDate(IEnumerable inputFiles, string outputFile, string inputsFile, ILogger logger, string inputDescription = null) { + var fileInputs = string.IsNullOrEmpty(inputsFile) ? null : new FileInfo(inputsFile); + if (fileInputs?.Exists != true) + return false; + var fileOut = new FileInfo(outputFile); if (!fileOut.Exists) return false; - var newestInput = DateTime.MinValue; - var hasInput = false; + var newestInput = fileInputs.LastWriteTimeUtc; + var hasInput = true; foreach (var inputFile in inputFiles) { @@ -109,14 +109,6 @@ internal static bool IsUpToDate(IEnumerable inputFiles, string outputFil newestInput = fileIn.LastWriteTimeUtc; } - var fileInputs = string.IsNullOrEmpty(inputsFile) ? null : new FileInfo(inputsFile); - if (fileInputs?.Exists == true) - { - hasInput = true; - if (fileInputs.LastWriteTimeUtc > newestInput) - newestInput = fileInputs.LastWriteTimeUtc; - } - if (!hasInput || newestInput > fileOut.LastWriteTimeUtc) return false; diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs new file mode 100644 index 000000000000..e6df393e7709 --- /dev/null +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using Xunit; + +namespace Microsoft.Maui.Resizetizer.Tests +{ + public class ResizerTests : IDisposable + { + readonly string DestinationDirectory; + + public ResizerTests() + { + DestinationDirectory = Path.Combine(Path.GetTempPath(), nameof(ResizerTests), Path.GetRandomFileName()); + Directory.CreateDirectory(DestinationDirectory); + } + + public void Dispose() + { + if (Directory.Exists(DestinationDirectory)) + Directory.Delete(DestinationDirectory, true); + } + + [Fact] + public void MultiInputIsNotUpToDateWhenInputsFileIsMissing() + { + var inputFile = Path.Combine(DestinationDirectory, "image.png"); + var outputFile = Path.Combine(DestinationDirectory, "image.out"); + var inputsFile = Path.Combine(DestinationDirectory, "mauiimage.inputs"); + + File.WriteAllText(inputFile, "input"); + File.WriteAllText(outputFile, "output"); + File.SetLastWriteTimeUtc(outputFile, File.GetLastWriteTimeUtc(inputFile).AddSeconds(1)); + + var logger = new TestLogger(); + + Assert.False(Resizer.IsUpToDate(new[] { inputFile }, outputFile, inputsFile, logger)); + Assert.Empty(logger.Messages); + } + } +} diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs index 8fe582acf8ee..8912ed81f4b4 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs @@ -121,6 +121,10 @@ public void ImageProcessingErrorCode(string image) [Fact] public void GenerationSkippedOnIncrementalBuild() { + var inputsFile = Path.Combine(DestinationDirectory, "mauiimage.inputs"); + Directory.CreateDirectory(DestinationDirectory); + File.WriteAllText(inputsFile, "ResizeQuality=Auto"); + var items = new[] { new TaskItem("images/dotnet_logo.svg", new Dictionary @@ -133,12 +137,15 @@ public void GenerationSkippedOnIncrementalBuild() }; var task = GetNewTask(items); + task.InputsFile = inputsFile; var success = task.Execute(); Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + File.SetLastWriteTimeUtc(inputsFile, DateTime.UtcNow.AddMinutes(-1)); LogErrorEvents.Clear(); LogMessageEvents.Clear(); task = GetNewTask(items); + task.InputsFile = inputsFile; success = task.Execute(); Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); From 3d3c59caa93b236ec1fd59bb76d9dc3de2b1e036 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 9 Jul 2026 10:07:44 +0200 Subject: [PATCH 14/28] Require source inputs for ResizeQuality freshness checks Keep the generated inputs file as part of multi-source freshness checks, but only skip regeneration when at least one real source input also exists. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/SingleProject/Resizetizer/src/Resizer.cs | 6 +++--- .../Resizetizer/test/UnitTests/ResizerTests.cs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/Resizer.cs b/src/SingleProject/Resizetizer/src/Resizer.cs index b33d74222947..7cd562f86ad6 100644 --- a/src/SingleProject/Resizetizer/src/Resizer.cs +++ b/src/SingleProject/Resizetizer/src/Resizer.cs @@ -93,7 +93,7 @@ internal static bool IsUpToDate(IEnumerable inputFiles, string outputFil return false; var newestInput = fileInputs.LastWriteTimeUtc; - var hasInput = true; + var hasSourceInput = false; foreach (var inputFile in inputFiles) { @@ -104,12 +104,12 @@ internal static bool IsUpToDate(IEnumerable inputFiles, string outputFil if (!fileIn.Exists) continue; - hasInput = true; + hasSourceInput = true; if (fileIn.LastWriteTimeUtc > newestInput) newestInput = fileIn.LastWriteTimeUtc; } - if (!hasInput || newestInput > fileOut.LastWriteTimeUtc) + if (!hasSourceInput || newestInput > fileOut.LastWriteTimeUtc) return false; var description = string.IsNullOrEmpty(inputDescription) ? "inputs" : inputDescription; diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs index e6df393e7709..46f9a96d3211 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs @@ -36,5 +36,22 @@ public void MultiInputIsNotUpToDateWhenInputsFileIsMissing() Assert.False(Resizer.IsUpToDate(new[] { inputFile }, outputFile, inputsFile, logger)); Assert.Empty(logger.Messages); } + + [Fact] + public void MultiInputIsNotUpToDateWhenSourceInputsAreMissing() + { + var missingInputFile = Path.Combine(DestinationDirectory, "missing.png"); + var outputFile = Path.Combine(DestinationDirectory, "image.out"); + var inputsFile = Path.Combine(DestinationDirectory, "mauiimage.inputs"); + + File.WriteAllText(inputsFile, "ResizeQuality=Auto"); + File.WriteAllText(outputFile, "output"); + File.SetLastWriteTimeUtc(outputFile, File.GetLastWriteTimeUtc(inputsFile).AddSeconds(1)); + + var logger = new TestLogger(); + + Assert.False(Resizer.IsUpToDate(new[] { missingInputFile }, outputFile, inputsFile, logger)); + Assert.Empty(logger.Messages); + } } } From f80c83f0b7e986a0f6252b7615f4e7004b75f263 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 10 Jul 2026 10:51:20 +0200 Subject: [PATCH 15/28] Allow optional empty ResizeQuality freshness inputs Treat missing declared source paths as stale while allowing generated outputs with only optional empty inputs to be considered up to date from the metadata inputs file. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/SingleProject/Resizetizer/src/Resizer.cs | 6 ++---- .../Resizetizer/test/UnitTests/ResizerTests.cs | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/Resizer.cs b/src/SingleProject/Resizetizer/src/Resizer.cs index 7cd562f86ad6..bfac7c7a8b40 100644 --- a/src/SingleProject/Resizetizer/src/Resizer.cs +++ b/src/SingleProject/Resizetizer/src/Resizer.cs @@ -93,7 +93,6 @@ internal static bool IsUpToDate(IEnumerable inputFiles, string outputFil return false; var newestInput = fileInputs.LastWriteTimeUtc; - var hasSourceInput = false; foreach (var inputFile in inputFiles) { @@ -102,14 +101,13 @@ internal static bool IsUpToDate(IEnumerable inputFiles, string outputFil var fileIn = new FileInfo(inputFile); if (!fileIn.Exists) - continue; + return false; - hasSourceInput = true; if (fileIn.LastWriteTimeUtc > newestInput) newestInput = fileIn.LastWriteTimeUtc; } - if (!hasSourceInput || newestInput > fileOut.LastWriteTimeUtc) + if (newestInput > fileOut.LastWriteTimeUtc) return false; var description = string.IsNullOrEmpty(inputDescription) ? "inputs" : inputDescription; diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs index 46f9a96d3211..77d09c98892c 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizerTests.cs @@ -38,7 +38,7 @@ public void MultiInputIsNotUpToDateWhenInputsFileIsMissing() } [Fact] - public void MultiInputIsNotUpToDateWhenSourceInputsAreMissing() + public void MultiInputIsNotUpToDateWhenDeclaredSourceInputIsMissing() { var missingInputFile = Path.Combine(DestinationDirectory, "missing.png"); var outputFile = Path.Combine(DestinationDirectory, "image.out"); @@ -53,5 +53,21 @@ public void MultiInputIsNotUpToDateWhenSourceInputsAreMissing() Assert.False(Resizer.IsUpToDate(new[] { missingInputFile }, outputFile, inputsFile, logger)); Assert.Empty(logger.Messages); } + + [Fact] + public void MultiInputCanBeUpToDateWhenSourceInputsAreEmpty() + { + var outputFile = Path.Combine(DestinationDirectory, "image.out"); + var inputsFile = Path.Combine(DestinationDirectory, "mauiimage.inputs"); + + File.WriteAllText(inputsFile, "ResizeQuality=Auto"); + File.WriteAllText(outputFile, "output"); + File.SetLastWriteTimeUtc(outputFile, File.GetLastWriteTimeUtc(inputsFile).AddSeconds(1)); + + var logger = new TestLogger(); + + Assert.True(Resizer.IsUpToDate(new string[] { null, string.Empty }, outputFile, inputsFile, logger)); + Assert.Single(logger.Messages); + } } } From 3848dcacaa80d8227f8b3f27080e9d2cd25c413d Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 15 Jul 2026 11:07:38 +0200 Subject: [PATCH 16/28] Address ResizeQuality SVG review feedback Ensure SVG ResizeQuality is honored for Fastest upscales without regressing Auto or Best vector rendering. Strengthen pixel-difference assertions and add task-level Best coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Resizetizer/src/SkiaSharpSvgTools.cs | 9 +- .../Resizetizer/src/SkiaSharpTools.cs | 6 +- .../Resizetizer/test/UnitTests/BaseTest.cs | 9 +- .../test/UnitTests/ResizetizeImagesTests.cs | 34 +++-- .../UnitTests/SkiaSharpRasterToolsTests.cs | 24 +--- .../test/UnitTests/SkiaSharpSvgToolsTests.cs | 131 ++++++++++++++++-- 6 files changed, 162 insertions(+), 51 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs index a466cdd923ab..494a23abe98c 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs @@ -40,21 +40,20 @@ public override void DrawUnscaled(SKCanvas canvas, float scale) { throw new InvalidOperationException($"Cannot draw SVG file '{Filename}'. The SVG has no size. Ensure the SVG includes a viewBox attribute or both width and height attributes with valid dimensions."); } - if (scale >= 1) + if (scale >= 1 && Quality != ResizeQuality.Fastest) { - // draw using default scaling + // Draw vectors directly for Auto/back-compat and Best/highest fidelity. canvas.DrawPicture(svg.Picture, Paint); } else { - // vector scaling has rounding issues, so first draw as intended + // Rasterize first so the final draw honors the selected sampling options. var info = new SKImageInfo((int)size.Width, (int)size.Height); using var surface = SKSurface.Create(info); var cvn = surface.Canvas; - // draw to a larger canvas first cvn.Clear(SKColors.Transparent); - cvn.DrawPicture(svg.Picture, Paint); + cvn.DrawPicture(svg.Picture); // convert it all into an image using var img = surface.Snapshot(); diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs index fec7b466ff95..1a1e69df0f61 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpTools.cs @@ -91,9 +91,11 @@ public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColo Paint.ColorFilter = SKColorFilter.CreateBlendMode(tint, SKBlendMode.SrcIn); } + Quality = Enum.IsDefined(typeof(ResizeQuality), quality) ? quality : ResizeQuality.Auto; + // Map ResizeQuality to SKSamplingOptions which controls actual resampling. // Auto preserves the original bilinear+mipmaps behavior for backward compatibility. - SamplingOptions = quality switch + SamplingOptions = Quality switch { ResizeQuality.Best => new SKSamplingOptions(SKCubicResampler.Mitchell), ResizeQuality.Fastest => new SKSamplingOptions(SKFilterMode.Nearest, SKMipmapMode.None), @@ -112,6 +114,8 @@ public SkiaSharpTools(string filename, SKSize? baseSize, SKColor? backgroundColo public SKPaint Paint { get; } + public ResizeQuality Quality { get; } + public SKSamplingOptions SamplingOptions { get; } public void Resize(DpiPath dpi, string destination, double additionalScale = 1.0, bool dpiSizeIsAbsolute = false) diff --git a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs index a0d5237a6868..3fffaafcf954 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs @@ -69,10 +69,15 @@ protected SKColor[] ReadPixels(string file) return bitmap.Pixels.ToArray(); } - protected static void AssertPixelsDiffer(SKColor[] expected, SKColor[] actual, string because) + protected internal static void AssertPixelsDiffer(SKColor[] expected, SKColor[] actual, string because) { Assert.Equal(expected.Length, actual.Length); - Assert.True(expected.Where((pixel, index) => pixel != actual[index]).Any(), because); + + var differentPixels = expected.Where((pixel, index) => pixel != actual[index]).Count(); + var minimumDifferentPixels = Math.Max(1, expected.Length / 100); + + Assert.True(differentPixels > minimumDifferentPixels, + $"{because} Differing pixels: {differentPixels} of {expected.Length}; expected more than {minimumDifferentPixels}."); } protected void AssertFileNotExists(string file) diff --git a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs index 8912ed81f4b4..8615c5c39497 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/ResizetizeImagesTests.cs @@ -556,22 +556,32 @@ public void ResizeQualityMetadataIsRespectedEndToEnd() Assert.True(successAuto, LogErrorEvents.FirstOrDefault()?.Message); AssertFileSize("drawable-mdpi/camera_auto.png", 100, 100); - // Compare: Fastest vs Auto should differ when downscaling + var itemsBest = new[] + { + new TaskItem("images/camera.png", new Dictionary + { + ["BaseSize"] = "100", + ["Link"] = "camera_best", + ["ResizeQuality"] = "Best", + }), + }; + + var taskBest = GetNewTask(itemsBest); + var successBest = taskBest.Execute(); + Assert.True(successBest, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileSize("drawable-mdpi/camera_best.png", 100, 100); + + var bestPixels = ReadPixels("drawable-mdpi/camera_best.png"); + + // Compare: Fastest/Best vs Auto should differ when downscaling var autoFile = Path.Combine(DestinationDirectory, "drawable-mdpi/camera_auto.png"); using var bmpAuto = SKBitmap.Decode(autoFile); var autoPixels = bmpAuto.Pixels.ToArray(); - Assert.Equal(fastestPixels.Length, autoPixels.Length); - - int differentPixels = 0; - for (int i = 0; i < fastestPixels.Length; i++) - { - if (fastestPixels[i] != autoPixels[i]) - differentPixels++; - } - - Assert.True(differentPixels > 0, - "End-to-end: Fastest and Auto must produce different pixel output"); + AssertPixelsDiffer(fastestPixels, autoPixels, + "End-to-end: Fastest and Auto must produce different pixel output."); + AssertPixelsDiffer(bestPixels, autoPixels, + "End-to-end: Best and Auto must produce different pixel output."); } [Fact] diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs index 5ee8ca2a6a56..480ed83c41f5 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpRasterToolsTests.cs @@ -419,17 +419,7 @@ public void DifferentQualitiesProduceDifferentPixelOutput() Assert.Equal(bmpFastest.Width, bmpAuto.Width); Assert.Equal(bmpFastest.Height, bmpAuto.Height); - int differentPixels = 0; - for (int y = 0; y < bmpFastest.Height; y++) - { - for (int x = 0; x < bmpFastest.Width; x++) - { - if (bmpFastest.GetPixel(x, y) != bmpAuto.GetPixel(x, y)) - differentPixels++; - } - } - - Assert.True(differentPixels > 0, + BaseTest.AssertPixelsDiffer(bmpFastest.Pixels, bmpAuto.Pixels, "Fastest and Auto should produce different pixel output when downscaling"); } @@ -459,17 +449,7 @@ public void BestQualityProducesDifferentPixelOutputThanAuto() Assert.Equal(bmpBest.Width, bmpAuto.Width); Assert.Equal(bmpBest.Height, bmpAuto.Height); - int differentPixels = 0; - for (int y = 0; y < bmpBest.Height; y++) - { - for (int x = 0; x < bmpBest.Width; x++) - { - if (bmpBest.GetPixel(x, y) != bmpAuto.GetPixel(x, y)) - differentPixels++; - } - } - - Assert.True(differentPixels > 0, + BaseTest.AssertPixelsDiffer(bmpBest.Pixels, bmpAuto.Pixels, "Best (Mitchell cubic) and Auto (bilinear) should produce different pixel output"); } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs index 768f35689f66..570956e3f766 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs @@ -484,18 +484,131 @@ public void DifferentQualitiesProduceDifferentPixelOutput() Assert.Equal(bmpFastest.Width, bmpAuto.Width); Assert.Equal(bmpFastest.Height, bmpAuto.Height); - int differentPixels = 0; - for (int y = 0; y < bmpFastest.Height; y++) + BaseTest.AssertPixelsDiffer(bmpFastest.Pixels, bmpAuto.Pixels, + "SVG: Fastest and Auto should produce different pixel output when downscaling"); + } + + [Fact] + public void FastestQualityAffectsSvgUpscaling() + { + var sourceFilename = Path.GetTempFileName(); + + try { - for (int x = 0; x < bmpFastest.Width; x++) - { - if (bmpFastest.GetPixel(x, y) != bmpAuto.GetPixel(x, y)) - differentPixels++; - } + File.WriteAllText(sourceFilename, + """ + + + + + """); + + var dpiPath = new DpiPath("", 1); + + var infoFastest = new ResizeImageInfo(); + infoFastest.Filename = sourceFilename; + infoFastest.BaseSize = new SKSize(64, 64); + infoFastest.Quality = ResizeQuality.Fastest; + var toolsFastest = new SkiaSharpSvgTools(infoFastest, Logger); + toolsFastest.Resize(dpiPath, DestinationFilename); + + var infoAuto = new ResizeImageInfo(); + infoAuto.Filename = sourceFilename; + infoAuto.BaseSize = new SKSize(64, 64); + infoAuto.Quality = ResizeQuality.Auto; + var toolsAuto = new SkiaSharpSvgTools(infoAuto, Logger); + toolsAuto.Resize(dpiPath, DestinationFilename2); + + using var bmpFastest = SKBitmap.Decode(DestinationFilename); + using var bmpAuto = SKBitmap.Decode(DestinationFilename2); + + Assert.Equal(bmpFastest.Width, bmpAuto.Width); + Assert.Equal(bmpFastest.Height, bmpAuto.Height); + BaseTest.AssertPixelsDiffer(bmpFastest.Pixels, bmpAuto.Pixels, + "SVG: Fastest and Auto should produce different pixel output when upscaling."); + } + finally + { + File.Delete(sourceFilename); } + } - Assert.True(differentPixels > 0, - "SVG: Fastest and Auto should produce different pixel output when downscaling"); + [Fact] + public void BestQualityPreservesSvgVectorOutputWhenUpscaling() + { + var sourceFilename = Path.GetTempFileName(); + + try + { + File.WriteAllText(sourceFilename, + """ + + + + + """); + + var dpiPath = new DpiPath("", 1); + + var infoBest = new ResizeImageInfo(); + infoBest.Filename = sourceFilename; + infoBest.BaseSize = new SKSize(64, 64); + infoBest.Quality = ResizeQuality.Best; + var toolsBest = new SkiaSharpSvgTools(infoBest, Logger); + toolsBest.Resize(dpiPath, DestinationFilename); + + var infoAuto = new ResizeImageInfo(); + infoAuto.Filename = sourceFilename; + infoAuto.BaseSize = new SKSize(64, 64); + infoAuto.Quality = ResizeQuality.Auto; + var toolsAuto = new SkiaSharpSvgTools(infoAuto, Logger); + toolsAuto.Resize(dpiPath, DestinationFilename2); + + using var bmpBest = SKBitmap.Decode(DestinationFilename); + using var bmpAuto = SKBitmap.Decode(DestinationFilename2); + + Assert.Equal(bmpBest.Width, bmpAuto.Width); + Assert.Equal(bmpBest.Height, bmpAuto.Height); + Assert.Equal(bmpBest.Pixels, bmpAuto.Pixels); + } + finally + { + File.Delete(sourceFilename); + } + } + + [Fact] + public void FastestQualityAppliesSvgTintOnceWhenUpscaling() + { + var sourceFilename = Path.GetTempFileName(); + + try + { + File.WriteAllText(sourceFilename, + """ + + + + """); + + var info = new ResizeImageInfo(); + info.Filename = sourceFilename; + info.BaseSize = new SKSize(64, 64); + info.TintColor = SKColors.Red.WithAlpha(127); + info.Quality = ResizeQuality.Fastest; + var tools = new SkiaSharpSvgTools(info, Logger); + var dpiPath = new DpiPath("", 1); + + tools.Resize(dpiPath, DestinationFilename); + + using var resultImage = SKBitmap.Decode(DestinationFilename); + using var pixmap = resultImage.PeekPixels(); + Assert.Equal(SKColors.Red.WithAlpha(127), pixmap.GetPixelColor(32, 32)); + } + finally + { + File.Delete(sourceFilename); + } } [Theory] From b80fcffb92bcf20651e40dd54752eac6ae3750a9 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 15 Jul 2026 11:59:21 +0200 Subject: [PATCH 17/28] Guard in-tree imports on Core buildtasks Avoid import-not-found failures when the in-tree .buildtasks directory is partially populated. Keep the existing friendly buildtasks diagnostic for incomplete Core buildtask outputs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Maui.InTree.props | 11 ++++++++--- src/Maui.InTree.targets | 13 +++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Maui.InTree.props b/src/Maui.InTree.props index 31d2fdf4a949..0473ac0ceaa9 100644 --- a/src/Maui.InTree.props +++ b/src/Maui.InTree.props @@ -1,10 +1,15 @@ - + + <_MauiCoreBuildTasksProps>$(_MauiBuildTasksLocation)Microsoft.Maui.Core.props + <_MauiCoreBuildTasksProps Condition="'$(_MauiTargetPlatformIsWindows)' == 'true'">$(_MauiBuildTasksLocation)net$(_MauiMinimumSupportedDotNetTfm)-windows$(MinimumWindowsTargetFrameworkVersion)\Microsoft.Maui.Core.props + <_MauiBuildTasksPropsReady Condition="Exists('$(_MauiCoreBuildTasksProps)') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Controls.Build.Tasks.dll') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Resizetizer.dll')">true + + + - - + diff --git a/src/Maui.InTree.targets b/src/Maui.InTree.targets index b60ae230421a..7bdbb4568880 100644 --- a/src/Maui.InTree.targets +++ b/src/Maui.InTree.targets @@ -13,11 +13,16 @@ - + + <_MauiCoreBuildTasksTargets>$(_MauiBuildTasksLocation)Microsoft.Maui.Core.targets + <_MauiCoreBuildTasksTargets Condition="'$(_MauiTargetPlatformIsWindows)' == 'true'">$(_MauiBuildTasksLocation)net$(_MauiMinimumSupportedDotNetTfm)-windows$(MinimumWindowsTargetFrameworkVersion)\Microsoft.Maui.Core.targets + <_MauiBuildTasksTargetsReady Condition="Exists('$(_MauiCoreBuildTasksTargets)') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Core.After.targets') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Controls.Build.Tasks.dll') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Resizetizer.dll')">true + + + - - + @@ -31,7 +36,7 @@ - + + + """); + + var info = new ResizeImageInfo(); + info.Filename = sourceFilename; + info.BaseSize = new SKSize(33, 33); + info.Quality = ResizeQuality.Fastest; + var tools = new SkiaSharpSvgTools(info, Logger); + var dpiPath = new DpiPath("", 1); + + tools.Resize(dpiPath, DestinationFilename); + + using var resultImage = SKBitmap.Decode(DestinationFilename); + Assert.Equal(33, resultImage.Width); + Assert.Equal(33, resultImage.Height); + Assert.True(resultImage.GetPixel(32, 32).Alpha > 0); + } + finally + { + File.Delete(sourceFilename); + } + } + [Theory] [InlineData("Auto")] [InlineData("Best")] From e29b54f3b510610e451df195393db2d182fcf45c Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 15 Jul 2026 14:04:53 +0200 Subject: [PATCH 19/28] Address final ResizeQuality review comments Preserve Auto's existing tinted SVG raster path, keep in-tree target imports gated on ready props, fix the sample-project capability condition, and improve image decode test diagnostics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Maui.InTree.targets | 4 ++-- src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs | 5 ++++- src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Maui.InTree.targets b/src/Maui.InTree.targets index 7bdbb4568880..804c43911bf5 100644 --- a/src/Maui.InTree.targets +++ b/src/Maui.InTree.targets @@ -10,7 +10,7 @@ - + @@ -19,7 +19,7 @@ <_MauiBuildTasksTargetsReady Condition="Exists('$(_MauiCoreBuildTasksTargets)') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Core.After.targets') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Controls.Build.Tasks.dll') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Resizetizer.dll')">true - + diff --git a/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs b/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs index 141b1d8857dc..75217ab99fb9 100644 --- a/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs +++ b/src/SingleProject/Resizetizer/src/SkiaSharpSvgTools.cs @@ -55,7 +55,10 @@ public override void DrawUnscaled(SKCanvas canvas, float scale) var cvn = surface.Canvas; cvn.Clear(SKColors.Transparent); - cvn.DrawPicture(svg.Picture); + if (Quality == ResizeQuality.Auto) + cvn.DrawPicture(svg.Picture, Paint); + else + cvn.DrawPicture(svg.Picture); // convert it all into an image using var img = surface.Snapshot(); diff --git a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs index 3fffaafcf954..bae77e47dfee 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs @@ -66,6 +66,7 @@ protected SKColor[] ReadPixels(string file) { file = Path.Combine(DestinationDirectory, file); using var bitmap = SKBitmap.Decode(file); + Assert.NotNull(bitmap); return bitmap.Pixels.ToArray(); } From 0a80a67ae81d8f3e0fc29681d216a4b54472431d Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 15 Jul 2026 15:51:06 +0200 Subject: [PATCH 20/28] Fix adaptive icon foreground comment typo Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Resizetizer/src/AndroidAdaptiveIconGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs index 10676b3690e0..56e87d7bf8e8 100644 --- a/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs +++ b/src/SingleProject/Resizetizer/src/AndroidAdaptiveIconGenerator.cs @@ -134,7 +134,7 @@ void ProcessForeground(List results, DirectoryInfo fullInterme if (foregroundExists) { - // resize the forground + // resize the foreground var tools = SkiaSharpTools.Create(Info.ForegroundIsVector, Info.ForegroundFilename, dpi.Size, null, Info.TintColor, Info.Quality, Logger); tools.Resize(dpi, destination, Info.ForegroundScale, dpiSizeIsAbsolute: true); } From 99f27eef58b9af46802c61ad3a0e8f2b95b0e961 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Wed, 15 Jul 2026 18:01:18 +0200 Subject: [PATCH 21/28] Remove unused Resizer using Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/SingleProject/Resizetizer/src/Resizer.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/SingleProject/Resizetizer/src/Resizer.cs b/src/SingleProject/Resizetizer/src/Resizer.cs index bfac7c7a8b40..4da91f852cc2 100644 --- a/src/SingleProject/Resizetizer/src/Resizer.cs +++ b/src/SingleProject/Resizetizer/src/Resizer.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using SkiaSharp; From 2cecde00d197db5dfbaacfff1d4ad315dad0c0c4 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Mon, 3 Aug 2026 11:52:24 +0200 Subject: [PATCH 22/28] Fix net11 device test compile errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec56cb07-d0e4-49cf-90fc-7c86d816e36a --- src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs | 2 +- .../Elements/Shell/ShellHandlerSubclasses.Android.cs | 2 +- .../DeviceTests/Elements/TabbedPage/TabbedPageTests.iOS.cs | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs index 421e7ee1c880..abac26a5bb5a 100644 --- a/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs +++ b/src/Controls/tests/DeviceTests/Elements/Modal/ModalTests.cs @@ -32,7 +32,7 @@ namespace Microsoft.Maui.DeviceTests [Trait(RendererHandlerVariant.TraitName, RendererHandlerVariant.AndroidShellRenderer)] // See RendererHandlerVariant.cs public partial class ModalTests : ControlsHandlerTestBase { - void SetupBuilder(bool includeNavigationViewHandler = true) + protected virtual void SetupBuilder(bool includeNavigationViewHandler = true) { EnsureHandlerCreated(builder => { diff --git a/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs b/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs index 2c91281c9439..359d43b2fa9f 100644 --- a/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs +++ b/src/Controls/tests/DeviceTests/Elements/Shell/ShellHandlerSubclasses.Android.cs @@ -99,7 +99,7 @@ MaterialToolbar GetShellHandlerToolbar(IElementHandler handler) [Trait(RendererHandlerVariant.TraitName, RendererHandlerVariant.AndroidShellHandler)] // See RendererHandlerVariant.cs public partial class ModalHandlerTests : ModalTests { - protected override void SetupBuilder() + protected override void SetupBuilder(bool includeNavigationViewHandler = true) { EnsureHandlerCreated(builder => { diff --git a/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.iOS.cs index f767988e5663..5aa6a91404b4 100644 --- a/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/TabbedPage/TabbedPageTests.iOS.cs @@ -7,6 +7,7 @@ using Microsoft.Maui.Controls.Handlers.Compatibility; using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; +using Microsoft.Maui.Hosting; using Microsoft.Maui.Platform; using Microsoft.Maui.DeviceTests.Stubs; using UIKit; From bc8afedbdff527819fde2e1fb23fab7476bc24ed Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Mon, 3 Aug 2026 13:07:02 +0200 Subject: [PATCH 23/28] Skip Avalonia template builds until packages are available Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec56cb07-d0e4-49cf-90fc-7c86d816e36a --- .../SimpleTemplateTest.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/SimpleTemplateTest.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/SimpleTemplateTest.cs index fd35c8aaddec..dc133de1d2f1 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/SimpleTemplateTest.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/SimpleTemplateTest.cs @@ -5,6 +5,8 @@ namespace Microsoft.Maui.IntegrationTests; [Trait("Category", "Build")] public class SimpleTemplateTest : BaseTemplateTests { + const string AvaloniaBuildSkipReason = "Avalonia packages are not available on dotnet-public. See https://github.com/dotnet/maui/pull/35950"; + public SimpleTemplateTest(IntegrationTestFixture fixture, ITestOutputHelper output) : base(fixture, output) { } [Theory] @@ -18,8 +20,8 @@ public SimpleTemplateTest(IntegrationTestFixture fixture, ITestOutputHelper outp //Debug not ready yet //[InlineData("maui", DotNetCurrent, "Debug", false, "--sample-content", "UseMonoRuntime=false")] [InlineData("maui", DotNetCurrent, "Release", false, "--sample-content", "UseMonoRuntime=false EnablePreviewFeatures=true")] - [InlineData("maui", DotNetCurrent, "Debug", false, "--with-avalonia", "")] - [InlineData("maui", DotNetCurrent, "Release", false, "--with-avalonia", "TrimMode=partial")] + [InlineData("maui", DotNetCurrent, "Debug", false, "--with-avalonia", "", Skip = AvaloniaBuildSkipReason)] + [InlineData("maui", DotNetCurrent, "Release", false, "--with-avalonia", "TrimMode=partial", Skip = AvaloniaBuildSkipReason)] // [InlineData("maui-blazor", DotNetPrevious, "Debug", false, "", "")] // [InlineData("maui-blazor", DotNetPrevious, "Release", false, "", "")] [InlineData("maui-blazor", DotNetCurrent, "Debug", false, "", "")] @@ -366,7 +368,7 @@ public void WithAvaloniaAddsHandlersAndDesktopHead() var projectDir = TestDirectory; var projectFile = Path.Combine(projectDir, $"{Path.GetFileName(projectDir)}.csproj"); - Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, "--with-avalonia", output: _output), + Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, "--with-avalonia --no-restore", output: _output), "Unable to create template maui with --with-avalonia. Check test output for errors."); var csproj = File.ReadAllText(projectFile); @@ -390,7 +392,7 @@ public void WithoutAvaloniaHasNoAvaloniaContent() var projectDir = TestDirectory; var projectFile = Path.Combine(projectDir, $"{Path.GetFileName(projectDir)}.csproj"); - Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, output: _output), + Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, "--no-restore", output: _output), "Unable to create template maui. Check test output for errors."); var csproj = File.ReadAllText(projectFile); @@ -408,7 +410,7 @@ public void WithAvaloniaIsIgnoredWhenSampleContentIncluded() var projectFile = Path.Combine(projectDir, $"{Path.GetFileName(projectDir)}.csproj"); // --with-avalonia is gated on the blank app: combining it with sample content must not wire Avalonia in. - Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, "--with-avalonia --sample-content", output: _output), + Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, "--with-avalonia --sample-content --no-restore", output: _output), "Unable to create template maui with --with-avalonia --sample-content. Check test output for errors."); var csproj = File.ReadAllText(projectFile); From b0d23524aea1a164fd3118de4a51a7dd4f1d265d Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Mon, 3 Aug 2026 15:07:22 +0200 Subject: [PATCH 24/28] Serialize dispatcher provider unit tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec56cb07-d0e4-49cf-90fc-7c86d816e36a --- .../tests/UnitTests/Dispatching/DispatcherTests.cs | 1 + .../tests/UnitTests/Hosting/MainThreadBridgeTests.cs | 1 + .../TestClasses/DispatcherProviderTestCollection.cs | 11 +++++++++++ 3 files changed, 13 insertions(+) create mode 100644 src/Core/tests/UnitTests/TestClasses/DispatcherProviderTestCollection.cs diff --git a/src/Core/tests/UnitTests/Dispatching/DispatcherTests.cs b/src/Core/tests/UnitTests/Dispatching/DispatcherTests.cs index 6521366e99a4..67617586711e 100644 --- a/src/Core/tests/UnitTests/Dispatching/DispatcherTests.cs +++ b/src/Core/tests/UnitTests/Dispatching/DispatcherTests.cs @@ -10,6 +10,7 @@ namespace Microsoft.Maui.UnitTests.Dispatching // Technically these tests are useless because they cannot test shipping code as they are // none of the platforms. However, they sort of do test the test dispatcher... [Category(TestCategory.Core, TestCategory.Dispatching)] + [Collection(nameof(DispatcherProviderTestCollection))] public class DispatcherTests : IDisposable { DispatcherProviderStub _dispatcherProvider; diff --git a/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs index 5ad82f9cfe28..87ddb150affe 100644 --- a/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs +++ b/src/Core/tests/UnitTests/Hosting/MainThreadBridgeTests.cs @@ -10,6 +10,7 @@ namespace Microsoft.Maui.UnitTests.Hosting { [Category(TestCategory.Core, TestCategory.Hosting)] + [Collection(nameof(DispatcherProviderTestCollection))] public class MainThreadBridgeTests : IDisposable { public MainThreadBridgeTests() diff --git a/src/Core/tests/UnitTests/TestClasses/DispatcherProviderTestCollection.cs b/src/Core/tests/UnitTests/TestClasses/DispatcherProviderTestCollection.cs new file mode 100644 index 000000000000..118784ebf873 --- /dev/null +++ b/src/Core/tests/UnitTests/TestClasses/DispatcherProviderTestCollection.cs @@ -0,0 +1,11 @@ +#nullable enable + +using Xunit; + +namespace Microsoft.Maui.UnitTests +{ + [CollectionDefinition(nameof(DispatcherProviderTestCollection), DisableParallelization = true)] + public class DispatcherProviderTestCollection + { + } +} From 3b29bba4aa9738a8d82ade6f27e94082a1c54c00 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Mon, 3 Aug 2026 17:16:24 +0200 Subject: [PATCH 25/28] Check in-tree build task imports before importing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec56cb07-d0e4-49cf-90fc-7c86d816e36a --- src/Maui.InTree.props | 14 ++++++++------ src/Maui.InTree.targets | 15 +++++++++------ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Maui.InTree.props b/src/Maui.InTree.props index 0473ac0ceaa9..8ec36883a230 100644 --- a/src/Maui.InTree.props +++ b/src/Maui.InTree.props @@ -3,7 +3,12 @@ <_MauiCoreBuildTasksProps>$(_MauiBuildTasksLocation)Microsoft.Maui.Core.props <_MauiCoreBuildTasksProps Condition="'$(_MauiTargetPlatformIsWindows)' == 'true'">$(_MauiBuildTasksLocation)net$(_MauiMinimumSupportedDotNetTfm)-windows$(MinimumWindowsTargetFrameworkVersion)\Microsoft.Maui.Core.props - <_MauiBuildTasksPropsReady Condition="Exists('$(_MauiCoreBuildTasksProps)') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Controls.Build.Tasks.dll') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Resizetizer.dll')">true + <_MauiControlsBuildTasksProps>$(_MauiBuildTasksLocation)Microsoft.Maui.Controls.Build.Tasks.props + <_MauiControlsBuildTasksProps Condition="'$(_MauiTargetPlatformIsiOS)' == 'true'">$(_MauiBuildTasksLocation)net6.0-ios10.0\Microsoft.Maui.Controls.Build.Tasks.props + <_MauiControlsBuildTasksProps Condition="'$(_MauiTargetPlatformIsMacCatalyst)' == 'true'">$(_MauiBuildTasksLocation)net6.0-maccatalyst13.1\Microsoft.Maui.Controls.Build.Tasks.props + <_MauiControlsBuildTasksProps Condition="'$(_MauiTargetPlatformIsWindows)' == 'true'">$(_MauiBuildTasksLocation)net6.0-windows10.0.17763.0\Microsoft.Maui.Controls.Build.Tasks.props + <_MauiResizetizerBuildTasksProps>$(_MauiBuildTasksLocation)Microsoft.Maui.Resizetizer.props + <_MauiBuildTasksPropsReady Condition="Exists('$(_MauiCoreBuildTasksProps)') and Exists('$(_MauiControlsBuildTasksProps)') and Exists('$(_MauiResizetizerBuildTasksProps)') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Controls.Build.Tasks.dll') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Resizetizer.dll')">true @@ -12,13 +17,10 @@ - - - - + - + diff --git a/src/Maui.InTree.targets b/src/Maui.InTree.targets index 804c43911bf5..f90ee981ecca 100644 --- a/src/Maui.InTree.targets +++ b/src/Maui.InTree.targets @@ -16,7 +16,13 @@ <_MauiCoreBuildTasksTargets>$(_MauiBuildTasksLocation)Microsoft.Maui.Core.targets <_MauiCoreBuildTasksTargets Condition="'$(_MauiTargetPlatformIsWindows)' == 'true'">$(_MauiBuildTasksLocation)net$(_MauiMinimumSupportedDotNetTfm)-windows$(MinimumWindowsTargetFrameworkVersion)\Microsoft.Maui.Core.targets - <_MauiBuildTasksTargetsReady Condition="Exists('$(_MauiCoreBuildTasksTargets)') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Core.After.targets') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Controls.Build.Tasks.dll') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Resizetizer.dll')">true + <_MauiControlsBuildTasksTargets>$(_MauiBuildTasksLocation)Microsoft.Maui.Controls.Build.Tasks.targets + <_MauiControlsBuildTasksTargets Condition="'$(_MauiTargetPlatformIsiOS)' == 'true'">$(_MauiBuildTasksLocation)net6.0-ios10.0\Microsoft.Maui.Controls.Build.Tasks.targets + <_MauiControlsBuildTasksTargets Condition="'$(_MauiTargetPlatformIsMacCatalyst)' == 'true'">$(_MauiBuildTasksLocation)net6.0-maccatalyst13.1\Microsoft.Maui.Controls.Build.Tasks.targets + <_MauiControlsBuildTasksTargets Condition="'$(_MauiTargetPlatformIsWindows)' == 'true'">$(_MauiBuildTasksLocation)net6.0-windows10.0.17763.0\Microsoft.Maui.Controls.Build.Tasks.targets + <_MauiCoreAfterBuildTasksTargets>$(_MauiBuildTasksLocation)Microsoft.Maui.Core.After.targets + <_MauiResizetizerBuildTasksTargets>$(_MauiBuildTasksLocation)Microsoft.Maui.Resizetizer.targets + <_MauiBuildTasksTargetsReady Condition="Exists('$(_MauiCoreBuildTasksTargets)') and Exists('$(_MauiControlsBuildTasksTargets)') and Exists('$(_MauiCoreAfterBuildTasksTargets)') and Exists('$(_MauiResizetizerBuildTasksTargets)') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Controls.Build.Tasks.dll') and Exists('$(_MauiBuildTasksLocation)Microsoft.Maui.Resizetizer.dll')">true @@ -25,13 +31,10 @@ - - - - + - + From 484a56226d69a30166866bf917ee78c5dcc2cc51 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 6 Aug 2026 20:54:35 +0200 Subject: [PATCH 26/28] Add empirical SVG resize quality test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec56cb07-d0e4-49cf-90fc-7c86d816e36a --- .../test/UnitTests/SkiaSharpSvgToolsTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs index d954b208391c..a41ebe6e87b3 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/SkiaSharpSvgToolsTests.cs @@ -533,6 +533,46 @@ public void FastestQualityAffectsSvgUpscaling() } } + [Fact] + public void FastestQualityAffectsCameraSvgUpscaling() + { + var dpiPath = new DpiPath("", 1); + + var infoFastest = new ResizeImageInfo(); + infoFastest.Filename = "images/camera.svg"; + infoFastest.BaseSize = new SKSize(2000, 2000); + infoFastest.Quality = ResizeQuality.Fastest; + var toolsFastest = new SkiaSharpSvgTools(infoFastest, Logger); + toolsFastest.Resize(dpiPath, DestinationFilename); + + var infoAuto = new ResizeImageInfo(); + infoAuto.Filename = "images/camera.svg"; + infoAuto.BaseSize = new SKSize(2000, 2000); + infoAuto.Quality = ResizeQuality.Auto; + var toolsAuto = new SkiaSharpSvgTools(infoAuto, Logger); + toolsAuto.Resize(dpiPath, DestinationFilename2); + + using var bmpFastest = SKBitmap.Decode(DestinationFilename); + using var bmpAuto = SKBitmap.Decode(DestinationFilename2); + + Assert.Equal(bmpFastest.Width, bmpAuto.Width); + Assert.Equal(bmpFastest.Height, bmpAuto.Height); + + var fastestPixels = bmpFastest.Pixels; + var autoPixels = bmpAuto.Pixels; + Assert.Equal(fastestPixels.Length, autoPixels.Length); + + var differentPixels = 0; + for (var i = 0; i < fastestPixels.Length; i++) + { + if (fastestPixels[i] != autoPixels[i]) + differentPixels++; + } + + Assert.True(differentPixels > 0, + $"SVG: Fastest and Auto should not produce byte-identical output when upscaling camera.svg. Differing pixels: {differentPixels} of {fastestPixels.Length}."); + } + [Fact] public void BestQualityPreservesSvgVectorOutputWhenUpscaling() { From 943428ab91e2636bccfe154265da21ac569f3f40 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 6 Aug 2026 22:46:36 +0200 Subject: [PATCH 27/28] Retry CI after infrastructure failure Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec56cb07-d0e4-49cf-90fc-7c86d816e36a From a6f7f810c7cf935c9bee55da49d22da8f062278d Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 7 Aug 2026 15:31:12 +0200 Subject: [PATCH 28/28] Preserve resize quality for themed splash assets Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec56cb07-d0e4-49cf-90fc-7c86d816e36a --- .../src/GenerateSplashAssetCatalog.cs | 1 + .../Resizetizer/src/ResizeImageInfo.cs | 1 + .../Resizetizer/test/UnitTests/BaseTest.cs | 4 +- .../GenerateSplashAndroidResourcesTests.cs | 34 +++++++++++++++++ .../GenerateSplashAssetCatalogTests.cs | 37 +++++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/SingleProject/Resizetizer/src/GenerateSplashAssetCatalog.cs b/src/SingleProject/Resizetizer/src/GenerateSplashAssetCatalog.cs index f492d0b74906..554cbec6828f 100644 --- a/src/SingleProject/Resizetizer/src/GenerateSplashAssetCatalog.cs +++ b/src/SingleProject/Resizetizer/src/GenerateSplashAssetCatalog.cs @@ -75,6 +75,7 @@ private static ResizeImageInfo CloneForAsset(ResizeImageInfo info, string alias) Resize = info.Resize, TintColor = info.TintColor, Color = info.Color, + Quality = info.Quality, }; private static string GetAssetExtension(ResizeImageInfo info) => diff --git a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs index 4b36571c732b..0fef74a3f826 100644 --- a/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs +++ b/src/SingleProject/Resizetizer/src/ResizeImageInfo.cs @@ -209,6 +209,7 @@ public ResizeImageInfo CreateDarkVariant(string? alias = null) ForegroundFilename = ForegroundFilename, ForegroundScale = ForegroundScale, MonochromeFilename = MonochromeFilename, + Quality = Quality, }; } } diff --git a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs index 93cb39a3231f..2bb7f708cdd7 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/BaseTest.cs @@ -98,7 +98,7 @@ protected SKColor[] ReadPixels(string file) return bitmap.Pixels.ToArray(); } - protected internal static void AssertPixelsDiffer(SKColor[] expected, SKColor[] actual, string because) + protected internal static int AssertPixelsDiffer(SKColor[] expected, SKColor[] actual, string because) { Assert.Equal(expected.Length, actual.Length); @@ -107,6 +107,8 @@ protected internal static void AssertPixelsDiffer(SKColor[] expected, SKColor[] Assert.True(differentPixels > minimumDifferentPixels, $"{because} Differing pixels: {differentPixels} of {expected.Length}; expected more than {minimumDifferentPixels}."); + + return differentPixels; } protected void AssertFileNotExists(string file) diff --git a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAndroidResourcesTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAndroidResourcesTests.cs index 84a17590fda0..9286990c07d5 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAndroidResourcesTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAndroidResourcesTests.cs @@ -107,6 +107,40 @@ public void DarkFileGeneratesNightQualifiedImages() AssertImageFile("maui_splash_image_v31.xml", _drawableNight_v31, "@drawable/camera"); } + [Fact] + public void ResizeQualityMetadataAffectsDarkNightImages() + { + var fastestSplash = new TaskItem("images/camera.png", new Dictionary + { + ["BaseSize"] = "64", + ["DarkFile"] = "images/camera.png", + ["ResizeQuality"] = "Fastest", + }); + + var task = GetNewTask(fastestSplash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileSize("drawable-night-mdpi/camera.png", 64, 64); + var fastestDarkPixels = ReadPixels("drawable-night-mdpi/camera.png"); + + var autoSplash = new TaskItem("images/camera.png", new Dictionary + { + ["BaseSize"] = "64", + ["DarkFile"] = "images/camera.png", + ["ResizeQuality"] = "Auto", + }); + + task = GetNewTask(autoSplash); + success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileSize("drawable-night-mdpi/camera.png", 64, 64); + + var autoDarkPixels = ReadPixels("drawable-night-mdpi/camera.png"); + var differentPixels = AssertPixelsDiffer(fastestDarkPixels, autoDarkPixels, + "Android dark/night splash output should honor ResizeQuality metadata during 1792-to-64 downscaling."); + Output.WriteLine($"Android dark/night Fastest vs Auto: {differentPixels} of {autoDarkPixels.Length} pixels differ."); + } + [Fact] public void DarkTintColorOnlyGeneratesTintedNightImage() { diff --git a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetCatalogTests.cs b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetCatalogTests.cs index 24ace2f5df30..faba92688f83 100644 --- a/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetCatalogTests.cs +++ b/src/SingleProject/Resizetizer/test/UnitTests/GenerateSplashAssetCatalogTests.cs @@ -103,6 +103,43 @@ public void RasterWithoutResizePreservesOriginalImageDimensions() AssertFileSize("Assets.xcassets/MauiSplashImage.imageset/MauiSplashImageDark@3x.png", 256, 256); } + [Theory] + [InlineData("MauiSplashImage.png", "light")] + [InlineData("MauiSplashImageDark.png", "dark")] + public void ResizeQualityMetadataAffectsImageAsset(string filename, string appearance) + { + var fastestSplash = new TaskItem("images/camera.png", new Dictionary + { + ["BaseSize"] = "64", + ["DarkFile"] = "images/camera.png", + ["ResizeQuality"] = "Fastest", + }); + + var task = GetNewTask(fastestSplash); + var success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + var output = $"Assets.xcassets/MauiSplashImage.imageset/{filename}"; + AssertFileSize(output, 64, 64); + var fastestPixels = ReadPixels(output); + + var autoSplash = new TaskItem("images/camera.png", new Dictionary + { + ["BaseSize"] = "64", + ["DarkFile"] = "images/camera.png", + ["ResizeQuality"] = "Auto", + }); + + task = GetNewTask(autoSplash); + success = task.Execute(); + Assert.True(success, LogErrorEvents.FirstOrDefault()?.Message); + AssertFileSize(output, 64, 64); + + var autoPixels = ReadPixels(output); + var differentPixels = AssertPixelsDiffer(fastestPixels, autoPixels, + $"Apple {appearance} splash output should honor ResizeQuality metadata during 1792-to-64 downscaling."); + Output.WriteLine($"Apple {appearance} Fastest vs Auto: {differentPixels} of {autoPixels.Length} pixels differ."); + } + [Fact] public void NonPngRasterWithoutResizeUsesMatchingAssetFilenames() {