From 6ef2d0b708cb3dcccb0fff52246106cf0b30b4d8 Mon Sep 17 00:00:00 2001 From: Julien Lebosquain Date: Tue, 7 Jul 2026 12:18:20 +0200 Subject: [PATCH] Implement Bitmap.Save with a format (#21455) * Implement Bitmap.Save with format options * Rename BitmapEncoderOptions.cs to match its type * Update API suppressions --- api/Avalonia.nupkg.xml | 36 ++++++ samples/GpuInterop/VulkanDemo/VulkanImage.cs | 3 +- .../Avalonia.Android/PlatformIconLoader.cs | 3 +- src/Android/Avalonia.Android/Stubs.cs | 3 +- src/Avalonia.Base/Media/Imaging/Bitmap.cs | 33 ++++- .../Media/Imaging/BitmapEncoderOptions.cs | 14 ++ src/Avalonia.Base/Media/Imaging/IBitmap.cs | 22 +--- .../Media/Imaging/JpegBitmapEncoderOptions.cs | 18 +++ .../Media/Imaging/PngBitmapEncoderOptions.cs | 20 +++ src/Avalonia.Base/Platform/IBitmapImpl.cs | 23 +--- src/Avalonia.Native/Cursor.cs | 2 +- ...ansferItemToAvnClipboardDataItemWrapper.cs | 3 +- src/Avalonia.Native/IAvnMenuItem.cs | 2 +- src/Avalonia.Native/IconLoader.cs | 5 +- .../Clipboard/WaylandClipboardImpl.cs | 2 +- .../Selections/SelectionDataProvider.cs | 3 +- src/Avalonia.X11/X11IconLoader.cs | 4 +- src/Browser/Avalonia.Browser/ClipboardImpl.cs | 3 +- src/Browser/Avalonia.Browser/Cursor.cs | 3 +- .../HeadlessPlatformRenderInterface.cs | 9 +- .../Helpers/ImageSavingHelper.cs | 121 +++++++++++++----- src/Skia/Avalonia.Skia/ImmutableBitmap.cs | 11 +- src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs | 20 +-- src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs | 19 +-- .../Avalonia.Win32/OleDataObjectHelper.cs | 2 +- src/Windows/Avalonia.Win32/Win32Platform.cs | 8 +- .../Avalonia.iOS/Clipboard/ClipboardImpl.cs | 3 +- src/iOS/Avalonia.iOS/Stubs.cs | 10 +- .../Avalonia.RenderTests/Media/BitmapTests.cs | 6 +- .../Avalonia.RenderTests/TestRenderHelper.cs | 5 +- .../Media/BitmapSaveTests.cs | 108 ++++++++++++++++ 31 files changed, 376 insertions(+), 148 deletions(-) create mode 100644 src/Avalonia.Base/Media/Imaging/BitmapEncoderOptions.cs create mode 100644 src/Avalonia.Base/Media/Imaging/JpegBitmapEncoderOptions.cs create mode 100644 src/Avalonia.Base/Media/Imaging/PngBitmapEncoderOptions.cs create mode 100644 tests/Avalonia.Skia.UnitTests/Media/BitmapSaveTests.cs diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 1751d863e6..ce13834a0b 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -19,6 +19,18 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,System.Nullable{System.Int32}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IBitmapImpl.Save(System.String,System.Nullable{System.Int32}) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0002 M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double,Avalonia.Size) @@ -79,6 +91,18 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0002 + M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,System.Nullable{System.Int32}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Platform.IBitmapImpl.Save(System.String,System.Nullable{System.Int32}) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0002 M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double,Avalonia.Size) @@ -121,6 +145,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.OpenGL.dll current/Avalonia/lib/net8.0/Avalonia.OpenGL.dll + + CP0006 + M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,Avalonia.Media.Imaging.BitmapEncoderOptions) + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0006 M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions) @@ -139,6 +169,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0006 + M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,Avalonia.Media.Imaging.BitmapEncoderOptions) + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0006 M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions) diff --git a/samples/GpuInterop/VulkanDemo/VulkanImage.cs b/samples/GpuInterop/VulkanDemo/VulkanImage.cs index 216cb3f815..15d2952cfe 100644 --- a/samples/GpuInterop/VulkanDemo/VulkanImage.cs +++ b/samples/GpuInterop/VulkanDemo/VulkanImage.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; using Avalonia; +using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Vulkan; using Silk.NET.Core.Native; @@ -355,7 +356,7 @@ public unsafe class VulkanImage : IDisposable using var b = new Avalonia.Media.Imaging.Bitmap(PixelFormat.Bgra8888, AlphaFormat.Premul, pSurface, new PixelSize(w, h), new Vector(96, 96), (int)sstride); - b.Save(path); + b.Save(path, PngBitmapEncoderOptions.Default); NativeMethods.IOSurfaceUnlock(surf, 0, IntPtr.Zero); return; diff --git a/src/Android/Avalonia.Android/PlatformIconLoader.cs b/src/Android/Avalonia.Android/PlatformIconLoader.cs index f557685dd2..5b5705753e 100644 --- a/src/Android/Avalonia.Android/PlatformIconLoader.cs +++ b/src/Android/Avalonia.Android/PlatformIconLoader.cs @@ -1,4 +1,5 @@ using System.IO; +using Avalonia.Media.Imaging; using Avalonia.Platform; namespace Avalonia.Android @@ -9,7 +10,7 @@ namespace Avalonia.Android { using (var stream = new MemoryStream()) { - bitmap.Save(stream); + bitmap.Save(stream, PngBitmapEncoderOptions.Default); return LoadIcon(stream); } } diff --git a/src/Android/Avalonia.Android/Stubs.cs b/src/Android/Avalonia.Android/Stubs.cs index 0620919e4d..57de1b1b10 100644 --- a/src/Android/Avalonia.Android/Stubs.cs +++ b/src/Android/Avalonia.Android/Stubs.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using Avalonia.Media.Imaging; using Avalonia.Platform; namespace Avalonia.Android @@ -22,7 +23,7 @@ namespace Avalonia.Android { using (var stream = new MemoryStream()) { - bitmap.Save(stream); + bitmap.Save(stream, PngBitmapEncoderOptions.Default); return LoadIcon(stream); } } diff --git a/src/Avalonia.Base/Media/Imaging/Bitmap.cs b/src/Avalonia.Base/Media/Imaging/Bitmap.cs index 87ef0a234f..e04603914c 100644 --- a/src/Avalonia.Base/Media/Imaging/Bitmap.cs +++ b/src/Avalonia.Base/Media/Imaging/Bitmap.cs @@ -147,7 +147,7 @@ namespace Avalonia.Media.Imaging IRef IBitmap.PlatformImpl => PlatformImpl; /// - /// Saves the bitmap to a file. + /// Saves the bitmap to a file, in PNG format. /// /// The filename. /// @@ -155,23 +155,44 @@ namespace Avalonia.Media.Imaging /// The quality value is interpreted from 0 - 100. If quality is null the default quality /// setting is applied. /// + [Obsolete($"Use the overload accepting {nameof(BitmapEncoderOptions)} instead.")] public void Save(string fileName, int? quality = null) + => Save(fileName, PngBitmapEncoderOptions.Default); + + /// + /// Saves the bitmap to a file with the specified options. + /// + /// The filename. + /// + /// The options specifying the format and settings to use. + /// Typical usages include and . + /// + public void Save(string fileName, BitmapEncoderOptions options) { - PlatformImpl.Item.Save(fileName, quality); + using var stream = File.Create(fileName); + + Save(stream, options); } /// - /// Saves the bitmap to a stream. + /// Saves the bitmap to a stream, in PNG format. /// /// The stream. /// - /// The optional quality for compression. - /// The quality value is interpreted from 0 - 100. If quality is null the default quality + /// The optional quality for compression. + /// The quality value is interpreted from 0 - 100. If quality is null the default quality /// setting is applied. /// + [Obsolete($"Use the overload accepting {nameof(BitmapEncoderOptions)} instead.")] public void Save(Stream stream, int? quality = null) { - PlatformImpl.Item.Save(stream, quality); + PlatformImpl.Item.Save(stream, PngBitmapEncoderOptions.Default); + } + + /// + public void Save(Stream stream, BitmapEncoderOptions options) + { + PlatformImpl.Item.Save(stream, options); } public virtual PixelFormat? Format => (PlatformImpl.Item as IReadableBitmapImpl)?.Format; diff --git a/src/Avalonia.Base/Media/Imaging/BitmapEncoderOptions.cs b/src/Avalonia.Base/Media/Imaging/BitmapEncoderOptions.cs new file mode 100644 index 0000000000..e75e2ab65c --- /dev/null +++ b/src/Avalonia.Base/Media/Imaging/BitmapEncoderOptions.cs @@ -0,0 +1,14 @@ +using System.IO; + +namespace Avalonia.Media.Imaging; + +/// +/// Represents the options used while saving a bitmap using . +/// Common implementations are , . +/// +public abstract class BitmapEncoderOptions +{ + internal BitmapEncoderOptions() + { + } +} diff --git a/src/Avalonia.Base/Media/Imaging/IBitmap.cs b/src/Avalonia.Base/Media/Imaging/IBitmap.cs index f2cbc7a11e..e2e1611383 100644 --- a/src/Avalonia.Base/Media/Imaging/IBitmap.cs +++ b/src/Avalonia.Base/Media/Imaging/IBitmap.cs @@ -32,25 +32,13 @@ namespace Avalonia.Media.Imaging IRef PlatformImpl { get; } /// - /// Saves the bitmap to a file. - /// - /// The filename. - /// - /// The optional quality for compression if supported by the specific backend. - /// The quality value is interpreted from 0 - 100. If quality is null the default quality - /// setting of the backend is applied. - /// - void Save(string fileName, int? quality = null); - - /// - /// Saves the bitmap to a stream in png format. + /// Saves the bitmap to a stream with the specified options. /// /// The stream. - /// - /// The optional quality for compression if supported by the specific backend. - /// The quality value is interpreted from 0 - 100. If quality is null the default quality - /// setting of the backend is applied. + /// + /// The options specifying the format and settings to use. + /// Typical usages include and . /// - void Save(Stream stream, int? quality = null); + void Save(Stream stream, BitmapEncoderOptions options); } } diff --git a/src/Avalonia.Base/Media/Imaging/JpegBitmapEncoderOptions.cs b/src/Avalonia.Base/Media/Imaging/JpegBitmapEncoderOptions.cs new file mode 100644 index 0000000000..9745c60f92 --- /dev/null +++ b/src/Avalonia.Base/Media/Imaging/JpegBitmapEncoderOptions.cs @@ -0,0 +1,18 @@ +namespace Avalonia.Media.Imaging; + +/// +/// Represents the options used to save a bitmap in the JPEG format. +/// +public sealed class JpegBitmapEncoderOptions : BitmapEncoderOptions +{ + /// + /// Gets the default JPEG encoder options. + /// + public static JpegBitmapEncoderOptions Default { get; } = new(); + + /// + /// Gets or sets the quality to use, from 0 (lowest) to 100 (highest). + /// Defaults to 100. + /// + public int Quality { get; init; } = 100; +} diff --git a/src/Avalonia.Base/Media/Imaging/PngBitmapEncoderOptions.cs b/src/Avalonia.Base/Media/Imaging/PngBitmapEncoderOptions.cs new file mode 100644 index 0000000000..0931ec9b8c --- /dev/null +++ b/src/Avalonia.Base/Media/Imaging/PngBitmapEncoderOptions.cs @@ -0,0 +1,20 @@ +using System.IO.Compression; + +namespace Avalonia.Media.Imaging; + +/// +/// Represents the options used to save a bitmap in the PNG format. +/// +public sealed class PngBitmapEncoderOptions : BitmapEncoderOptions +{ + /// + /// Gets the default PNG encoder options. + /// + public static PngBitmapEncoderOptions Default { get; } = new(); + + /// + /// Gets or sets the compression level to use. + /// Defaults to . + /// + public CompressionLevel CompressionLevel { get; init; } = CompressionLevel.Optimal; +} diff --git a/src/Avalonia.Base/Platform/IBitmapImpl.cs b/src/Avalonia.Base/Platform/IBitmapImpl.cs index 586ac76a3a..bec184e6aa 100644 --- a/src/Avalonia.Base/Platform/IBitmapImpl.cs +++ b/src/Avalonia.Base/Platform/IBitmapImpl.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using Avalonia.Media.Imaging; using Avalonia.Metadata; namespace Avalonia.Platform @@ -26,25 +27,13 @@ namespace Avalonia.Platform int Version { get; } /// - /// Saves the bitmap to a file. - /// - /// The filename. - /// - /// The optional quality for compression if supported by the specific backend. - /// The quality value is interpreted from 0 - 100. If quality is null the default quality - /// setting of the backend is applied. - /// - void Save(string fileName, int? quality = null); - - /// - /// Saves the bitmap to a stream in png format. + /// Saves the bitmap to a stream with the specified options. /// /// The stream. - /// - /// The optional quality for compression if supported by the specific backend. - /// The quality value is interpreted from 0 - 100. If quality is null the default quality - /// setting of the backend is applied. + /// + /// The options specifying the format and settings to use. + /// Typical usages include and . /// - void Save(Stream stream, int? quality = null); + void Save(Stream stream, BitmapEncoderOptions options); } } diff --git a/src/Avalonia.Native/Cursor.cs b/src/Avalonia.Native/Cursor.cs index 58306b5698..37bb18feb6 100644 --- a/src/Avalonia.Native/Cursor.cs +++ b/src/Avalonia.Native/Cursor.cs @@ -45,7 +45,7 @@ namespace Avalonia.Native { using(var ms = new MemoryStream()) { - cursor.Save(ms); + cursor.Save(ms, PngBitmapEncoderOptions.Default); var imageData = ms.ToArray(); diff --git a/src/Avalonia.Native/DataTransferItemToAvnClipboardDataItemWrapper.cs b/src/Avalonia.Native/DataTransferItemToAvnClipboardDataItemWrapper.cs index 4b659a3cc4..ccaae66379 100644 --- a/src/Avalonia.Native/DataTransferItemToAvnClipboardDataItemWrapper.cs +++ b/src/Avalonia.Native/DataTransferItemToAvnClipboardDataItemWrapper.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using Avalonia.Input; using Avalonia.Logging; +using Avalonia.Media.Imaging; using Avalonia.Native.Interop; namespace Avalonia.Native; @@ -35,7 +36,7 @@ internal sealed class DataTransferItemToAvnClipboardDataItemWrapper(IDataTransfe if (_item.TryGetValue(DataFormat.Bitmap) is { } bitmap) { var memoryStream = new MemoryStream(); - bitmap.Save(memoryStream); + bitmap.Save(memoryStream, PngBitmapEncoderOptions.Default); memoryStream.Seek(0, SeekOrigin.Begin); return new StreamValue(memoryStream); } diff --git a/src/Avalonia.Native/IAvnMenuItem.cs b/src/Avalonia.Native/IAvnMenuItem.cs index 441b974df2..65b8563055 100644 --- a/src/Avalonia.Native/IAvnMenuItem.cs +++ b/src/Avalonia.Native/IAvnMenuItem.cs @@ -56,7 +56,7 @@ namespace Avalonia.Native.Interop.Impl { using(var ms = new MemoryStream()) { - icon.Save(ms); + icon.Save(ms, PngBitmapEncoderOptions.Default); var imageData = ms.ToArray(); diff --git a/src/Avalonia.Native/IconLoader.cs b/src/Avalonia.Native/IconLoader.cs index dbc6379bd8..104ec2078c 100644 --- a/src/Avalonia.Native/IconLoader.cs +++ b/src/Avalonia.Native/IconLoader.cs @@ -1,4 +1,5 @@ using System.IO; +using Avalonia.Media.Imaging; using Avalonia.Platform; namespace Avalonia.Native @@ -20,7 +21,7 @@ namespace Avalonia.Native public void Save(Stream outputStream) { - _bitmap.Save(outputStream); + _bitmap.Save(outputStream, PngBitmapEncoderOptions.Default); } } @@ -39,7 +40,7 @@ namespace Avalonia.Native public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) { var ms = new MemoryStream(); - bitmap.Save(ms); + bitmap.Save(ms, PngBitmapEncoderOptions.Default); ms.Seek(0, SeekOrigin.Begin); return LoadIcon(ms); } diff --git a/src/Avalonia.Wayland/Clipboard/WaylandClipboardImpl.cs b/src/Avalonia.Wayland/Clipboard/WaylandClipboardImpl.cs index 5c82470ee6..70a7c6d315 100644 --- a/src/Avalonia.Wayland/Clipboard/WaylandClipboardImpl.cs +++ b/src/Avalonia.Wayland/Clipboard/WaylandClipboardImpl.cs @@ -109,7 +109,7 @@ class WaylandClipboardImpl : IOwnedClipboardImpl await Task.Run(() => { using (bitmapRef) - bitmapRef.Item.Save(stream); + bitmapRef.Item.Save(stream, PngBitmapEncoderOptions.Default); }).ConfigureAwait(false); return; } diff --git a/src/Avalonia.X11/Selections/SelectionDataProvider.cs b/src/Avalonia.X11/Selections/SelectionDataProvider.cs index c1a7be87fb..846a25bb6e 100644 --- a/src/Avalonia.X11/Selections/SelectionDataProvider.cs +++ b/src/Avalonia.X11/Selections/SelectionDataProvider.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading.Tasks; using Avalonia.Input; using Avalonia.Logging; +using Avalonia.Media.Imaging; using Avalonia.X11.Selections.Clipboard; using static Avalonia.X11.XLib; @@ -154,7 +155,7 @@ internal abstract class SelectionDataProvider : IDisposable return null; using var stream = new MemoryStream(); - bitmap.Save(stream); + bitmap.Save(stream, PngBitmapEncoderOptions.Default); return stream.ToArray(); } diff --git a/src/Avalonia.X11/X11IconLoader.cs b/src/Avalonia.X11/X11IconLoader.cs index ab0946f531..caa5662e00 100644 --- a/src/Avalonia.X11/X11IconLoader.cs +++ b/src/Avalonia.X11/X11IconLoader.cs @@ -22,7 +22,7 @@ namespace Avalonia.X11 public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) { var ms = new MemoryStream(); - bitmap.Save(ms); + bitmap.Save(ms, PngBitmapEncoderOptions.Default); ms.Position = 0; return LoadIcon(ms); } @@ -81,7 +81,7 @@ namespace Avalonia.X11 fbp[fbr + x] = Data[r + x + 2].ToUInt32(); } } - wr.Save(outputStream); + wr.Save(outputStream, PngBitmapEncoderOptions.Default); } } } diff --git a/src/Browser/Avalonia.Browser/ClipboardImpl.cs b/src/Browser/Avalonia.Browser/ClipboardImpl.cs index 50cec8721c..1c39f62742 100644 --- a/src/Browser/Avalonia.Browser/ClipboardImpl.cs +++ b/src/Browser/Avalonia.Browser/ClipboardImpl.cs @@ -6,6 +6,7 @@ using Avalonia.Browser.Interop; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Logging; +using Avalonia.Media.Imaging; using static Avalonia.Browser.BrowserDataFormatHelper; using static Avalonia.Browser.Interop.InputHelper; @@ -78,7 +79,7 @@ internal sealed class ClipboardImpl : IClipboardImpl if (bitmap != null) { using var stream = new MemoryStream(); - bitmap.Save(stream); + bitmap.Save(stream, PngBitmapEncoderOptions.Default); writeableItem ??= CreateWriteableClipboardItem(source); AddBytesToWriteableClipboardItem(writeableItem, formatString, stream.ToArray()); diff --git a/src/Browser/Avalonia.Browser/Cursor.cs b/src/Browser/Avalonia.Browser/Cursor.cs index bf782f2126..39db876376 100644 --- a/src/Browser/Avalonia.Browser/Cursor.cs +++ b/src/Browser/Avalonia.Browser/Cursor.cs @@ -1,6 +1,7 @@ using System; using System.IO; using Avalonia.Input; +using Avalonia.Media.Imaging; using Avalonia.Platform; namespace Avalonia.Browser @@ -79,7 +80,7 @@ namespace Avalonia.Browser public ICursorImpl CreateCursor(Avalonia.Media.Imaging.Bitmap cursor, PixelPoint hotSpot) { using var imageStream = new MemoryStream(); - cursor.Save(imageStream); + cursor.Save(imageStream, PngBitmapEncoderOptions.Default); //not memory optimized because CryptoStream with ToBase64Transform is not supported in the browser. var base64String = Convert.ToBase64String(imageStream.ToArray()); diff --git a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs index 1c152d0fe4..0d8114f7f5 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -428,17 +428,10 @@ namespace Avalonia.Headless public AlphaFormat? AlphaFormat => Platform.AlphaFormat.Premul; public int Version { get; set; } - public void Save(string fileName, int? quality = null) + public void Save(Stream stream, BitmapEncoderOptions options) { - - } - - public void Save(Stream stream, int? quality = null) - { - } - public ILockedFramebuffer Lock() { Version++; diff --git a/src/Skia/Avalonia.Skia/Helpers/ImageSavingHelper.cs b/src/Skia/Avalonia.Skia/Helpers/ImageSavingHelper.cs index 6adfc01951..89c40186d0 100644 --- a/src/Skia/Avalonia.Skia/Helpers/ImageSavingHelper.cs +++ b/src/Skia/Avalonia.Skia/Helpers/ImageSavingHelper.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.IO.Compression; +using Avalonia.Media.Imaging; using SkiaSharp; namespace Avalonia.Skia.Helpers @@ -10,57 +12,116 @@ namespace Avalonia.Skia.Helpers public static class ImageSavingHelper { /// - /// Save Skia image to a file. + /// Saves a Skia image to a file in the PNG format. /// - /// Image to save - /// Target file. + /// The image to save + /// The output file to save the image to. /// - /// The optional quality for PNG compression. - /// The quality value is interpreted from 0 - 100. If quality is null - /// the encoder applies the default quality value. + /// The optional quality for compression. + /// The quality value is interpreted from 0 to 100. When null, 100 is used. /// + [Obsolete($"Use the overload accepting {nameof(BitmapEncoderOptions)} instead.")] public static void SaveImage(SKImage image, string fileName, int? quality = null) { - if (image == null) throw new ArgumentNullException(nameof(image)); - if (fileName == null) throw new ArgumentNullException(nameof(fileName)); + SaveImage(image, fileName, PngBitmapEncoderOptions.Default); + } - using (var stream = File.Create(fileName)) - { - SaveImage(image, stream, quality); - } + /// + /// Saves a Skia image to a file in the specified format. + /// + /// The image to save + /// The output file to save the image to. + /// + /// The options specifying the format and settings to use. + /// Typical usages include and . + /// + public static void SaveImage(SKImage image, string fileName, BitmapEncoderOptions options) + { + ArgumentNullException.ThrowIfNull(image); + ArgumentNullException.ThrowIfNull(fileName); + + using var stream = File.Create(fileName); + + SaveImage(image, stream, options); } /// - /// Save Skia image to a stream. + /// Saves a Skia image to a stream in the PNG format. /// - /// Image to save - /// The output stream to save the image. + /// The image to save + /// The output stream to save the image to. /// - /// The optional quality for PNG compression. - /// The quality value is interpreted from 0 - 100. If quality is null - /// the encoder applies the default quality value. + /// The optional quality for compression. + /// The quality value is interpreted from 0 to 100. When null, 100 is used. /// + [Obsolete($"Use the overload accepting {nameof(BitmapEncoderOptions)} instead.")] public static void SaveImage(SKImage image, Stream stream, int? quality = null) { - if (image == null) throw new ArgumentNullException(nameof(image)); - if (stream == null) throw new ArgumentNullException(nameof(stream)); + SaveImage(image, stream, PngBitmapEncoderOptions.Default); + } - if (quality == null) + /// + /// Saves a Skia image to a stream in the specified format. + /// + /// The image to save + /// The output stream to save the image to. + /// + /// The options specifying the format and settings to use. + /// Typical usages include and . + /// + public static void SaveImage(SKImage image, Stream stream, BitmapEncoderOptions options) + { + ArgumentNullException.ThrowIfNull(image); + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(options); + + var raster = image.ToRasterImage(true); + + try { - using (var data = image.Encode()) + using var pixmap = raster.PeekPixels() ?? throw new InvalidOperationException("Could not get image pixels"); + + using var data = options switch { - data.SaveTo(stream); - } + PngBitmapEncoderOptions pngOptions => pixmap.Encode(ToSkia(pngOptions)), + JpegBitmapEncoderOptions jpegOptions => pixmap.Encode(ToSkia(jpegOptions)), + _ => throw new ArgumentOutOfRangeException(nameof(options), options, "Unknown encoder options type") + }; + + if (data is null) + throw new InvalidOperationException("Could not encode image"); + + data.SaveTo(stream); } - else + finally { - using (var data = image.Encode(SKEncodedImageFormat.Png, (int)quality)) - { - data.SaveTo(stream); - } + if (image != raster) + raster.Dispose(); } } + private static SKPngEncoderOptions ToSkia(PngBitmapEncoderOptions options) + { + var zLibLevel = options.CompressionLevel switch + { + CompressionLevel.Optimal => 6, + CompressionLevel.Fastest => 1, + CompressionLevel.NoCompression => 0, + CompressionLevel.SmallestSize => 9, + _ => throw new ArgumentOutOfRangeException(nameof(options), "Unknown compression level") + }; + + return new SKPngEncoderOptions(SKPngEncoderFilterFlags.AllFilters, zLibLevel); + } + + private static SKJpegEncoderOptions ToSkia(JpegBitmapEncoderOptions options) + { + if (options.Quality is < 0 or > 100) + throw new ArgumentOutOfRangeException(nameof(options), "Unknown quality"); + + return new SKJpegEncoderOptions(options.Quality); + } + // This method is here mostly for debugging purposes internal static void SavePicture(SKPicture picture, float scale, string path) { @@ -68,7 +129,7 @@ namespace Avalonia.Skia.Helpers (int)Math.Ceiling(picture.CullRect.Height * scale)); using var snap = SKImage.FromPicture(picture, snapshotSize, SKMatrix.CreateScale(scale, scale)); - SaveImage(snap, path); + SaveImage(snap, path, PngBitmapEncoderOptions.Default); } } } diff --git a/src/Skia/Avalonia.Skia/ImmutableBitmap.cs b/src/Skia/Avalonia.Skia/ImmutableBitmap.cs index c168394768..e2994f7b64 100644 --- a/src/Skia/Avalonia.Skia/ImmutableBitmap.cs +++ b/src/Skia/Avalonia.Skia/ImmutableBitmap.cs @@ -173,16 +173,9 @@ namespace Avalonia.Skia _bitmap?.Dispose(); } - /// - public void Save(string fileName, int? quality = null) - { - ImageSavingHelper.SaveImage(_image, fileName, quality); - } - - /// - public void Save(Stream stream, int? quality = null) + public void Save(Stream stream, BitmapEncoderOptions options) { - ImageSavingHelper.SaveImage(_image, stream, quality); + ImageSavingHelper.SaveImage(_image, stream, options); } /// diff --git a/src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs b/src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs index 3e54394541..889abb9d8d 100644 --- a/src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs +++ b/src/Skia/Avalonia.Skia/SurfaceRenderTarget.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using Avalonia.Media.Imaging; using Avalonia.Reactive; using Avalonia.Platform; using Avalonia.Skia.Helpers; @@ -126,23 +127,12 @@ namespace Avalonia.Skia public PixelSize PixelSize { get; } public int Version { get; private set; } = 1; - - /// - public void Save(string fileName, int? quality = null) + + public void Save(Stream stream, BitmapEncoderOptions options) { - using (var image = SnapshotImage()) - { - ImageSavingHelper.SaveImage(image, fileName, quality); - } - } + using var image = SnapshotImage(); - /// - public void Save(Stream stream, int? quality = null) - { - using (var image = SnapshotImage()) - { - ImageSavingHelper.SaveImage(image, stream, quality); - } + ImageSavingHelper.SaveImage(image, stream, options); } public void Blit(IDrawingContextImpl contextImpl) diff --git a/src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs b/src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs index f57f84b168..3fd7664dca 100644 --- a/src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs +++ b/src/Skia/Avalonia.Skia/WriteableBitmapImpl.cs @@ -149,24 +149,13 @@ namespace Avalonia.Skia } } - /// - public void Save(Stream stream, int? quality = null) + public void Save(Stream stream, BitmapEncoderOptions options) { - using (var image = GetSnapshot()) - { - ImageSavingHelper.SaveImage(image, stream, quality); - } - } + using var image = GetSnapshot(); - /// - public void Save(string fileName, int? quality = null) - { - using (var image = GetSnapshot()) - { - ImageSavingHelper.SaveImage(image, fileName, quality); - } + ImageSavingHelper.SaveImage(image, stream, options); } - + public PixelFormat? Format => _bitmap.ColorType.ToAvalonia(); public AlphaFormat? AlphaFormat => _bitmap.AlphaType.ToAlphaFormat(); diff --git a/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs b/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs index 198c6fca07..c69fba5f10 100644 --- a/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs +++ b/src/Windows/Avalonia.Win32/OleDataObjectHelper.cs @@ -445,7 +445,7 @@ internal static class OleDataObjectHelper if (bitmap != null) { using var stream = new MemoryStream(); - bitmap.Save(stream); + bitmap.Save(stream, PngBitmapEncoderOptions.Default); return WriteBytesToHGlobal(ref hGlobal, stream.ToArray().AsSpan()); } diff --git a/src/Windows/Avalonia.Win32/Win32Platform.cs b/src/Windows/Avalonia.Win32/Win32Platform.cs index 5eaee60448..c7cd651ca5 100644 --- a/src/Windows/Avalonia.Win32/Win32Platform.cs +++ b/src/Windows/Avalonia.Win32/Win32Platform.cs @@ -1,24 +1,22 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using Avalonia.Reactive; using System.Runtime.InteropServices; -using System.Threading; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Platform; using Avalonia.Input; using Avalonia.Input.Platform; +using Avalonia.Media.Imaging; using Avalonia.Platform; using Avalonia.Rendering; using Avalonia.Rendering.Composition; using Avalonia.Threading; using Avalonia.Utilities; using Avalonia.Win32.Input; -using Avalonia.Win32.Interop; using static Avalonia.Win32.Interop.UnmanagedMethods; -using System.Collections.Generic; namespace Avalonia { @@ -282,7 +280,7 @@ namespace Avalonia.Win32 { using (var memoryStream = new MemoryStream()) { - bitmap.Save(memoryStream); + bitmap.Save(memoryStream, PngBitmapEncoderOptions.Default); memoryStream.Seek(0, SeekOrigin.Begin); return new IconImpl(memoryStream); } diff --git a/src/iOS/Avalonia.iOS/Clipboard/ClipboardImpl.cs b/src/iOS/Avalonia.iOS/Clipboard/ClipboardImpl.cs index 57ff3c9043..5f02c24604 100644 --- a/src/iOS/Avalonia.iOS/Clipboard/ClipboardImpl.cs +++ b/src/iOS/Avalonia.iOS/Clipboard/ClipboardImpl.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Logging; +using Avalonia.Media.Imaging; using Foundation; using UIKit; using static Avalonia.iOS.Clipboard.ClipboardDataFormatHelper; @@ -100,7 +101,7 @@ internal sealed class ClipboardImpl(UIPasteboard pasteboard) if (bitmap is null) return null; using var memoryStream = new MemoryStream(); - bitmap.Save(memoryStream, 100); + bitmap.Save(memoryStream, PngBitmapEncoderOptions.Default); memoryStream.Seek(0, SeekOrigin.Begin); using var data = NSData.FromStream(memoryStream)!; return UIImage.LoadFromData(data); diff --git a/src/iOS/Avalonia.iOS/Stubs.cs b/src/iOS/Avalonia.iOS/Stubs.cs index c78823cd81..913b8ba2fd 100644 --- a/src/iOS/Avalonia.iOS/Stubs.cs +++ b/src/iOS/Avalonia.iOS/Stubs.cs @@ -1,6 +1,7 @@ using System; using System.IO; using Avalonia.Input; +using Avalonia.Media.Imaging; using Avalonia.Platform; namespace Avalonia.iOS @@ -34,11 +35,10 @@ namespace Avalonia.iOS { public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) { - using (var stream = new MemoryStream()) - { - bitmap.Save(stream); - return LoadIcon(stream); - } + using var stream = new MemoryStream(); + + bitmap.Save(stream, PngBitmapEncoderOptions.Default); + return LoadIcon(stream); } public IWindowIconImpl LoadIcon(Stream stream) diff --git a/tests/Avalonia.RenderTests/Media/BitmapTests.cs b/tests/Avalonia.RenderTests/Media/BitmapTests.cs index 1db7556b46..12f6becd8f 100644 --- a/tests/Avalonia.RenderTests/Media/BitmapTests.cs +++ b/tests/Avalonia.RenderTests/Media/BitmapTests.cs @@ -94,7 +94,7 @@ namespace Avalonia.Skia.RenderTests var rc = new Rect(0, 0, 60, 60); ctx.DrawBitmap(bmp.PlatformImpl, 1, rc, rc); } - rtb.Save(Path.Combine(OutputPath, testName + ".out.png")); + rtb.Save(Path.Combine(OutputPath, testName + ".out.png"), PngBitmapEncoderOptions.Default); } CompareImagesNoRenderer(testName); } @@ -123,7 +123,7 @@ namespace Avalonia.Skia.RenderTests var name = nameof(WriteableBitmapShouldBeUsable) + "_" + fmt; - writeableBitmap.Save(Path.Combine(OutputPath, name + ".out.png")); + writeableBitmap.Save(Path.Combine(OutputPath, name + ".out.png"), PngBitmapEncoderOptions.Default); CompareImagesNoRenderer(name); } @@ -215,7 +215,7 @@ namespace Avalonia.Skia.RenderTests Assert.Equal(data.ToArray(), copyTo); } - b.Save(path); + b.Save(path, PngBitmapEncoderOptions.Default); CompareImagesNoRenderer(testName, expectedName); } finally diff --git a/tests/Avalonia.RenderTests/TestRenderHelper.cs b/tests/Avalonia.RenderTests/TestRenderHelper.cs index 2bd12cfa81..7b3a94d6b7 100644 --- a/tests/Avalonia.RenderTests/TestRenderHelper.cs +++ b/tests/Avalonia.RenderTests/TestRenderHelper.cs @@ -69,7 +69,7 @@ static class TestRenderHelper target.Measure(size); target.Arrange(new Rect(size)); bitmap.Render(target); - bitmap.Save(path); + bitmap.Save(path, PngBitmapEncoderOptions.Default); } } else @@ -91,7 +91,8 @@ static class TestRenderHelper renderer.Paint(new Rect(root.Bounds.Size), false); } - writableBitmap.Save(path); + using var fileStream = File.Create(path); + writableBitmap.Save(fileStream, PngBitmapEncoderOptions.Default); } } diff --git a/tests/Avalonia.Skia.UnitTests/Media/BitmapSaveTests.cs b/tests/Avalonia.Skia.UnitTests/Media/BitmapSaveTests.cs new file mode 100644 index 0000000000..5107e778a9 --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/Media/BitmapSaveTests.cs @@ -0,0 +1,108 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Runtime.InteropServices; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.UnitTests; +using SkiaSharp; +using Xunit; + +namespace Avalonia.Skia.UnitTests.Media +{ + public class BitmapSaveTests + { + [Fact] + public void Save_With_Null_Options_Throws() + { + using var app = Start(); + + using var bitmap = CreateBitmap(SKColors.Red, 16, 16); + using var stream = new MemoryStream(); + + Assert.Throws(() => bitmap.Save(stream, (BitmapEncoderOptions)null!)); + } + + [Fact] + public void Save_With_Invalid_Png_CompressionLevel_Throws() + { + using var app = Start(); + + using var bitmap = CreateBitmap(SKColors.Red, 16, 16); + using var stream = new MemoryStream(); + var options = new PngBitmapEncoderOptions { CompressionLevel = (CompressionLevel)42 }; + + Assert.Throws(() => bitmap.Save(stream, options)); + } + + [Fact] + public void Save_With_Invalid_Jpeg_Quality_Throws() + { + using var app = Start(); + + using var bitmap = CreateBitmap(SKColors.Red, 16, 16); + using var stream = new MemoryStream(); + var options = new JpegBitmapEncoderOptions { Quality = -1 }; + + Assert.Throws(() => bitmap.Save(stream, options)); + } + + [Fact] + public void Save_With_Png_Options_Produces_Png() + { + using var app = Start(); + + using var bitmap = CreateBitmap(SKColors.Red, 16, 16); + using var stream = new MemoryStream(); + + bitmap.Save(stream, PngBitmapEncoderOptions.Default); + + stream.Position = 0; + using var codec = SKCodec.Create(stream); + + Assert.Equal(SKEncodedImageFormat.Png, codec.EncodedFormat); + } + + [Fact] + public void Save_With_Jpeg_Options_Produces_Jpeg() + { + using var app = Start(); + + using var bitmap = CreateBitmap(SKColors.Red, 16, 16); + using var stream = new MemoryStream(); + + bitmap.Save(stream, JpegBitmapEncoderOptions.Default); + + stream.Position = 0; + using var codec = SKCodec.Create(stream); + + Assert.Equal(SKEncodedImageFormat.Jpeg, codec.EncodedFormat); + } + + private static WriteableBitmap CreateBitmap(SKColor color, int width, int height) + { + var pixel = (color.Alpha << 24) | (color.Red << 16) | (color.Green << 8) | color.Blue; + + var data = new int[width * height]; + data.AsSpan().Fill(pixel); + + return CreateBitmap(width, height, data); + } + + private static WriteableBitmap CreateBitmap(int width, int height, int[] data) + { + var bitmap = new WriteableBitmap(new PixelSize(width, height), new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Premul); + + using var fb = bitmap.Lock(); + + for (var y = 0; y < height; y++) + Marshal.Copy(data, y * width, fb.Address + y * fb.RowBytes, width); + + return bitmap; + } + + private static IDisposable Start() + => UnitTestApplication.Start( + TestServices.MockPlatformRenderInterface.With(renderInterface: new PlatformRenderInterface())); + } +}