From c18986ffb93378415a1e4f49d98b5bcc089a975a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:24:13 +0000 Subject: [PATCH 1/2] Imaging: WicFrameInfo.HasResolution and palettes for Encode HasResolution tells a resolution stored in the file apart from the default of the decoder, with the rules per container in ResolutionProbe. WicEncodeOptions.Palette and Dither, indexed pixel formats, and GIF with the exact colors when the pixels have at most 256. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017qu4NGxUZizEKzrG8d2DGY --- CHANGELOG.md | 10 + doc/articles/imaging.md | 32 +- .../ImagingSamples.cs | 3 +- .../Internal/ResolutionProbe.cs | 285 ++++++++++ .../Internal/WicPixelSource.cs | 34 +- .../Interop/WicEnums.cs | 3 +- .../WicEncodeOptions.cs | 15 + src/Dapplo.Windows.Imaging/WicFrameInfo.cs | 11 +- src/Dapplo.Windows.Imaging/WicImage.cs | 174 +++++- src/Dapplo.Windows.Imaging/WicPixelFormats.cs | 20 + .../WicResolutionTests.cs | 497 ++++++++++++++++++ src/version.json | 2 +- 12 files changed, 1068 insertions(+), 18 deletions(-) create mode 100644 src/Dapplo.Windows.Imaging/Internal/ResolutionProbe.cs create mode 100644 src/Dapplo.Windows.Tests/WicResolutionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index a0b74a14..6651643c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and the packages use [Semantic Versioning](https://semver.org/). Finding IDs suc Version 3.0 fixes a large number of interop bugs and deliberately breaks APIs whose concept was wrong. Read the [migration guide](doc/articles/migration-3.0.md) before upgrading. +## [3.19.0] + +### Added +- `WicFrameInfo.HasResolution` in Dapplo.Windows.Imaging: true when the file itself stores a resolution, false when `DpiX` / `DpiY` are only the default of the decoder. WIC can't say "unknown", every decoder fills in its own default (96 for a PNG without pHYs, 72 for a WebP without EXIF), so "the file says 72" (macOS screenshots, camera JPEGs) couldn't be told apart from "the file says nothing". Per container: PNG pHYs with the unit meter, JPEG JFIF density in inch or cm or EXIF XResolution, TIFF/DNG XResolution, JPEG XR WIDTH_RESOLUTION, BMP pixels per meter other than 0, GIF/ICO/DDS never, other containers an EXIF IFD with XResolution; a ResolutionUnit of 1 (none) or a pHYs/JFIF unit 0 (aspect ratio only) is no resolution. It is filled by `GetFrameInfo` and every `Decode` overload from the frame which is open anyway; only PNG (when WIC doesn't expose pHYs), JPEG XR and BMP read a few bytes of the file, the stream position is restored. +- `WicEncodeOptions.Palette` (up to 256 colors as 0xAARRGGBB) and the indexed pixel formats `WicPixelFormats.Format1bppIndexed` .. `Format8bppIndexed`: indexed pixels are encoded with that palette (GIF, PNG, BMP, TIFF), other pixels get the nearest color of the palette and the encoder is asked for an 8 bit indexed format. +- `WicEncodeOptions.Dither`: error diffusion when the pixels are reduced to fewer colors than they have. + +### Changed +- GIF without a palette in the options: pixels with at most 256 colors and no partly transparent pixel get exactly these colors as palette, so decoding gives the same pixels. Before, WIC always chose the palette (`IWICPalette::InitializeFromBitmap`), which could change colors; it still does for more colors. + ## [3.18.0] ### Added diff --git a/doc/articles/imaging.md b/doc/articles/imaging.md index 35509e8f..f473249e 100644 --- a/doc/articles/imaging.md +++ b/doc/articles/imaging.md @@ -74,8 +74,14 @@ The pixel formats are WIC GUIDs, `WicPixelFormats` has the common ones: `Format3 ImageSharp `Bgra32`), `Format32bppPbgra`, `Format24bppBgr` (GDI+ `Format24bppRgb`, ImageSharp `Bgr24`), `Format32bppRgba` (ImageSharp `Rgba32`), and `Format64bppRgbaHalf` and `Format128bppRgbaFloat` for HDR. When the encoder wants another pixel format (e.g. JPEG has no alpha), WIC converts the pixels while it reads them; -`WicEncodeResult.PixelFormat` is the format which was written. For an indexed format (GIF) a palette is made from the -pixels, with a transparent color when the source has alpha. +`WicEncodeResult.PixelFormat` is the format which was written. + +For an indexed format (GIF) a palette is made from the pixels: with at most 256 colors (and no partly transparent +pixel) exactly these colors, so the GIF has the same pixels; otherwise WIC chooses the colors, with +`WicEncodeOptions.Dither` using error diffusion. `WicEncodeOptions.Palette` (up to 256 colors as `0xAARRGGBB`, alpha 0 +is the transparent color) sets the palette yourself: pixels in `WicPixelFormats.Format8bppIndexed` (or 1, 2, 4 bpp) +are indexes into it, other pixels get the nearest color and the encoder is asked for an indexed format, so a PNG gets a +palette too. When the pixels aren't in one buffer (ImageSharp can split a large image), the other overload takes a callback. The encoder pulls the rows it needs, no copy of the image is made. @@ -125,7 +131,8 @@ try WicFrameInfo info = WicImage.Decode(source, WicPixelFormats.Format32bppBgra, (WicFrameInfo frame, out int stride) => { bitmap = new Bitmap(frame.Width, frame.Height, PixelFormat.Format32bppArgb); - bitmap.SetResolution((float)frame.DpiX, (float)frame.DpiY); + // The resolution of the file; when it stores none, WIC reports the default of the decoder (96 or 72), use your own + bitmap.SetResolution(frame.HasResolution ? (float)frame.DpiX : 96, frame.HasResolution ? (float)frame.DpiY : 96); bitmapData = bitmap.LockBits(new Rectangle(0, 0, frame.Width, frame.Height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); stride = bitmapData.Stride; return bitmapData.Scan0; @@ -144,6 +151,25 @@ With `applyOrientation: true` the EXIF orientation is applied (rotated and flipp `WicFrameInfo.Width` and `Height` are then those of the upright image. A third overload hands the rows to a `WicRowConsumer`, in bands from a pooled buffer. +### Resolution + +WIC always reports a resolution: when the file stores none, each decoder fills in its own default (e.g. 96 for +PNG, 72 for WebP). `WicFrameInfo.HasResolution` tells the two apart, it is true only when the file itself stores a +resolution: + +| Container | Stored resolution | +|---|---| +| PNG | a pHYs chunk with the unit meter (unit 0 is only an aspect ratio) | +| JPEG | JFIF density with the unit inch or cm, or EXIF XResolution with a ResolutionUnit other than none | +| TIFF, DNG | XResolution with a ResolutionUnit other than none (no ResolutionUnit means inch) | +| JPEG XR | WIDTH_RESOLUTION of the container | +| BMP | pixels per meter other than 0 | +| GIF, ICO, DDS | never, GIF only has an aspect ratio | +| others (WebP, HEIF, AVIF, JPEG XL, RAW, ...) | an EXIF IFD with XResolution and a ResolutionUnit other than none | + +The metadata of the frame is used, the bytes of the file are read only for a PNG whose pHYs WIC doesn't expose, JPEG XR +and BMP. + ## When there is no codec When WIC has no codec for the container format or doesn't recognize the stream, a `WicCodecNotFoundException` (a diff --git a/src/Dapplo.Windows.Example.DocSamples/ImagingSamples.cs b/src/Dapplo.Windows.Example.DocSamples/ImagingSamples.cs index a20a3312..8cd1b09b 100644 --- a/src/Dapplo.Windows.Example.DocSamples/ImagingSamples.cs +++ b/src/Dapplo.Windows.Example.DocSamples/ImagingSamples.cs @@ -70,7 +70,8 @@ public static Bitmap DecodeIntoBitmap(Stream source) WicFrameInfo info = WicImage.Decode(source, WicPixelFormats.Format32bppBgra, (WicFrameInfo frame, out int stride) => { bitmap = new Bitmap(frame.Width, frame.Height, PixelFormat.Format32bppArgb); - bitmap.SetResolution((float)frame.DpiX, (float)frame.DpiY); + // The resolution of the file; when it stores none, WIC reports the default of the decoder (96 or 72), use your own + bitmap.SetResolution(frame.HasResolution ? (float)frame.DpiX : 96, frame.HasResolution ? (float)frame.DpiY : 96); bitmapData = bitmap.LockBits(new Rectangle(0, 0, frame.Width, frame.Height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); stride = bitmapData.Stride; return bitmapData.Scan0; diff --git a/src/Dapplo.Windows.Imaging/Internal/ResolutionProbe.cs b/src/Dapplo.Windows.Imaging/Internal/ResolutionProbe.cs new file mode 100644 index 00000000..c0236327 --- /dev/null +++ b/src/Dapplo.Windows.Imaging/Internal/ResolutionProbe.cs @@ -0,0 +1,285 @@ +// Copyright (c) Dapplo and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IO; +using Dapplo.Log; +using Dapplo.Windows.Common.Extensions; +using Dapplo.Windows.Imaging.Interop; + +namespace Dapplo.Windows.Imaging.Internal; + +/// +/// Finds out whether a file stores a resolution, or whether the resolution of IWICBitmapSource::GetResolution is only the default of the decoder. +/// WIC has no way to say "unknown", the rules per container are kept here. +/// The metadata of the frame is used first, the bytes of the file only where WIC doesn't expose the field. +/// +internal static class ResolutionProbe +{ + private static readonly LogSource Log = new LogSource(); + + private const int TagXResolution = 282; + private const int TagResolutionUnit = 296; + + /// + /// JPEG XR (ITU-T T.832, Annex A) WIDTH_RESOLUTION, a FLOAT in dots per inch + /// + private const int JpegXrTagWidthResolution = 0xBC82; + + /// the query reader of the frame, can be null + /// the metadata items of the frame + /// the container format of the decoder + /// the seekable stream the decoder reads, its position is restored + /// the position of the file in the stream + public static bool HasResolution(IWICMetadataQueryReader queryReader, WicMetadata metadata, Guid containerFormat, Stream input, long origin) + { + try + { + if (containerFormat == WicContainerFormats.Png) + { + return Png(queryReader, input, origin); + } + if (containerFormat == WicContainerFormats.Jpeg) + { + return Jpeg(queryReader); + } + if (containerFormat == WicContainerFormats.Tiff || containerFormat == WicContainerFormats.Adng) + { + return IfdResolution(queryReader, "/ifd"); + } + if (containerFormat == WicContainerFormats.Wmp) + { + return JpegXr(input, origin); + } + if (containerFormat == WicContainerFormats.Bmp) + { + return Bmp(input, origin); + } + if (containerFormat == WicContainerFormats.Gif || containerFormat == WicContainerFormats.Ico || containerFormat == WicContainerFormats.Dds) + { + // GIF only has a pixel aspect ratio, ICO and DDS have nothing + return false; + } + return ExifResolution(metadata); + } + catch (Exception ex) when (ex is IOException || ex is NotSupportedException || ex is ObjectDisposedException || ex is InvalidCastException) + { + Log.Debug().WriteLine("Couldn't find out whether the {0} file stores a resolution: {1}", containerFormat, ex.Message); + return false; + } + } + + /// + /// PNG: a pHYs chunk with the unit specifier 1 (meter); unit 0 is only an aspect ratio + /// + private static bool Png(IWICMetadataQueryReader queryReader, Stream input, long origin) + { + if (TryQueryInteger(queryReader, "/pHYs/UnitSpecifier", out var unit)) + { + return unit == 1 && TryQueryInteger(queryReader, "/pHYs/PixelsPerUnitX", out var pixelsPerUnit) ? pixelsPerUnit > 0 : unit == 1; + } + // The reader doesn't expose pHYs, read the chunk headers up to the first IDAT + return WithPosition(input, () => + { + input.Position = origin + 8; + var header = new byte[8]; + for (int chunk = 0; chunk < 1000; chunk++) + { + if (!ReadFully(input, header, 8)) + { + return false; + } + var length = ReadUInt32BigEndian(header, 0); + var type = System.Text.Encoding.ASCII.GetString(header, 4, 4); + if (type == "pHYs") + { + var data = new byte[9]; + return length >= 9 && ReadFully(input, data, 9) && data[8] == 1 && ReadUInt32BigEndian(data, 0) > 0; + } + if (type == "IDAT" || type == "IEND") + { + return false; + } + // Skip the data and the CRC + input.Position += length + 4L; + } + return false; + }); + } + + /// + /// JPEG: the JFIF density unit 1 (inch) or 2 (cm), or an EXIF XResolution with a ResolutionUnit other than 1 (none) + /// + private static bool Jpeg(IWICMetadataQueryReader queryReader) + { + if (TryQueryInteger(queryReader, "/app0/{ushort=1}", out var units) && (units == 1 || units == 2)) + { + return true; + } + return IfdResolution(queryReader, "/app1/ifd"); + } + + /// + /// TIFF IFD: XResolution present and ResolutionUnit not 1 (none); without ResolutionUnit the unit is inch + /// + private static bool IfdResolution(IWICMetadataQueryReader queryReader, string ifdPath) + { + if (!TryQuery(queryReader, $"{ifdPath}/{{ushort={TagXResolution}}}", out _)) + { + return false; + } + return !TryQueryInteger(queryReader, $"{ifdPath}/{{ushort={TagResolutionUnit}}}", out var unit) || unit != 1; + } + + /// + /// Other containers (WebP, HEIF, JPEG XL, RAW, codecs of other vendors): an EXIF IFD (IFD0) with XResolution and a ResolutionUnit other than 1. + /// The path of the IFD differs per container, the IFD blocks are found in the enumerated metadata. + /// + private static bool ExifResolution(WicMetadata metadata) + { + foreach (var item in metadata.Items) + { + if (item.Value is not WicMetadataBlock block || block.Format != WicMetadataFormats.Ifd) + { + continue; + } + if (!metadata.TryGet($"{block.Path}/{{ushort={TagXResolution}}}", out _)) + { + continue; + } + if (!metadata.TryGet($"{block.Path}/{{ushort={TagResolutionUnit}}}", out var unit) || unit is not IConvertible convertible || convertible.ToInt64(null) != 1) + { + return true; + } + } + return false; + } + + /// + /// JPEG XR: the WIDTH_RESOLUTION tag in the IFD of the container, read from the file (the WIC metadata reader of JPEG XR doesn't expose it) + /// + private static bool JpegXr(Stream input, long origin) => WithPosition(input, () => + { + input.Position = origin; + var header = new byte[8]; + // "II", 0xBC, version + if (!ReadFully(input, header, 8) || header[0] != 0x49 || header[1] != 0x49 || header[2] != 0xBC) + { + return false; + } + input.Position = origin + ReadUInt32LittleEndian(header, 4); + var countBytes = new byte[2]; + if (!ReadFully(input, countBytes, 2)) + { + return false; + } + var count = countBytes[0] | countBytes[1] << 8; + var entry = new byte[12]; + for (int i = 0; i < count; i++) + { + if (!ReadFully(input, entry, 12)) + { + return false; + } + var tag = entry[0] | entry[1] << 8; + if (tag != JpegXrTagWidthResolution) + { + continue; + } + var type = entry[2] | entry[3] << 8; + // FLOAT (11), stored in the value field + return type == 11 ? BitConverter.ToSingle(entry, 8) > 0 : ReadUInt32LittleEndian(entry, 8) > 0; + } + return false; + }); + + /// + /// BMP: biXPelsPerMeter of the BITMAPINFOHEADER (byte 38 of the file), 0 means not stored. A BITMAPCOREHEADER has no resolution. + /// + private static bool Bmp(Stream input, long origin) => WithPosition(input, () => + { + input.Position = origin; + var header = new byte[42]; + if (!ReadFully(input, header, header.Length) || header[0] != (byte)'B' || header[1] != (byte)'M') + { + return false; + } + var infoHeaderSize = ReadUInt32LittleEndian(header, 14); + return infoHeaderSize >= 40 && ReadUInt32LittleEndian(header, 38) != 0; + }); + + private static bool TryQuery(IWICMetadataQueryReader queryReader, string query, out object value) + { + value = null; + if (queryReader == null) + { + return false; + } + var propVariant = PropVariant.Allocate(); + try + { + if (!queryReader.GetMetadataByName(query, propVariant).Succeeded() || !PropVariant.TryRead(propVariant, out value)) + { + return false; + } + if (value != null && System.Runtime.InteropServices.Marshal.IsComObject(value)) + { + Wic.Release(value); + value = null; + return true; + } + return value != null; + } + finally + { + PropVariant.Free(propVariant); + } + } + + private static bool TryQueryInteger(IWICMetadataQueryReader queryReader, string query, out long value) + { + value = 0; + if (!TryQuery(queryReader, query, out var raw) || raw is not IConvertible convertible || raw is string) + { + return false; + } + value = convertible.ToInt64(null); + return true; + } + + private static bool WithPosition(Stream input, Func read) + { + if (input == null || !input.CanSeek) + { + return false; + } + var position = input.Position; + try + { + return read(); + } + finally + { + input.Position = position; + } + } + + private static bool ReadFully(Stream input, byte[] buffer, int count) + { + var offset = 0; + while (offset < count) + { + var read = input.Read(buffer, offset, count - offset); + if (read <= 0) + { + return false; + } + offset += read; + } + return true; + } + + private static uint ReadUInt32BigEndian(byte[] data, int offset) => (uint)(data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3]); + + private static uint ReadUInt32LittleEndian(byte[] data, int offset) => (uint)(data[offset] | data[offset + 1] << 8 | data[offset + 2] << 16 | data[offset + 3] << 24); +} diff --git a/src/Dapplo.Windows.Imaging/Internal/WicPixelSource.cs b/src/Dapplo.Windows.Imaging/Internal/WicPixelSource.cs index 915379f7..00832fbe 100644 --- a/src/Dapplo.Windows.Imaging/Internal/WicPixelSource.cs +++ b/src/Dapplo.Windows.Imaging/Internal/WicPixelSource.cs @@ -56,7 +56,39 @@ public HResult GetResolution(out double dpiX, out double dpiY) return HResult.S_OK; } - public HResult CopyPalette(nint palette) => WicErrors.PaletteUnavailable; + /// + /// The colors of an indexed pixel format (WICColor, 0xAARRGGBB), null for the other formats + /// + public uint[] Palette { get; set; } + + public HResult CopyPalette(nint palette) + { + if (Palette == null || Palette.Length == 0) + { + return WicErrors.PaletteUnavailable; + } + if (palette == IntPtr.Zero) + { + return HResult.E_INVALIDARG; + } + object paletteObject = null; + var handle = GCHandle.Alloc(Palette, GCHandleType.Pinned); + try + { + paletteObject = Marshal.GetObjectForIUnknown(palette); + return ((IWICPalette)paletteObject).InitializeCustom(handle.AddrOfPinnedObject(), (uint)Palette.Length); + } + catch (Exception ex) + { + LastException = ex; + return ex.HResult < 0 ? (HResult)ex.HResult : HResult.E_FAIL; + } + finally + { + handle.Free(); + Wic.Release(paletteObject); + } + } public unsafe HResult CopyPixels(nint rectangle, uint stride, uint bufferSize, nint buffer) { diff --git a/src/Dapplo.Windows.Imaging/Interop/WicEnums.cs b/src/Dapplo.Windows.Imaging/Interop/WicEnums.cs index f5fc1001..f4404cef 100644 --- a/src/Dapplo.Windows.Imaging/Interop/WicEnums.cs +++ b/src/Dapplo.Windows.Imaging/Interop/WicEnums.cs @@ -27,7 +27,8 @@ internal enum WicBitmapEncoderCacheOption : uint /// internal enum WicBitmapDitherType : uint { - None = 0x00000000 + None = 0x00000000, + ErrorDiffusion = 0x00000008 } /// diff --git a/src/Dapplo.Windows.Imaging/WicEncodeOptions.cs b/src/Dapplo.Windows.Imaging/WicEncodeOptions.cs index ac9a8ba6..2f2411bf 100644 --- a/src/Dapplo.Windows.Imaging/WicEncodeOptions.cs +++ b/src/Dapplo.Windows.Imaging/WicEncodeOptions.cs @@ -48,6 +48,21 @@ public sealed class WicEncodeOptions /// What the encoder doesn't support is skipped and reported in , it never fails the encoding. /// public Action Metadata { get; set; } + + /// + /// The palette for an indexed image (e.g. GIF, or a PNG with a palette), at most 256 colors as 32 bit BGRA values (0xAARRGGBB, the layout of + /// GDI+ Color.ToArgb and of WICColor). A color with alpha 0 is the transparent color. + /// Required when the pixels have an indexed pixel format (), the indexes refer to this palette. + /// With other pixels the encoder is asked for an 8 bit indexed format and each pixel gets the nearest color of the palette. + /// When it is null and the encoder writes an indexed format (GIF), the palette is made from the pixels: with at most 256 colors (and no partial + /// transparency) these exact colors, otherwise WIC chooses the colors. + /// + public IReadOnlyList Palette { get; set; } + + /// + /// Use error diffusion dithering when the pixels are reduced to a palette with fewer colors than the image has; false (default) uses the nearest color + /// + public bool Dither { get; set; } } /// diff --git a/src/Dapplo.Windows.Imaging/WicFrameInfo.cs b/src/Dapplo.Windows.Imaging/WicFrameInfo.cs index fcc425da..825bfa59 100644 --- a/src/Dapplo.Windows.Imaging/WicFrameInfo.cs +++ b/src/Dapplo.Windows.Imaging/WicFrameInfo.cs @@ -10,12 +10,13 @@ namespace Dapplo.Windows.Imaging; /// public sealed class WicFrameInfo { - internal WicFrameInfo(int width, int height, double dpiX, double dpiY, Guid pixelFormat, Guid containerFormat, int frameCount, WicMetadata metadata, bool orientationApplied) + internal WicFrameInfo(int width, int height, double dpiX, double dpiY, bool hasResolution, Guid pixelFormat, Guid containerFormat, int frameCount, WicMetadata metadata, bool orientationApplied) { Width = width; Height = height; DpiX = dpiX; DpiY = dpiY; + HasResolution = hasResolution; PixelFormat = pixelFormat; ContainerFormat = containerFormat; FrameCount = frameCount; @@ -43,6 +44,14 @@ internal WicFrameInfo(int width, int height, double dpiX, double dpiY, Guid pixe /// public double DpiY { get; } + /// + /// True when the file itself stores a resolution (PNG pHYs in pixels per meter, JFIF density in inch or cm, EXIF/TIFF XResolution with a unit, + /// JPEG XR WIDTH_RESOLUTION, BMP pixels per meter, ...). + /// False when / are only the default of the decoder, because the file stores none, or only an aspect ratio. + /// WIC reports a resolution in both cases, e.g. 96 for a PNG without pHYs and 72 for a WebP without EXIF; use your own default when this is false. + /// + public bool HasResolution { get; } + /// /// The pixel format of the frame as stored, see /// diff --git a/src/Dapplo.Windows.Imaging/WicImage.cs b/src/Dapplo.Windows.Imaging/WicImage.cs index f5055e2e..a9b51f40 100644 --- a/src/Dapplo.Windows.Imaging/WicImage.cs +++ b/src/Dapplo.Windows.Imaging/WicImage.cs @@ -161,6 +161,13 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio { factory = WicNativeMethods.CreateImagingFactory(); var source = createSource(factory); + var sourceIndexedBits = IndexedBitsPerPixel(source.PixelFormat); + var paletteColors = GetPaletteColors(options, sourceIndexedBits); + if (sourceIndexedBits > 0) + { + // The encoder and the converter get the colors of the indexes through IWICBitmapSource.CopyPalette + source.Palette = paletteColors; + } var stream = new ManagedIStream(target); var container = options.ContainerFormat; @@ -173,6 +180,11 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio Wic.Check(frame.SetSize((uint)source.Width, (uint)source.Height), "IWICBitmapFrameEncode.SetSize", stream); Wic.Check(frame.SetResolution(options.DpiX, options.DpiY), "IWICBitmapFrameEncode.SetResolution", stream); var pixelFormat = source.PixelFormat; + if (paletteColors != null && sourceIndexedBits == 0) + { + // A palette was given for pixels which aren't indexed: ask the encoder for an indexed format + pixelFormat = WicPixelFormats.Format8bppIndexed; + } Wic.Check(frame.SetPixelFormat(ref pixelFormat), "IWICBitmapFrameEncode.SetPixelFormat", stream); IReadOnlyList metadataIssues = Array.Empty(); @@ -191,21 +203,31 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio metadataIssues = metadataWriter.Issues; } + var targetIndexedBits = IndexedBitsPerPixel(pixelFormat); + if (targetIndexedBits > 0) + { + // An indexed format (e.g. GIF) needs a palette: the given one, the exact colors of the pixels when they fit, otherwise one which WIC makes + Wic.Check(factory.CreatePalette(out palette), "IWICImagingFactory.CreatePalette"); + var colors = paletteColors ?? CollectColors(factory, source, 1 << targetIndexedBits); + if (colors != null) + { + InitializePalette(palette, colors); + } + else + { + Wic.Check(palette.InitializeFromBitmap(source, 1u << targetIndexedBits, HasAlpha(source.PixelFormat) ? 1 : 0), "IWICPalette.InitializeFromBitmap", source); + } + Wic.Check(frame.SetPalette(palette), "IWICBitmapFrameEncode.SetPalette", stream); + } + IWICBitmapSource pixels = source; if (pixelFormat != source.PixelFormat) { // The encoder wants another pixel format, WIC converts while it pulls the rows Log.Debug().WriteLine("The encoder for {0} converts {1} to {2}", options.ContainerFormat, source.PixelFormat, pixelFormat); - var indexedBits = IndexedBitsPerPixel(pixelFormat); - if (indexedBits > 0) - { - // An indexed format (e.g. GIF) needs a palette, made from the pixels, with a transparent color when the source has alpha - Wic.Check(factory.CreatePalette(out palette), "IWICImagingFactory.CreatePalette"); - Wic.Check(palette.InitializeFromBitmap(source, 1u << indexedBits, HasAlpha(source.PixelFormat) ? 1 : 0), "IWICPalette.InitializeFromBitmap", source); - Wic.Check(frame.SetPalette(palette), "IWICBitmapFrameEncode.SetPalette", stream); - } Wic.Check(factory.CreateFormatConverter(out converter), "IWICImagingFactory.CreateFormatConverter"); - Wic.Check(converter.Initialize(source, ref pixelFormat, WicBitmapDitherType.None, palette, indexedBits > 0 ? 50 : 0, WicBitmapPaletteType.Custom), "IWICFormatConverter.Initialize", source); + var dither = options.Dither ? WicBitmapDitherType.ErrorDiffusion : WicBitmapDitherType.None; + Wic.Check(converter.Initialize(source, ref pixelFormat, dither, targetIndexedBits > 0 ? palette : null, targetIndexedBits > 0 ? 50 : 0, WicBitmapPaletteType.Custom), "IWICFormatConverter.Initialize", source); pixels = (IWICBitmapSource)converter; } Wic.Check(frame.WriteSource(pixels, IntPtr.Zero), "IWICBitmapFrameEncode.WriteSource", source, stream); @@ -236,6 +258,119 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio } } + /// + /// The palette of the options as WICColor values, validated + /// + private static uint[] GetPaletteColors(WicEncodeOptions options, int sourceIndexedBits) + { + if (options.Palette == null) + { + if (sourceIndexedBits > 0) + { + throw new ArgumentException("Pixels with an indexed pixel format need WicEncodeOptions.Palette", nameof(options)); + } + return null; + } + var colors = new uint[options.Palette.Count]; + for (int i = 0; i < colors.Length; i++) + { + colors[i] = options.Palette[i]; + } + if (colors.Length == 0 || colors.Length > 256) + { + throw new ArgumentException("The palette must have 1 to 256 colors", nameof(options)); + } + if (sourceIndexedBits > 0 && colors.Length > 1 << sourceIndexedBits) + { + throw new ArgumentException($"The palette has {colors.Length} colors, the pixel format only {1 << sourceIndexedBits}", nameof(options)); + } + return colors; + } + + private static void InitializePalette(IWICPalette palette, uint[] colors) + { + var handle = GCHandle.Alloc(colors, GCHandleType.Pinned); + try + { + Wic.Check(palette.InitializeCustom(handle.AddrOfPinnedObject(), (uint)colors.Length), "IWICPalette.InitializeCustom"); + } + finally + { + handle.Free(); + } + } + + /// + /// The distinct colors of the pixels, sorted, when there are at most maxColors and no pixel is partly transparent (a fully transparent pixel counts + /// as the color 0); otherwise null. The pixels are read once more for this, in bands. + /// + private static uint[] CollectColors(IWICImagingFactory factory, WicPixelSource source, int maxColors) + { + IWICFormatConverter converter = null; + try + { + IWICBitmapSource bgra = source; + if (source.PixelFormat != WicPixelFormats.Format32bppBgra) + { + Wic.Check(factory.CreateFormatConverter(out converter), "IWICImagingFactory.CreateFormatConverter"); + var format = WicPixelFormats.Format32bppBgra; + Wic.Check(converter.Initialize(source, ref format, WicBitmapDitherType.None, null, 0, WicBitmapPaletteType.Custom), "IWICFormatConverter.Initialize", source); + bgra = (IWICBitmapSource)converter; + } + var rowBytes = source.Width * 4; + var bandRows = Math.Max(1, Math.Min(source.Height, BandBytes / rowBytes)); + var buffer = new uint[bandRows * source.Width]; + var colors = new HashSet(); + var bufferHandle = GCHandle.Alloc(buffer, GCHandleType.Pinned); + try + { + for (int top = 0; top < source.Height; top += bandRows) + { + var count = Math.Min(bandRows, source.Height - top); + var rect = new WicRect(0, top, source.Width, count); + var rectHandle = GCHandle.Alloc(rect, GCHandleType.Pinned); + try + { + Wic.Check(bgra.CopyPixels(rectHandle.AddrOfPinnedObject(), (uint)rowBytes, (uint)(count * rowBytes), bufferHandle.AddrOfPinnedObject()), "IWICBitmapSource.CopyPixels", source); + } + finally + { + rectHandle.Free(); + } + for (int i = 0; i < count * source.Width; i++) + { + var color = buffer[i]; + var alpha = color >> 24; + if (alpha == 0) + { + color = 0; + } + else if (alpha != 255) + { + return null; + } + if (colors.Add(color) && colors.Count > maxColors) + { + return null; + } + } + } + } + finally + { + bufferHandle.Free(); + } + var result = new uint[colors.Count]; + colors.CopyTo(result); + Array.Sort(result); + return result; + } + finally + { + Wic.Release(converter); + } + } + /// /// The bits per pixel of the indexed WIC pixel formats (GUID_WICPixelFormat1bppIndexed .. 8bppIndexed), 0 for the others /// @@ -550,6 +685,7 @@ private static T WithFrame(Stream source, bool applyOrientation, Func(Stream source, bool applyOrientation, Func(Stream source, bool applyOrientation, Func(Stream source, bool applyOrientation, Func action) { IWICFormatConverter converter = null; diff --git a/src/Dapplo.Windows.Imaging/WicPixelFormats.cs b/src/Dapplo.Windows.Imaging/WicPixelFormats.cs index 4af83f70..cbfaf3b4 100644 --- a/src/Dapplo.Windows.Imaging/WicPixelFormats.cs +++ b/src/Dapplo.Windows.Imaging/WicPixelFormats.cs @@ -55,4 +55,24 @@ public static class WicPixelFormats /// GUID_WICPixelFormat128bppRGBAFloat: 32 bit floating point R, G, B, A, for HDR. /// public static readonly Guid Format128bppRgbaFloat = new Guid("6FDDC324-4E03-4BFE-B185-3D77768DC919"); + /// + /// GUID_WICPixelFormat1bppIndexed: 1 bit palette index, needs when encoding + /// + public static readonly Guid Format1bppIndexed = new Guid("6FDDC324-4E03-4BFE-B185-3D77768DC901"); + + /// + /// GUID_WICPixelFormat2bppIndexed: 2 bit palette index, needs when encoding + /// + public static readonly Guid Format2bppIndexed = new Guid("6FDDC324-4E03-4BFE-B185-3D77768DC902"); + + /// + /// GUID_WICPixelFormat4bppIndexed: 4 bit palette index, needs when encoding + /// + public static readonly Guid Format4bppIndexed = new Guid("6FDDC324-4E03-4BFE-B185-3D77768DC903"); + + /// + /// GUID_WICPixelFormat8bppIndexed: one byte palette index per pixel, needs when encoding. + /// The same layout as GDI+ Format8bppIndexed. + /// + public static readonly Guid Format8bppIndexed = new Guid("6FDDC324-4E03-4BFE-B185-3D77768DC904"); } diff --git a/src/Dapplo.Windows.Tests/WicResolutionTests.cs b/src/Dapplo.Windows.Tests/WicResolutionTests.cs new file mode 100644 index 00000000..9e0116f3 --- /dev/null +++ b/src/Dapplo.Windows.Tests/WicResolutionTests.cs @@ -0,0 +1,497 @@ +// Copyright (c) Dapplo and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using Dapplo.Log; +using Dapplo.Log.XUnit; +using Dapplo.Windows.Imaging; +using Xunit; + +namespace Dapplo.Windows.Tests; + +/// +/// WicFrameInfo.HasResolution per container, and palettes for WicImage.Encode. +/// The files are made in the tests: encoded with WIC, the resolution fields are then patched in the bytes. +/// +public class WicResolutionTests +{ + private static readonly LogSource Log = new LogSource(); + + public WicResolutionTests(ITestOutputHelper testOutputHelper) + { + LogSettings.RegisterDefaultLogger(LogLevels.Verbose, testOutputHelper); + } + + private const int Width = 5; + private const int Height = 3; + + private static byte[] Encode(Guid container, double dpi = 96, Action metadata = null) + { + var pixels = new byte[Width * Height * 4]; + for (int i = 0; i < pixels.Length; i++) + { + pixels[i] = (i % 4) == 3 ? (byte)255 : (byte)(i * 17); + } + var options = new WicEncodeOptions { ContainerFormat = container, DpiX = dpi, DpiY = dpi, Metadata = metadata }; + using var output = new MemoryStream(); + var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + WicImage.Encode(output, options, handle.AddrOfPinnedObject(), Width, Height, Width * 4, WicPixelFormats.Format32bppBgra); + } + finally + { + handle.Free(); + } + return output.ToArray(); + } + + private static WicFrameInfo Info(byte[] file) + { + var info = WicImage.GetFrameInfo(new MemoryStream(file)); + Log.Info().WriteLine("{0}: HasResolution {1}, {2} x {3} DPI", info.ContainerFormat, info.HasResolution, info.DpiX, info.DpiY); + return info; + } + + private static bool HasDecoder(Guid containerFormat) => WicCodecs.GetDecoders().Any(e => e.ContainerFormat == containerFormat); + + private static bool CanCreateEncoder(Guid containerFormat) + { + if (!WicCodecs.GetEncoders().Any(e => e.ContainerFormat == containerFormat)) + { + return false; + } + try + { + WicImage.GetEncoderOptions(containerFormat); + return true; + } + catch (WicCodecNotFoundException) + { + return false; + } + } + + #region PNG + + private static readonly uint[] CrcTable = Enumerable.Range(0, 256).Select(n => + { + var c = (uint)n; + for (int k = 0; k < 8; k++) + { + c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1; + } + return c; + }).ToArray(); + + private static uint Crc32(byte[] data, int offset, int length) + { + var crc = 0xFFFFFFFF; + for (int i = offset; i < offset + length; i++) + { + crc = CrcTable[(crc ^ data[i]) & 0xFF] ^ (crc >> 8); + } + return crc ^ 0xFFFFFFFF; + } + + private static void WriteBigEndian(List target, uint value) + { + target.Add((byte)(value >> 24)); + target.Add((byte)(value >> 16)); + target.Add((byte)(value >> 8)); + target.Add((byte)value); + } + + private static uint ReadBigEndian(byte[] data, int offset) => (uint)(data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3]); + + /// + /// Remove every pHYs chunk and, when pixelsPerUnit is given, add one after IHDR with that value and unit + /// + private static byte[] WithPhys(byte[] png, uint? pixelsPerUnit, byte unit) + { + var result = new List(png.Take(8)); + var offset = 8; + while (offset < png.Length) + { + var length = (int)ReadBigEndian(png, offset); + var type = Encoding.ASCII.GetString(png, offset + 4, 4); + var chunkLength = 12 + length; + if (type != "pHYs") + { + result.AddRange(png.Skip(offset).Take(chunkLength)); + } + if (type == "IHDR" && pixelsPerUnit.HasValue) + { + var chunk = new List(); + WriteBigEndian(chunk, 9); + chunk.AddRange(Encoding.ASCII.GetBytes("pHYs")); + WriteBigEndian(chunk, pixelsPerUnit.Value); + WriteBigEndian(chunk, pixelsPerUnit.Value); + chunk.Add(unit); + var bytes = chunk.ToArray(); + WriteBigEndian(chunk, Crc32(bytes, 4, bytes.Length - 4)); + result.AddRange(chunk); + } + offset += chunkLength; + } + return result.ToArray(); + } + + [Fact] + public void Png_Phys72_HasResolution() + { + // 72 DPI = 2835 pixels per meter + var info = Info(WithPhys(Encode(WicContainerFormats.Png), 2835, 1)); + Assert.True(info.HasResolution); + Assert.InRange(info.DpiX, 71.9, 72.1); + } + + [Fact] + public void Png_EncodedByWic_HasResolution() + { + var file = Encode(WicContainerFormats.Png, 72); + var info = Info(file); + // The WIC encoder writes pHYs for the resolution + Assert.Equal(file.AsSpan().IndexOf(Encoding.ASCII.GetBytes("pHYs")) >= 0, info.HasResolution); + } + + [Fact] + public void Png_WithoutPhys_NoResolution() + { + var info = Info(WithPhys(Encode(WicContainerFormats.Png, 72), null, 0)); + Assert.False(info.HasResolution); + Assert.Equal(96, info.DpiX, 1); + } + + [Fact] + public void Png_PhysAspectRatioOnly_NoResolution() + { + var info = Info(WithPhys(Encode(WicContainerFormats.Png), 1, 0)); + Assert.False(info.HasResolution); + } + + #endregion + + #region JPEG + + /// + /// Remove the APP0 (and when wanted the APP1) segments and add a JFIF APP0 with the units and density after SOI + /// + private static byte[] WithJfif(byte[] jpeg, byte units, ushort density, bool removeApp1) + { + Assert.True(jpeg[0] == 0xFF && jpeg[1] == 0xD8, "Not a JPEG"); + var result = new List { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 }; + result.AddRange(Encoding.ASCII.GetBytes("JFIF\0")); + result.AddRange(new byte[] { 1, 1, units, (byte)(density >> 8), (byte)density, (byte)(density >> 8), (byte)density, 0, 0 }); + var offset = 2; + while (offset + 4 <= jpeg.Length && jpeg[offset] == 0xFF) + { + var marker = jpeg[offset + 1]; + if (marker == 0xDA) + { + break; + } + var length = jpeg[offset + 2] << 8 | jpeg[offset + 3]; + if (marker != 0xE0 && !(removeApp1 && marker == 0xE1)) + { + result.AddRange(jpeg.Skip(offset).Take(2 + length)); + } + offset += 2 + length; + } + // The scan and everything after it + result.AddRange(jpeg.Skip(offset)); + return result.ToArray(); + } + + [Fact] + public void Jpeg_JfifInch96_HasResolution() + { + var info = Info(WithJfif(Encode(WicContainerFormats.Jpeg), 1, 96, true)); + Assert.True(info.HasResolution); + Assert.Equal(96, info.DpiX, 1); + } + + [Fact] + public void Jpeg_JfifNoUnitsWithoutExif_NoResolution() + { + var info = Info(WithJfif(Encode(WicContainerFormats.Jpeg), 0, 1, true)); + Assert.False(info.HasResolution); + } + + [Fact] + public void Jpeg_ExifXResolution72Only_HasResolution() + { + var file = Encode(WicContainerFormats.Jpeg, 72, writer => + { + Assert.True(writer.Set("/app1/ifd/{ushort=282}", new WicRational(72, 1))); + Assert.True(writer.Set("/app1/ifd/{ushort=283}", new WicRational(72, 1))); + }); + var info = Info(WithJfif(file, 0, 1, false)); + Assert.True(info.Metadata.TryGet("/app1/ifd/{ushort=282}", out _), "The EXIF XResolution is missing"); + Assert.False(info.Metadata.TryGet("/app1/ifd/{ushort=296}", out _), "The test needs a file without ResolutionUnit"); + Assert.True(info.HasResolution); + Assert.Equal(72, info.DpiX, 1); + } + + [Fact] + public void Jpeg_ExifResolutionUnitNone_NoResolution() + { + var file = Encode(WicContainerFormats.Jpeg, 72, writer => + { + Assert.True(writer.Set("/app1/ifd/{ushort=282}", new WicRational(72, 1))); + Assert.True(writer.Set("/app1/ifd/{ushort=296}", (ushort)1)); + }); + Assert.False(Info(WithJfif(file, 0, 1, false)).HasResolution); + } + + #endregion + + [Fact] + public void Tiff_XResolution_HasResolution() + { + var info = Info(Encode(WicContainerFormats.Tiff, 150)); + Assert.True(info.Metadata.TryGet("/ifd/{ushort=282}", out _)); + Assert.True(info.HasResolution); + Assert.Equal(150, info.DpiX, 1); + } + + [Fact] + public void Bmp_PixelsPerMeter() + { + var file = Encode(WicContainerFormats.Bmp); + var pixelsPerMeter = BitConverter.ToInt32(file, 38); + Log.Info().WriteLine("The WIC BMP encoder wrote {0} pixels per meter", pixelsPerMeter); + Assert.InRange(pixelsPerMeter, 3779, 3780); + var info = Info(file); + Assert.True(info.HasResolution); + Assert.Equal(96, info.DpiX, 0); + + // biXPelsPerMeter and biYPelsPerMeter 0 + Array.Clear(file, 38, 8); + var withoutResolution = Info(file); + Assert.False(withoutResolution.HasResolution); + } + + [Fact] + public void Gif_NoResolution() + { + Assert.False(Info(Encode(WicContainerFormats.Gif)).HasResolution); + } + + [Fact] + public void JpegXr_HasResolution() + { + Assert.SkipUnless(CanCreateEncoder(WicContainerFormats.Wmp), "The JPEG XR encoder is not available"); + var info = Info(Encode(WicContainerFormats.Wmp, 120)); + Assert.True(info.HasResolution); + Assert.Equal(120, info.DpiX, 1); + } + + /// + /// A 1x1 lossless WebP (VP8L) without EXIF + /// + private static readonly byte[] WebpWithoutExif = Convert.FromBase64String("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=="); + + [Fact] + public void Webp_WithoutExif_NoResolution() + { + Assert.SkipUnless(HasDecoder(WicContainerFormats.Webp), "The WebP decoder is not installed"); + WicFrameInfo info; + try + { + info = Info(WebpWithoutExif); + } + catch (WicCodecNotFoundException ex) + { + Assert.Skip($"The WebP decoder can't be used: {ex.Message}"); + return; + } + Assert.Equal(WicContainerFormats.Webp, info.ContainerFormat); + Assert.False(info.HasResolution); + Assert.True(info.DpiX > 0); + } + + [Fact] + public void Decode_ReportsHasResolution() + { + var file = WithPhys(Encode(WicContainerFormats.Png), null, 0); + var pixels = new byte[Width * Height * 4]; + var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + var info = WicImage.Decode(new MemoryStream(file), WicPixelFormats.Format32bppBgra, handle.AddrOfPinnedObject(), Width * 4); + Assert.False(info.HasResolution); + } + finally + { + handle.Free(); + } + var rowsInfo = WicImage.Decode(new MemoryStream(WithPhys(file, 3780, 1)), WicPixelFormats.Format32bppBgra, (int top, int count, ReadOnlySpan rows, int stride) => { }); + Assert.True(rowsInfo.HasResolution); + } + + [Fact] + public void HasResolution_StreamAtOffset_PositionUnchanged() + { + var file = Encode(WicContainerFormats.Bmp); + using var stream = new MemoryStream(); + stream.Write(new byte[17], 0, 17); + stream.Write(file, 0, file.Length); + stream.Position = 17; + var info = WicImage.GetFrameInfo(stream); + Assert.True(info.HasResolution); + Assert.Equal(17, stream.Position); + } + + #region Palette + + private static byte[] DecodeBgra(byte[] file, int width, int height) + { + var decoded = new byte[width * height * 4]; + var handle = GCHandle.Alloc(decoded, GCHandleType.Pinned); + try + { + WicImage.Decode(new MemoryStream(file), WicPixelFormats.Format32bppBgra, handle.AddrOfPinnedObject(), width * 4); + } + finally + { + handle.Free(); + } + return decoded; + } + + private static byte[] EncodePixels(WicEncodeOptions options, byte[] pixels, int width, int height, int stride, Guid pixelFormat, out WicEncodeResult result) + { + using var output = new MemoryStream(); + var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + result = WicImage.Encode(output, options, handle.AddrOfPinnedObject(), width, height, stride, pixelFormat); + } + finally + { + handle.Free(); + } + return output.ToArray(); + } + + /// + /// 32bpp pixels with at most 256 colors (and a transparent pixel) are written to GIF with exactly these colors + /// + [Fact] + public void Gif_Bgra32With256Colors_IdenticalPixels() + { + const int width = 37; + const int height = 9; + var random = new Random(4711); + var colors = Enumerable.Range(0, 255).Select(_ => (uint)random.Next(0x1000000) | 0xFF000000).Append(0u).ToArray(); + var pixels = new byte[width * height * 4]; + for (int i = 0; i < width * height; i++) + { + BitConverter.GetBytes(colors[(i * 7) % colors.Length]).CopyTo(pixels, i * 4); + } + var file = EncodePixels(new WicEncodeOptions { ContainerFormat = WicContainerFormats.Gif }, pixels, width, height, width * 4, WicPixelFormats.Format32bppBgra, out var result); + Assert.Equal(WicPixelFormats.Format8bppIndexed, result.PixelFormat); + Assert.Equal(pixels, DecodeBgra(file, width, height)); + } + + /// + /// 8bpp indexed pixels with the palette of the options + /// + [Theory] + [InlineData("gif")] + [InlineData("png")] + [InlineData("bmp")] + public void Encode_Indexed8WithPalette(string containerName) + { + var container = containerName switch + { + "gif" => WicContainerFormats.Gif, + "png" => WicContainerFormats.Png, + _ => WicContainerFormats.Bmp + }; + const int width = 7; + const int height = 3; + const int stride = 8; + var palette = new uint[] { 0xFF000000, 0xFFFF0000, 0xFF00FF00, 0xFF0000FF, 0xFFFFFFFF, 0xFF123456 }; + var indexes = new byte[stride * height]; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + indexes[y * stride + x] = (byte)((x + y) % palette.Length); + } + } + var file = EncodePixels(new WicEncodeOptions { ContainerFormat = container, Palette = palette }, indexes, width, height, stride, WicPixelFormats.Format8bppIndexed, out var result); + Log.Info().WriteLine("{0}: {1}", containerName, result.PixelFormat); + var decoded = DecodeBgra(file, width, height); + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + Assert.Equal(palette[indexes[y * stride + x]], BitConverter.ToUInt32(decoded, (y * width + x) * 4)); + } + } + } + + /// + /// A palette for 32bpp pixels: the encoder writes an indexed format with the nearest colors + /// + [Fact] + public void Encode_Bgra32WithPalette_NearestColor() + { + const int width = 4; + const int height = 2; + var palette = new uint[] { 0xFF000000, 0xFFFFFFFF, 0xFFFF0000 }; + var source = new uint[] { 0xFF010101, 0xFFFEFEFE, 0xFFF00000, 0xFF000000, 0xFFFFFFFF, 0xFFFF0101, 0xFF020000, 0xFFEEEEEE }; + var expected = new uint[] { 0xFF000000, 0xFFFFFFFF, 0xFFFF0000, 0xFF000000, 0xFFFFFFFF, 0xFFFF0000, 0xFF000000, 0xFFFFFFFF }; + var pixels = source.SelectMany(BitConverter.GetBytes).ToArray(); + var file = EncodePixels(new WicEncodeOptions { ContainerFormat = WicContainerFormats.Png, Palette = palette }, pixels, width, height, width * 4, WicPixelFormats.Format32bppBgra, out var result); + Assert.Equal(WicPixelFormats.Format8bppIndexed, result.PixelFormat); + var decoded = DecodeBgra(file, width, height); + Assert.Equal(expected, Enumerable.Range(0, width * height).Select(i => BitConverter.ToUInt32(decoded, i * 4)).ToArray()); + } + + [Fact] + public void Encode_IndexedWithoutPalette_Throws() + { + var indexes = new byte[4]; + Assert.Throws(() => EncodePixels(new WicEncodeOptions { ContainerFormat = WicContainerFormats.Gif }, indexes, 2, 2, 2, WicPixelFormats.Format8bppIndexed, out _)); + Assert.Throws(() => EncodePixels(new WicEncodeOptions { ContainerFormat = WicContainerFormats.Gif, Palette = new uint[257] }, indexes, 2, 2, 2, WicPixelFormats.Format8bppIndexed, out _)); + } + + /// + /// More than 256 colors: WIC makes the palette, with and without dithering + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Gif_ManyColors_Encodes(bool dither) + { + const int width = 64; + const int height = 64; + var pixels = new byte[width * height * 4]; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var offset = (y * width + x) * 4; + pixels[offset] = (byte)(x * 4); + pixels[offset + 1] = (byte)(y * 4); + pixels[offset + 2] = (byte)((x + y) * 2); + pixels[offset + 3] = 255; + } + } + var file = EncodePixels(new WicEncodeOptions { ContainerFormat = WicContainerFormats.Gif, Dither = dither }, pixels, width, height, width * 4, WicPixelFormats.Format32bppBgra, out _); + var info = WicImage.GetFrameInfo(new MemoryStream(file)); + Assert.Equal(width, info.Width); + var decoded = DecodeBgra(file, width, height); + Assert.All(Enumerable.Range(0, width * height), i => Assert.Equal(255, decoded[i * 4 + 3])); + } + + #endregion +} diff --git a/src/version.json b/src/version.json index 08735f16..6ecf6ec5 100644 --- a/src/version.json +++ b/src/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "3.18", + "version": "3.19", "publicReleaseRefSpec": [ "^refs/heads/master$" ], From 4f1726e89b9931b9e98f31e6475171c60cbdfbce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:30:03 +0000 Subject: [PATCH 2/2] Imaging: exact palette mapping and EXIF resolution The WIC format converter picked a neighbor of exact palette colors, the pixels are now mapped by WicIndexingPixelSource. The JPEG decoder ignores the EXIF resolution, DpiX/DpiY take it when the file stores only that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017qu4NGxUZizEKzrG8d2DGY --- CHANGELOG.md | 4 +- doc/articles/imaging.md | 3 +- .../Internal/ResolutionProbe.cs | 76 +++++++++++-- .../Internal/WicPixelSource.cs | 103 ++++++++++++++++++ src/Dapplo.Windows.Imaging/WicFrameInfo.cs | 4 +- src/Dapplo.Windows.Imaging/WicImage.cs | 39 +++++-- 6 files changed, 206 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6651643c..4ca4812c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,12 @@ Read the [migration guide](doc/articles/migration-3.0.md) before upgrading. ## [3.19.0] ### Added -- `WicFrameInfo.HasResolution` in Dapplo.Windows.Imaging: true when the file itself stores a resolution, false when `DpiX` / `DpiY` are only the default of the decoder. WIC can't say "unknown", every decoder fills in its own default (96 for a PNG without pHYs, 72 for a WebP without EXIF), so "the file says 72" (macOS screenshots, camera JPEGs) couldn't be told apart from "the file says nothing". Per container: PNG pHYs with the unit meter, JPEG JFIF density in inch or cm or EXIF XResolution, TIFF/DNG XResolution, JPEG XR WIDTH_RESOLUTION, BMP pixels per meter other than 0, GIF/ICO/DDS never, other containers an EXIF IFD with XResolution; a ResolutionUnit of 1 (none) or a pHYs/JFIF unit 0 (aspect ratio only) is no resolution. It is filled by `GetFrameInfo` and every `Decode` overload from the frame which is open anyway; only PNG (when WIC doesn't expose pHYs), JPEG XR and BMP read a few bytes of the file, the stream position is restored. +- `WicFrameInfo.HasResolution` in Dapplo.Windows.Imaging: true when the file itself stores a resolution, false when `DpiX` / `DpiY` are only the default of the decoder. WIC can't say "unknown", every decoder fills in its own default (96 for a PNG without pHYs, 72 for a WebP without EXIF), so "the file says 72" (macOS screenshots, camera JPEGs) couldn't be told apart from "the file says nothing". Per container: PNG pHYs with the unit meter, JPEG JFIF density in inch or cm or EXIF XResolution, TIFF/DNG XResolution, JPEG XR WIDTH_RESOLUTION, BMP pixels per meter other than 0, GIF/ICO/DDS never, other containers an EXIF IFD with XResolution; a ResolutionUnit of 1 (none) or a pHYs/JFIF unit 0 (aspect ratio only) is no resolution. When the resolution is only in EXIF, `DpiX` / `DpiY` are now the EXIF values: the WIC JPEG decoder reported 96 for a JPEG with only EXIF XResolution 72. It is filled by `GetFrameInfo` and every `Decode` overload from the frame which is open anyway; only PNG (when WIC doesn't expose pHYs), JPEG XR and BMP read a few bytes of the file, the stream position is restored. - `WicEncodeOptions.Palette` (up to 256 colors as 0xAARRGGBB) and the indexed pixel formats `WicPixelFormats.Format1bppIndexed` .. `Format8bppIndexed`: indexed pixels are encoded with that palette (GIF, PNG, BMP, TIFF), other pixels get the nearest color of the palette and the encoder is asked for an 8 bit indexed format. - `WicEncodeOptions.Dither`: error diffusion when the pixels are reduced to fewer colors than they have. ### Changed -- GIF without a palette in the options: pixels with at most 256 colors and no partly transparent pixel get exactly these colors as palette, so decoding gives the same pixels. Before, WIC always chose the palette (`IWICPalette::InitializeFromBitmap`), which could change colors; it still does for more colors. +- GIF without a palette in the options: pixels with at most 256 colors and no partly transparent pixel get exactly these colors as palette, so decoding gives the same pixels; the pixels are mapped to the palette by Dapplo.Windows.Imaging, the WIC format converter picked a neighbor of an exact color. Before, WIC always chose the palette (`IWICPalette::InitializeFromBitmap`), which could change colors; it still does for more colors. ## [3.18.0] diff --git a/doc/articles/imaging.md b/doc/articles/imaging.md index f473249e..1adda355 100644 --- a/doc/articles/imaging.md +++ b/doc/articles/imaging.md @@ -168,7 +168,8 @@ resolution: | others (WebP, HEIF, AVIF, JPEG XL, RAW, ...) | an EXIF IFD with XResolution and a ResolutionUnit other than none | The metadata of the frame is used, the bytes of the file are read only for a PNG whose pHYs WIC doesn't expose, JPEG XR -and BMP. +and BMP. When the resolution is only in EXIF, `DpiX` / `DpiY` are the EXIF values: the JPEG decoder of WIC reports its +default (96) for a JPEG with only EXIF XResolution. ## When there is no codec diff --git a/src/Dapplo.Windows.Imaging/Internal/ResolutionProbe.cs b/src/Dapplo.Windows.Imaging/Internal/ResolutionProbe.cs index c0236327..49d201e4 100644 --- a/src/Dapplo.Windows.Imaging/Internal/ResolutionProbe.cs +++ b/src/Dapplo.Windows.Imaging/Internal/ResolutionProbe.cs @@ -19,6 +19,7 @@ internal static class ResolutionProbe private static readonly LogSource Log = new LogSource(); private const int TagXResolution = 282; + private const int TagYResolution = 283; private const int TagResolutionUnit = 296; /// @@ -31,8 +32,11 @@ internal static class ResolutionProbe /// the container format of the decoder /// the seekable stream the decoder reads, its position is restored /// the position of the file in the stream - public static bool HasResolution(IWICMetadataQueryReader queryReader, WicMetadata metadata, Guid containerFormat, Stream input, long origin) + /// the horizontal resolution of the EXIF IFD, when the resolution comes from EXIF where the decoder doesn't use it (JPEG without JFIF density, WebP, HEIF, ...) + /// the vertical resolution of the EXIF IFD, see exifDpiX + public static bool HasResolution(IWICMetadataQueryReader queryReader, WicMetadata metadata, Guid containerFormat, Stream input, long origin, out double? exifDpiX, out double? exifDpiY) { + exifDpiX = exifDpiY = null; try { if (containerFormat == WicContainerFormats.Png) @@ -41,11 +45,12 @@ public static bool HasResolution(IWICMetadataQueryReader queryReader, WicMetadat } if (containerFormat == WicContainerFormats.Jpeg) { - return Jpeg(queryReader); + return Jpeg(queryReader, out exifDpiX, out exifDpiY); } if (containerFormat == WicContainerFormats.Tiff || containerFormat == WicContainerFormats.Adng) { - return IfdResolution(queryReader, "/ifd"); + // The TIFF decoder reports the resolution of the IFD itself + return IfdResolution(queryReader, "/ifd", out _, out _); } if (containerFormat == WicContainerFormats.Wmp) { @@ -60,7 +65,7 @@ public static bool HasResolution(IWICMetadataQueryReader queryReader, WicMetadat // GIF only has a pixel aspect ratio, ICO and DDS have nothing return false; } - return ExifResolution(metadata); + return ExifResolution(metadata, out exifDpiX, out exifDpiY); } catch (Exception ex) when (ex is IOException || ex is NotSupportedException || ex is ObjectDisposedException || ex is InvalidCastException) { @@ -110,45 +115,92 @@ private static bool Png(IWICMetadataQueryReader queryReader, Stream input, long /// /// JPEG: the JFIF density unit 1 (inch) or 2 (cm), or an EXIF XResolution with a ResolutionUnit other than 1 (none) /// - private static bool Jpeg(IWICMetadataQueryReader queryReader) + private static bool Jpeg(IWICMetadataQueryReader queryReader, out double? exifDpiX, out double? exifDpiY) { + exifDpiX = exifDpiY = null; if (TryQueryInteger(queryReader, "/app0/{ushort=1}", out var units) && (units == 1 || units == 2)) { + // The decoder reports the JFIF density return true; } - return IfdResolution(queryReader, "/app1/ifd"); + // The JPEG decoder doesn't report the EXIF resolution (verified on the CI runner: 96 for a JPEG with only EXIF XResolution 72) + return IfdResolution(queryReader, "/app1/ifd", out exifDpiX, out exifDpiY); } /// /// TIFF IFD: XResolution present and ResolutionUnit not 1 (none); without ResolutionUnit the unit is inch /// - private static bool IfdResolution(IWICMetadataQueryReader queryReader, string ifdPath) + private static bool IfdResolution(IWICMetadataQueryReader queryReader, string ifdPath, out double? dpiX, out double? dpiY) { - if (!TryQuery(queryReader, $"{ifdPath}/{{ushort={TagXResolution}}}", out _)) + dpiX = dpiY = null; + if (!TryQuery(queryReader, $"{ifdPath}/{{ushort={TagXResolution}}}", out var xResolution)) { return false; } - return !TryQueryInteger(queryReader, $"{ifdPath}/{{ushort={TagResolutionUnit}}}", out var unit) || unit != 1; + var unit = TryQueryInteger(queryReader, $"{ifdPath}/{{ushort={TagResolutionUnit}}}", out var storedUnit) ? storedUnit : 2; + if (unit == 1) + { + return false; + } + TryQuery(queryReader, $"{ifdPath}/{{ushort={TagYResolution}}}", out var yResolution); + (dpiX, dpiY) = ToDpi(xResolution, yResolution, unit); + return true; + } + + /// + /// The EXIF rationals (VT_UI8 with the numerator in the low 32 bits) in dots per inch, unit 3 is centimeter; null for values which can't be used + /// + private static (double? DpiX, double? DpiY) ToDpi(object xResolution, object yResolution, long unit) + { + var x = ToDouble(xResolution); + var y = ToDouble(yResolution) ?? x; + if (x == null || y == null) + { + return (null, null); + } + var factor = unit == 3 ? 2.54 : 1.0; + return (x * factor, y * factor); + } + + private static double? ToDouble(object rational) + { + WicRational? value = rational switch + { + ulong packed => WicRational.FromPacked(packed), + long signedPacked => WicRational.FromPacked(unchecked((ulong)signedPacked)), + _ => null + }; + if (value == null || value.Value.Denominator == 0 || value.Value.Numerator == 0) + { + return null; + } + return value.Value.ToDouble(); } /// /// Other containers (WebP, HEIF, JPEG XL, RAW, codecs of other vendors): an EXIF IFD (IFD0) with XResolution and a ResolutionUnit other than 1. /// The path of the IFD differs per container, the IFD blocks are found in the enumerated metadata. /// - private static bool ExifResolution(WicMetadata metadata) + private static bool ExifResolution(WicMetadata metadata, out double? dpiX, out double? dpiY) { + dpiX = dpiY = null; foreach (var item in metadata.Items) { if (item.Value is not WicMetadataBlock block || block.Format != WicMetadataFormats.Ifd) { continue; } - if (!metadata.TryGet($"{block.Path}/{{ushort={TagXResolution}}}", out _)) + if (!metadata.TryGet($"{block.Path}/{{ushort={TagXResolution}}}", out var xResolution)) { continue; } - if (!metadata.TryGet($"{block.Path}/{{ushort={TagResolutionUnit}}}", out var unit) || unit is not IConvertible convertible || convertible.ToInt64(null) != 1) + var unit = metadata.TryGet($"{block.Path}/{{ushort={TagResolutionUnit}}}", out var storedUnit) && storedUnit is IConvertible convertible && storedUnit is not string + ? convertible.ToInt64(null) + : 2; + if (unit != 1) { + metadata.TryGet($"{block.Path}/{{ushort={TagYResolution}}}", out var yResolution); + (dpiX, dpiY) = ToDpi(xResolution, yResolution, unit); return true; } } diff --git a/src/Dapplo.Windows.Imaging/Internal/WicPixelSource.cs b/src/Dapplo.Windows.Imaging/Internal/WicPixelSource.cs index 00832fbe..7b1a3cea 100644 --- a/src/Dapplo.Windows.Imaging/Internal/WicPixelSource.cs +++ b/src/Dapplo.Windows.Imaging/Internal/WicPixelSource.cs @@ -3,6 +3,7 @@ using System; using System.Buffers; +using System.Collections.Generic; using System.Runtime.InteropServices; using Dapplo.Windows.Common.Enums; using Dapplo.Windows.Imaging.Interop; @@ -198,3 +199,105 @@ protected override unsafe void CopyRows(WicRect rect, int xOffset, int rectRowBy } } } + +/// +/// 8 bit palette indexes for 32bpp BGRA pixels, each pixel gets its exact color or the nearest color of the palette. +/// The WIC format converter maps colors through a reduced lookup and can pick a neighbor of an exact color, this keeps the colors exact. +/// +internal sealed class WicIndexingPixelSource : WicPixelSource +{ + private readonly IWICBitmapSource _bgra; + private readonly IFailureSource _bgraFailures; + private readonly Dictionary _indexes = new Dictionary(); + private readonly int _transparentIndex = -1; + + /// the pixels as GUID_WICPixelFormat32bppBGRA + /// the source which reports the exceptions of the pixels, e.g. of a row callback + /// at most 256 colors (0xAARRGGBB) + public WicIndexingPixelSource(IWICBitmapSource bgra, IFailureSource bgraFailures, uint[] palette, int width, int height, double dpiX, double dpiY) + : base(width, height, WicPixelFormats.Format8bppIndexed, 8, dpiX, dpiY) + { + _bgra = bgra; + _bgraFailures = bgraFailures; + Palette = palette; + for (int i = 0; i < palette.Length; i++) + { + if (palette[i] >> 24 == 0) + { + _transparentIndex = i; + break; + } + } + } + + protected override unsafe void CopyRows(WicRect rect, int xOffset, int rectRowBytes, int destinationStride, uint destinationSize, byte* destination) + { + var rowPixels = rect.Width; + var bandRows = Math.Max(1, Math.Min(rect.Height, 1024 * 1024 / Math.Max(1, rowPixels))); + var buffer = ArrayPool.Shared.Rent(bandRows * rowPixels); + try + { + fixed (uint* bufferPointer = buffer) + { + for (int top = 0; top < rect.Height; top += bandRows) + { + var count = Math.Min(bandRows, rect.Height - top); + var band = new WicRect(rect.X, rect.Y + top, rect.Width, count); + Wic.Check(_bgra.CopyPixels((nint)(&band), (uint)(rowPixels * 4), (uint)(count * rowPixels * 4), (nint)bufferPointer), "IWICBitmapSource.CopyPixels", _bgraFailures); + for (int row = 0; row < count; row++) + { + var target = destination + (long)(top + row) * destinationStride; + var source = bufferPointer + row * rowPixels; + for (int x = 0; x < rowPixels; x++) + { + target[x] = IndexOf(source[x]); + } + } + } + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private byte IndexOf(uint color) + { + // Mostly transparent pixels (below the threshold of 50 % WIC uses) get the transparent color, when the palette has one + if (color >> 24 < 128 && _transparentIndex >= 0) + { + return (byte)_transparentIndex; + } + if (_indexes.TryGetValue(color, out var index)) + { + return index; + } + var best = 0; + var bestDistance = long.MaxValue; + for (int i = 0; i < Palette.Length; i++) + { + var entry = Palette[i]; + if (entry == color) + { + best = i; + break; + } + if (i == _transparentIndex) + { + continue; + } + long db = (int)(entry & 0xFF) - (int)(color & 0xFF); + long dg = (int)(entry >> 8 & 0xFF) - (int)(color >> 8 & 0xFF); + long dr = (int)(entry >> 16 & 0xFF) - (int)(color >> 16 & 0xFF); + var distance = dr * dr + dg * dg + db * db; + if (distance < bestDistance) + { + bestDistance = distance; + best = i; + } + } + _indexes[color] = (byte)best; + return (byte)best; + } +} diff --git a/src/Dapplo.Windows.Imaging/WicFrameInfo.cs b/src/Dapplo.Windows.Imaging/WicFrameInfo.cs index 825bfa59..15368bd6 100644 --- a/src/Dapplo.Windows.Imaging/WicFrameInfo.cs +++ b/src/Dapplo.Windows.Imaging/WicFrameInfo.cs @@ -35,7 +35,9 @@ internal WicFrameInfo(int width, int height, double dpiX, double dpiY, bool hasR public int Height { get; } /// - /// The horizontal resolution in dots per inch, after the orientation when it is applied + /// The horizontal resolution in dots per inch, after the orientation when it is applied. + /// Reported by the decoder; when the file stores its resolution only in EXIF and the decoder doesn't report that (JPEG without JFIF density, WebP, HEIF, ...) + /// it is the EXIF XResolution. See . /// public double DpiX { get; } diff --git a/src/Dapplo.Windows.Imaging/WicImage.cs b/src/Dapplo.Windows.Imaging/WicImage.cs index a9b51f40..ecc22001 100644 --- a/src/Dapplo.Windows.Imaging/WicImage.cs +++ b/src/Dapplo.Windows.Imaging/WicImage.cs @@ -155,6 +155,7 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio IWICBitmapFrameEncode frame = null; IPropertyBag2 propertyBag = null; IWICFormatConverter converter = null; + IWICFormatConverter bgraConverter = null; IWICMetadataQueryWriter queryWriter = null; IWICPalette palette = null; try @@ -204,11 +205,13 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio } var targetIndexedBits = IndexedBitsPerPixel(pixelFormat); + IWICBitmapSource pixels = source; + WicPixelSource pixelsFailures = source; if (targetIndexedBits > 0) { // An indexed format (e.g. GIF) needs a palette: the given one, the exact colors of the pixels when they fit, otherwise one which WIC makes Wic.Check(factory.CreatePalette(out palette), "IWICImagingFactory.CreatePalette"); - var colors = paletteColors ?? CollectColors(factory, source, 1 << targetIndexedBits); + var colors = paletteColors ?? (sourceIndexedBits == 0 ? CollectColors(factory, source, 1 << targetIndexedBits) : null); if (colors != null) { InitializePalette(palette, colors); @@ -218,10 +221,25 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio Wic.Check(palette.InitializeFromBitmap(source, 1u << targetIndexedBits, HasAlpha(source.PixelFormat) ? 1 : 0), "IWICPalette.InitializeFromBitmap", source); } Wic.Check(frame.SetPalette(palette), "IWICBitmapFrameEncode.SetPalette", stream); + + if (sourceIndexedBits == 0 && colors != null && pixelFormat == WicPixelFormats.Format8bppIndexed && !(options.Dither && paletteColors != null)) + { + // Map the pixels to the palette here: the WIC converter can pick a neighbor of an exact color + IWICBitmapSource bgra = source; + if (source.PixelFormat != WicPixelFormats.Format32bppBgra) + { + Wic.Check(factory.CreateFormatConverter(out bgraConverter), "IWICImagingFactory.CreateFormatConverter"); + var bgraFormat = WicPixelFormats.Format32bppBgra; + Wic.Check(bgraConverter.Initialize(source, ref bgraFormat, WicBitmapDitherType.None, null, 0, WicBitmapPaletteType.Custom), "IWICFormatConverter.Initialize", source); + bgra = (IWICBitmapSource)bgraConverter; + } + var indexing = new WicIndexingPixelSource(bgra, source, colors, source.Width, source.Height, source.DpiX, source.DpiY); + pixels = indexing; + pixelsFailures = indexing; + } } - IWICBitmapSource pixels = source; - if (pixelFormat != source.PixelFormat) + if (ReferenceEquals(pixels, source) && pixelFormat != source.PixelFormat) { // The encoder wants another pixel format, WIC converts while it pulls the rows Log.Debug().WriteLine("The encoder for {0} converts {1} to {2}", options.ContainerFormat, source.PixelFormat, pixelFormat); @@ -230,7 +248,7 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio Wic.Check(converter.Initialize(source, ref pixelFormat, dither, targetIndexedBits > 0 ? palette : null, targetIndexedBits > 0 ? 50 : 0, WicBitmapPaletteType.Custom), "IWICFormatConverter.Initialize", source); pixels = (IWICBitmapSource)converter; } - Wic.Check(frame.WriteSource(pixels, IntPtr.Zero), "IWICBitmapFrameEncode.WriteSource", source, stream); + Wic.Check(frame.WriteSource(pixels, IntPtr.Zero), "IWICBitmapFrameEncode.WriteSource", pixelsFailures, stream); Wic.Check(frame.Commit(), "IWICBitmapFrameEncode.Commit", stream); Wic.Check(encoder.Commit(), "IWICBitmapEncoder.Commit", stream); stream.SeekToEndOfWritten(); @@ -247,6 +265,7 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio Wic.Release(queryWriter); Wic.Release(palette); Wic.Release(converter); + Wic.Release(bgraConverter); Wic.Release(propertyBag); Wic.Release(frame); Wic.Release(encoder); @@ -698,7 +717,13 @@ private static T WithFrame(Stream source, bool applyOrientation, Func(Stream source, bool applyOrientation, Func