Images through WIC
Dapplo.Windows.Imaging uses the Windows Imaging Component (WIC) directly: it lists the image codecs of the PC, encodes and decodes images, and reads and writes their metadata. It needs no WPF, no System.Drawing and no other pixel library: pixels are passed as memory (a pointer and a stride) or row by row through a callback, and the input and output are .NET streams.
dotnet add package Dapplo.Windows.Imaging
Namespace used on this page: Dapplo.Windows.Imaging.
Every COM object is created per call and released before the call returns, and every method works from STA and MTA threads.
Which formats the PC can read and write
WIC lists every codec it has, including the packaged (Store) codecs like HEIF (also AVIF), WebP, Raw and JPEG XL. The
registry key of the WIC decoder category (HKCR\CLSID\{7ED96837-96F0-4812-B211-F13C24117ED3}\Instance) doesn't list
the built-in codecs on newer Windows versions and never lists the packaged ones, WicCodecs asks WIC itself.
// Every decoder WIC has, also the packaged (Store) codecs like HEIF, AVIF, WebP and JPEG XL
foreach (var decoder in WicCodecs.GetDecoders())
{
Console.WriteLine($"{decoder.FriendlyName}: {string.Join(" ", decoder.FileExtensions)} ({string.Join(" ", decoder.MimeTypes)})");
}
// Can this PC write JPEG XL?
bool canWriteJpegXl = WicCodecs.GetEncoders().Any(encoder => encoder.ContainerFormat == WicContainerFormats.JpegXl);
Each WicCodecInfo has the CLSID, the container format, the vendor, the friendly name, author and version, the file
extensions (lower case with the dot, e.g. .heic) and MIME types (lower case), and the signing status. IsFromMicrosoft
is true for the codecs of Windows and for the Store codecs of Microsoft. A codec which can't be read is left out (and
logged), it doesn't break the list. GetDecoders(refresh: true) lets WIC refresh its list, e.g. after a codec was
installed.
Encoding
WicImage.Encode takes the pixels as memory, for example the Scan0 of a locked GDI+ bitmap or a contiguous ImageSharp
buffer, and reads them without a copy of the image.
// Save a GDI+ bitmap as lossless JPEG XL, the pixels are read straight from the locked bitmap
var bitmapData = bitmap.LockBits(new Rectangle(Point.Empty, bitmap.Size), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
try
{
var options = new WicEncodeOptions
{
ContainerFormat = WicContainerFormats.JpegXl,
Lossless = true,
DpiX = bitmap.HorizontalResolution,
DpiY = bitmap.VerticalResolution,
Metadata = metadata => metadata.Set("System.ApplicationName", "Greenshot")
};
// Format32bppArgb has the byte order B, G, R, A: WicPixelFormats.Format32bppBgra
WicEncodeResult result = WicImage.Encode(destination, options, bitmapData.Scan0, bitmapData.Width, bitmapData.Height, bitmapData.Stride, WicPixelFormats.Format32bppBgra);
}
catch (WicCodecNotFoundException)
{
// The JPEG XL extension isn't installed (or can't be used), fall back to PNG
}
finally
{
bitmap.UnlockBits(bitmapData);
}
The pixel formats are WIC GUIDs, WicPixelFormats has the common ones: Format32bppBgra (GDI+ Format32bppArgb,
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 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.
// Pixels which aren't in one buffer (e.g. ImageSharp ProcessPixelRows): the encoder pulls the rows it needs
WicImage.Encode(destination, new WicEncodeOptions { ContainerFormat = WicContainerFormats.Png }, width, height, WicPixelFormats.Format32bppBgra,
(top, count, rows, stride) =>
{
for (int row = 0; row < count; row++)
{
getRow(top + row).AsSpan().CopyTo(rows.Slice(row * stride));
}
});
ImageQuality (0..1) and Lossless are set when the encoder has them, EncoderOptions takes any other option by
name. An option the encoder doesn't have is skipped; WicEncodeResult.AppliedOptions lists what was set and
WicImage.GetEncoderOptions(containerFormat) lists what an encoder has:
| Encoder | Options |
|---|---|
| JPEG | ImageQuality, BitmapTransform, SuppressApp0, JpegYCrCbSubsampling, Luminance, Chrominance and the Huffman tables |
| TIFF | TiffCompressionMethod, CompressionQuality |
| PNG | InterlaceOption, FilterOption |
| JPEG XR | ImageQuality, Lossless, BitmapTransform, UseCodecOptions, Quality, Overlap, Subsampling, tile slices, FrequencyOrder, InterleavedAlpha, AlphaQuality ... |
| BMP | EnableV5Header32bppBGRA |
| GIF | none |
A stream which can't seek is written through a MemoryStream. A seekable stream is written from its current position
and ends up after the image.
Decoding
Decoding reads the first frame and converts it to the pixel format you ask for. GetFrameInfo reads only the size,
resolution, pixel format and metadata. Decode writes the pixels into your memory, either memory you allocated with
the size from GetFrameInfo, or memory you supply once the size is known, so the stream is read once:
// Load any image WIC can read into a new GDI+ bitmap, upright, without WPF and without an extra copy
Bitmap bitmap = null;
BitmapData bitmapData = null;
try
{
WicFrameInfo info = WicImage.Decode(source, WicPixelFormats.Format32bppBgra, (WicFrameInfo frame, out int stride) =>
{
bitmap = new Bitmap(frame.Width, frame.Height, PixelFormat.Format32bppArgb);
// 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;
}, applyOrientation: true);
}
finally
{
if (bitmapData != null)
{
bitmap.UnlockBits(bitmapData);
}
}
With applyOrientation: true the EXIF orientation is applied (rotated and flipped before the pixel format conversion),
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
COMException) is thrown, so you can fall back. It is also thrown when a codec is registered but can't be initialized:
on the CI runner (Windows Server) the HEIF and JPEG XL encoders are listed but fail with
WINCODEC_ERR_COMPONENTINITIALIZEFAILURE (0x88982F8B); HEIF encoding needs the HEVC video extension. Other WIC
failures are a COMException with the HRESULT, and an exception of your stream or row callback reaches you unchanged.
Metadata
The metadata uses the WIC metadata query language,
so it is not a fixed set of properties. The same EXIF tag has a different path in each container, WicMetadataPaths has
them, e.g. Software(WicContainerFormats.Jpeg) is /app1/ifd/{ushort=305} and Software(WicContainerFormats.Tiff) is
/ifd/{ushort=305}.
Writing
WicEncodeOptions.Metadata is called after the frame is initialized and before the pixels are written. Everything the
encoder doesn't accept is skipped and listed in WicEncodeResult.MetadataIssues (and logged), it never fails the
encoding.
var container = WicContainerFormats.Jpeg;
var options = new WicEncodeOptions
{
ContainerFormat = container,
ImageQuality = 0.9f,
Metadata = metadata =>
{
// The EXIF ASCII tags are VT_LPSTR
metadata.Set(WicMetadataPaths.Software(container), WicMetadataValue.AnsiString("Greenshot"));
metadata.Set(WicMetadataPaths.DateTimeOriginal(container), WicMetadataValue.AnsiString(DateTime.Now.ToString("yyyy:MM:dd HH:mm:ss", CultureInfo.InvariantCulture)));
// dc:title becomes a language alternative with x-default
metadata.Set(WicMetadataPaths.XmpProperty(container, "dc:title"), "Capture of the Explorer window");
metadata.Set(WicMetadataPaths.XmpProperty(container, "xmp:CreatorTool"), "Greenshot");
metadata.SetColorProfile(iccProfile);
}
};
var result = WicImage.Encode(destination, options, pixels, width, height, stride, WicPixelFormats.Format32bppBgra);
foreach (var issue in result.MetadataIssues)
{
Console.WriteLine($"Not written: {issue}");
}
Values: string is written as VT_LPWSTR, WicMetadataValue.AnsiString as VT_LPSTR (the EXIF ASCII tags like Software,
DateTime and DateTimeOriginal), bool, the integer types, float, double, WicRational and WicSignedRational (EXIF
RATIONAL and SRATIONAL), DateTime (FILETIME), Guid, byte[] (VT_BLOB, or WicMetadataValue.ByteVector for
VT_VECTOR | VT_UI1), ushort[], uint[], int[] and string[].
The photo metadata policies of Windows, e.g. System.ApplicationName, System.Title, System.Comment and
System.Photo.Orientation, write every location the container has: System.ApplicationName writes the EXIF Software
and the XMP xmp:CreatorTool, System.Title the EXIF ImageDescription and the XMP dc:title as language alternative. A
plain string at /xmp/dc:title makes a simple XMP property.
Reading
WicFrameInfo.Metadata is a snapshot of every readable item of the first frame, walked through the nested blocks.
WicFrameInfo info = WicImage.GetFrameInfo(source);
WicMetadata metadata = info.Metadata;
if (metadata.TryGet(WicMetadataPaths.Software(info.ContainerFormat), out var software))
{
Console.WriteLine($"Written by {software}");
}
int? orientation = metadata.Orientation; // 1..8
byte[] icc = metadata.ColorProfile; // null without a profile
foreach (var item in metadata.Items)
{
// e.g. "/app1/ifd/exif/{ushort=36867}" = "2026:11:02 12:34:56"
Console.WriteLine($"{item.Key} = {item.Value}");
}
The paths use the names of the blocks (/app1/ifd/exif), the paths with the tags as WIC enumerates them
(/app1/{ushort=0}/{ushort=34665}) work for TryGet too. EXIF rationals are stored by WIC as ulong / long
(numerator in the low 32 bits), TryGetRational converts them. A nested block is listed as a WicMetadataBlock with its
metadata format. Orientation is read with the path of the container or System.Photo.Orientation, ColorProfile is
the ICC profile of the first color context.
Raw blocks
To carry metadata over unchanged, RawBlocks has the EXIF, XMP and IPTC blocks as bytes, and AddRawBlock writes one:
// Copy the XMP block of the original unchanged into the new file
var original = WicImage.GetFrameInfo(source).Metadata;
WicImage.Encode(destination, new WicEncodeOptions
{
ContainerFormat = WicContainerFormats.Jpeg,
Metadata = metadata =>
{
foreach (var block in original.RawBlocks.Where(block => block.Format == WicMetadataFormats.Xmp))
{
metadata.AddRawBlock(new WicMetadataRawBlock(WicMetadataPaths.Xmp(WicContainerFormats.Jpeg), block.Format, block.Data));
}
}
}, pixels, width, height, stride, WicPixelFormats.Format32bppBgra);
What the codecs of Windows accept
Tested with the codecs of Windows on the CI runner (Windows Server 2025, windows-latest), see WicMetadataTests:
| JPEG | TIFF | PNG | JPEG XR | GIF | BMP | |
|---|---|---|---|---|---|---|
| EXIF tags (Software, ImageDescription, Orientation, XResolution) | /app1/ifd/{ushort=n} |
/ifd/{ushort=n} |
- | /ifd/{ushort=n} |
- | - |
| EXIF sub IFD (DateTimeOriginal, ExposureTime) | /app1/ifd/exif/{ushort=n} |
/ifd/exif/{ushort=n} |
- | /ifd/exif/{ushort=n} |
- | - |
| XMP | /xmp/... |
/ifd/xmp/... |
/xmp/... |
/ifd/xmp/... |
- | - |
| PNG text | - | - | /tEXt/{str=keyword}, /[1]tEXt/..., /iTXt/... |
- | - | - |
| Policies (System.ApplicationName, System.Title, System.Comment, System.Photo.Orientation) | yes | yes | yes | yes | - | - |
| Color profile | yes | yes | yes (iCCP) | yes | - | - |
| Raw blocks | /app1, /xmp |
/ifd/xmp (tag 700) |
/xmp |
/ifd/xmp (tag 700) |
- | - |
GIF has only its own blocks (/imgdesc, /grctlext, ...), BMP has no metadata. A PNG tEXt chunk holds one keyword: a
second keyword at /tEXt replaces the first, give each its own chunk index (WicMetadataPaths.PngText("Description", 1)).
HEIF and JPEG XL couldn't be tested on the CI runner (their encoders don't initialize there); their decoders are listed.