diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0b74a14..4ca4812c 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. 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; 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]
### Added
diff --git a/doc/articles/imaging.md b/doc/articles/imaging.md
index 35509e8f..1adda355 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,26 @@ 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 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
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..49d201e4
--- /dev/null
+++ b/src/Dapplo.Windows.Imaging/Internal/ResolutionProbe.cs
@@ -0,0 +1,337 @@
+// 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 TagYResolution = 283;
+ 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
+ /// 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)
+ {
+ return Png(queryReader, input, origin);
+ }
+ if (containerFormat == WicContainerFormats.Jpeg)
+ {
+ return Jpeg(queryReader, out exifDpiX, out exifDpiY);
+ }
+ if (containerFormat == WicContainerFormats.Tiff || containerFormat == WicContainerFormats.Adng)
+ {
+ // The TIFF decoder reports the resolution of the IFD itself
+ return IfdResolution(queryReader, "/ifd", out _, out _);
+ }
+ 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, out exifDpiX, out exifDpiY);
+ }
+ 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, 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;
+ }
+ // 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, out double? dpiX, out double? dpiY)
+ {
+ dpiX = dpiY = null;
+ if (!TryQuery(queryReader, $"{ifdPath}/{{ushort={TagXResolution}}}", out var xResolution))
+ {
+ return false;
+ }
+ 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, 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 var xResolution))
+ {
+ continue;
+ }
+ 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;
+ }
+ }
+ 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..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;
@@ -56,7 +57,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)
{
@@ -166,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/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..15368bd6 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;
@@ -34,7 +35,9 @@ internal WicFrameInfo(int width, int height, double dpiX, double dpiY, Guid pixe
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; }
@@ -43,6 +46,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..ecc22001 100644
--- a/src/Dapplo.Windows.Imaging/WicImage.cs
+++ b/src/Dapplo.Windows.Imaging/WicImage.cs
@@ -155,12 +155,20 @@ 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
{
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 +181,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,24 +204,51 @@ private static WicEncodeResult Encode(Stream destination, WicEncodeOptions optio
metadataIssues = metadataWriter.Issues;
}
+ var targetIndexedBits = IndexedBitsPerPixel(pixelFormat);
IWICBitmapSource pixels = source;
- if (pixelFormat != source.PixelFormat)
+ WicPixelSource pixelsFailures = source;
+ if (targetIndexedBits > 0)
{
- // 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: 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 ?? (sourceIndexedBits == 0 ? CollectColors(factory, source, 1 << targetIndexedBits) : null);
+ 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);
+
+ if (sourceIndexedBits == 0 && colors != null && pixelFormat == WicPixelFormats.Format8bppIndexed && !(options.Dither && paletteColors != null))
{
- // 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);
+ // 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;
}
+ }
+
+ 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);
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);
+ 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();
@@ -225,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);
@@ -236,6 +277,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 +704,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$"
],