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$" ],