diff --git a/src/MagicScaler/Ico/IcoCodecCollectionExtensions.cs b/src/MagicScaler/Ico/IcoCodecCollectionExtensions.cs
new file mode 100644
index 0000000..25984ac
--- /dev/null
+++ b/src/MagicScaler/Ico/IcoCodecCollectionExtensions.cs
@@ -0,0 +1,23 @@
+using PhotoSauce.MagicScaler;
+using System;
+
+namespace PhotoSauce.ManagedCodecs.Ico;
+
+public static class IcoCodecCollectionExtensions
+{
+ public static void UseIcoManagedDecoder(this CodecCollection codecs)
+ {
+ if (codecs == null)
+ {
+ throw new ArgumentNullException(nameof(codecs));
+ }
+
+ codecs.Add(new DecoderInfo(
+ IcoContainer.DecoderDisplayName,
+ IcoContainer.MimeTypes,
+ IcoContainer.Extensions,
+ [new ContainerPattern(0, [0, 0, 1, 0], [0xFF, 0xFF, 0xFF, 0xFF])],
+ null,
+ IcoContainer.TryLoad));
+ }
+}
\ No newline at end of file
diff --git a/src/MagicScaler/Ico/IcoContainer.cs b/src/MagicScaler/Ico/IcoContainer.cs
new file mode 100644
index 0000000..00050fb
--- /dev/null
+++ b/src/MagicScaler/Ico/IcoContainer.cs
@@ -0,0 +1,516 @@
+using PhotoSauce.MagicScaler;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+
+namespace PhotoSauce.ManagedCodecs.Ico;
+
+internal sealed class IcoContainer : IImageContainer
+{
+ ///
+ /// Size of ICONDIRENTRY structure.
+ ///
+ private const int ICONDIRENTRY_SIZE = 16;
+ ///
+ /// First 4 bytes of PNG file.
+ ///
+ private const int PNG_SIGNATURE = 0x47_4E_50_89;
+ ///
+ /// BITMAPINFOHEADER size.
+ ///
+ private const int BMP_HEADER_SIZE = 0x28;
+ ///
+ /// No-compression BMP bitmap compression mode.
+ ///
+ private const int BI_RGB = 0;
+
+
+ public const string DecoderDisplayName = "ICO Container Managed Decoder";
+ public const string DefaultMimeType = "image/x-icon";
+
+ public static readonly string[] Extensions = [".ico"];
+ public static readonly string[] MimeTypes = [DefaultMimeType, "image/ico", "image/vnd.microsoft.icon"];
+
+ private readonly Stream _image;
+ private readonly long[] _frameOffsets;
+ private readonly IcoFrame?[] _frames;
+
+ string? IImageContainer.MimeType => DefaultMimeType;
+
+ int IImageContainer.FrameCount => _frames.Length;
+
+ internal static IcoContainer? TryLoad(Stream image, IDecoderOptions? options)
+ {
+ return TryLoadHeader(image, options is IMultiFrameDecoderOptions multiFrameDecoderOptions ? multiFrameDecoderOptions.FrameRange : Range.All, out var frameOffsets)
+ ? new IcoContainer(image, frameOffsets)
+ : null;
+ }
+
+ private IcoContainer(Stream image, long[] frameOffsets)
+ {
+ _image = image;
+ _frameOffsets = frameOffsets;
+ _frames = new IcoFrame[frameOffsets.Length];
+ }
+
+ void IDisposable.Dispose()
+ {
+ }
+
+ IImageFrame IImageContainer.GetFrame(int index)
+ {
+ return index >= 0 && index < _frames.Length
+ ? _frames[index] ??= new IcoFrame(_image, _frameOffsets[index])
+ : throw new ArgumentOutOfRangeException(nameof(index), $"Invalid frame index {index}. Expected range: [0, {_frames.Length}).");
+ }
+
+ private static bool TryLoadHeader(Stream image, Range frameRange, [NotNullWhen(true)] out long[]? frameOffsets)
+ {
+ /*
+ * Notes:
+ * 1. All data stored in little-indian format
+ * 2. Paddings in images data counted from start of image entry (specified in image descriptor)
+ * 3. Each image entry in container is either PNG file (whole) or BMP (without header)
+ * 4. for more details on ICO format you can use https://en.wikipedia.org/wiki/ICO_(file_format), which contains more or less correct information
+ *
+ * Format
+ *
+ * 1. Header : 6 bytes
+ * reserved : 2 bytes. Always 0
+ * container type: 2 bytes. 0x1 for ICO, 0x2 for CUR (not handled by current decoder)
+ * images count : 2 bytes. Could be 0 for empty container
+ *
+ * 2. Image Descriptors: image count * 16 (descriptor size).
+ * Most of fields in descriptor are not used by decoder and sometimes even could contain wrong information. All required data for decoding we get from image itself.
+ *
+ * width in pixels. 1 byte.
+ * height in pixels. 1 byte.
+ * number of colors in color palette. 1 byte.
+ * reserved. 1 byte.
+ * color planes count. 2 bytes.
+ * bits per pixel. 2 bytes.
+ * size of image in bytes. 4 bytes.
+ * offset to image data from start of ICO file. 4 bytes. This is the only field that contain important information for decoder
+ */
+
+ // check first 4 bytes of fixed header signature
+ if (ReadUInt32(image) != 0x00010000)
+ {
+ frameOffsets = null;
+ return false;
+ }
+
+ var start = image.Position - 4;
+
+ var imageCount = ReadUInt16(image);
+
+ // apply frame range
+ var (startFrame, frameCount) = frameRange.GetOffsetAndLength(imageCount);
+ frameOffsets = new long[frameCount];
+
+ // move to first selected frame descriptor
+ image.Position += ICONDIRENTRY_SIZE * startFrame;
+
+ // read frames
+ for (var i = 0; i < frameCount; i++)
+ {
+ // ignore descriptor data except offset field
+ image.Position += 12;
+ frameOffsets[i] = start + ReadUInt32(image);
+ }
+
+ return true;
+ }
+
+ sealed class IcoFrame(Stream stream, long Position) : IImageFrame
+ {
+ IPixelSource IImageFrame.PixelSource
+ {
+ get
+ {
+ stream.Position = Position;
+ var signature = ReadUInt32(stream);
+ stream.Position = Position;
+
+ // PNG
+ if (signature == PNG_SIGNATURE)
+ {
+ // easiest case: image is PNG, so we can just delegate decode to PNG codec
+ // potentially there is a gap in functionality if it is APNG, but I doubt such PNG supported in ICO
+ return MagicImageProcessor.BuildPipeline(stream, ProcessImageSettings.Default).PixelSource;
+ }
+ // BMP
+ else if (signature == BMP_HEADER_SIZE)
+ {
+ // Decode BMP
+ return ReadBitmap(stream);
+ }
+
+ throw new InvalidOperationException($"Unknown ICO image. First 4 bytes are 0x{signature:X8}.");
+ }
+ }
+
+ void IDisposable.Dispose()
+ {
+ }
+
+ private static IPixelSource ReadBitmap(Stream image)
+ {
+ /*
+ * ICO use limited set of BMP functionality and use following layout (BITMAPFILEHEADER is missing):
+ * 1. image starts with BITMAPINFOHEADER (other header types are not supported)
+ * 2. Color table (only for images with BPP=1,4,8)
+ * 3. Bitmap in BPP-specific format
+ * 4. 1-bit opacity mask
+ *
+ * Implementation notes:
+ * 1. supported BPP (bit-per-pixel) values: 1, 4, 8, 24, 32
+ * - some docs/ibraries also mention/support BPP=2, but it is not actually a valid value for BMP and I didn't see any such images before.
+ * Still we can easily enable it if requested.
+ * - 16 (actually 15) BPP support is not implemented as I didn't seen any ICO files yet with such BPP
+ * 2. In places, where DWORD alignment required, it is calculated from frame start position, not from file start
+ * 3. Opacity mask is ignored for BPP=32 as this format already provide 8-bit opacity information
+ * 4. Reserved 4th byte from color in color table ignored despite rumors that it could be used for opacity as all ICO files with this byte filled
+ * contained only random garbage
+ *
+ * More or less correct additional information could be also found here https://en.wikipedia.org/wiki/BMP_file_format
+ */
+
+ var start = image.Position;
+
+ // read required data from BITMAPINFOHEADER
+
+ // skip header size
+ image.Position += 4;
+
+ // read real bitmap dimensions
+ var width = checked((int)ReadUInt32(image));
+ var height = checked((int)ReadUInt32(image));
+
+ // skip color planes count
+ image.Position += 2;
+
+ var bpp = ReadUInt16(image);
+ var compression = ReadUInt32(image);
+
+ // skip size and PPM fields
+ image.Position += 12;
+
+ var paletteSize = ReadUInt32(image);
+
+ // position to next byte after header
+ image.Position = start + 40;
+
+ // validate/normalize data
+ // height contains x2 value as it also count opacity mask height
+ if (height % 2 != 0)
+ {
+ throw new InvalidDataException($"Expected bitmap height must be power of 2 but was {height}.");
+ }
+ height /= 2;
+
+ if (bpp is not 1 and not 4 and not 8 and not 24 and not 32)
+ {
+ throw new InvalidDataException($"Bitmaps with BPP={bpp} currently not supported.");
+ }
+
+ if (compression != BI_RGB)
+ {
+ throw new InvalidDataException($"Bitmaps with compression method {compression} currently not supported.");
+ }
+
+ // allocate bitmap
+ var body = new byte[width * height * 4];
+
+ if (bpp is 1 or 4 or 8)
+ {
+ // indexed bitmap:
+ // - color table
+ // - indexed bitmap
+ // - opacity mask
+
+ // color table stored as RGBx DWORD with x being reserved(alignment) byte
+ // while in theory reserved byte could be used to store opacity, in practice it contains
+ // either 0 or (in rare cases) garbage
+ var colors = paletteSize != 0 ? (int)paletteSize : (1 << bpp);
+ var colorTable = new byte[4 * colors];
+ ReadSpan(image, colorTable);
+
+ // read indexed bitmap
+ // bitmap stored in rows in upside down order with each row padded to DWORD alignment
+ var y = height - 1;
+ var x = 0;
+
+ foreach (var idx in GetColorIndexes(image, body.Length / 4, bpp))
+ {
+ body[y * width * 4 + x * 4] = colorTable[idx * 4];
+ body[y * width * 4 + x * 4 + 1] = colorTable[idx * 4 + 1];
+ body[y * width * 4 + x * 4 + 2] = colorTable[idx * 4 + 2];
+ // opacity byte filled below
+ x++;
+
+ // append alignment if needed
+ if (x == width)
+ {
+ x = 0;
+ y--;
+ image.Position += (4 - ((image.Position - start) % 4)) % 4;
+ }
+ }
+
+ // read and apply 1-bit opacity mask
+ ApplyOpacityMask(image, start, body, width, height);
+ }
+ else if (bpp == 24)
+ {
+ // 24-bit RGB bitmap:
+ // - bitmap
+ // - opacity mask
+
+ // bitmap stored upside down without padding
+ for (var y = height - 1; y >= 0; y--)
+ {
+ var idx = y * width * 4;
+ for (var x = 0; x < width; x++)
+ {
+ body[idx] = ReadUInt8(image);
+ body[idx + 1] = ReadUInt8(image);
+ body[idx + 2] = ReadUInt8(image);
+ idx += 4;
+ }
+ }
+
+ // read and apply 1-bit opacity mask
+ ApplyOpacityMask(image, start, body, width, height);
+ }
+ else if (bpp == 32)
+ {
+ // 32-bit RGBA bitmap
+
+ // birmap rows stored bottom-up without padding
+ var hasOpacity = false;
+ for (var y = height - 1; y >= 0; y--)
+ {
+ var idx = y * width * 4;
+ for (var x = 0; x < width; x++)
+ {
+ body[idx] = ReadUInt8(image);
+ body[idx + 1] = ReadUInt8(image);
+ body[idx + 2] = ReadUInt8(image);
+ body[idx + 3] = ReadUInt8(image);
+ hasOpacity = hasOpacity || body[idx + 3] != 0;
+ idx += 4;
+ }
+ }
+
+ // as we already have 8-bit opacity, no need to read lower-quality 1-bit mask
+ // except case when opacity is not actually specified in 4-th byte
+ // (this actually happends in the wild)
+ if (!hasOpacity)
+ {
+ ApplyOpacityMask(image, start, body, width, height);
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException($"Unexpected bitmap BPP value ({bpp})");
+ }
+
+ return new IcoBitmapPixelSource(body, width, height);
+ }
+
+ private static void ApplyOpacityMask(Stream image, long start, byte[] body, int width, int height)
+ {
+ // opacity mask stored as 1-bit mask in same upside-down row order with each row padded to DWORD
+ var idx = body.Length - 1 - width * 4 + 4;
+ var x = 0;
+ foreach (var opaque in GetOpacities(image, start, width, height))
+ {
+ if (opaque)
+ {
+ body[idx] = 0xFF;
+ }
+
+ idx += 4;
+ x++;
+
+ if (x % width == 0)
+ {
+ idx -= width * 4 * 2;
+ }
+ }
+ }
+
+ private static IEnumerable GetOpacities(Stream image, long start, int width, int height)
+ {
+ for (var y = 0; y < height; y++)
+ {
+ if (y > 0)
+ {
+ image.Position += (4 - ((image.Position - start) % 4)) % 4;
+ }
+
+ for (var i = 0; i < width;)
+ {
+ var pixelsToRead = width - i;
+
+ var b = ReadUInt8(image);
+
+ if (pixelsToRead > 0) yield return 0 == ((b & 0x80) >> 7);
+ if (pixelsToRead > 1) yield return 0 == ((b & 0x40) >> 6);
+ if (pixelsToRead > 2) yield return 0 == ((b & 0x20) >> 5);
+ if (pixelsToRead > 3) yield return 0 == ((b & 0x10) >> 4);
+ if (pixelsToRead > 4) yield return 0 == ((b & 0x08) >> 3);
+ if (pixelsToRead > 5) yield return 0 == ((b & 0x04) >> 2);
+ if (pixelsToRead > 6) yield return 0 == ((b & 0x02) >> 1);
+ if (pixelsToRead > 7) yield return 0 == (b & 0x01);
+
+ i += pixelsToRead >= 8 ? 8 : pixelsToRead;
+ }
+ }
+ }
+
+ private static IEnumerable GetColorIndexes(Stream image, int sizeInPixels, ushort bpp)
+ {
+ var currentPixel = 0;
+ while (currentPixel < sizeInPixels)
+ {
+ var b = ReadUInt8(image);
+
+ // color indexes are packed into byte with length based on image bpp
+
+ if (bpp == 1)
+ {
+ if (currentPixel == sizeInPixels) yield break;
+ yield return (b & 0x80) >> 7;
+ currentPixel++;
+
+ if (currentPixel == sizeInPixels) yield break;
+ yield return (b & 0x40) >> 6;
+ currentPixel++;
+
+ if (currentPixel == sizeInPixels) yield break;
+ yield return (b & 0x20) >> 5;
+ currentPixel++;
+
+ if (currentPixel == sizeInPixels) yield break;
+ yield return (b & 0x10) >> 4;
+ currentPixel++;
+
+ if (currentPixel == sizeInPixels) yield break;
+ yield return (b & 0x08) >> 3;
+ currentPixel++;
+
+ if (currentPixel == sizeInPixels) yield break;
+ yield return (b & 0x04) >> 2;
+ currentPixel++;
+
+ if (currentPixel == sizeInPixels) yield break;
+ yield return (b & 0x02) >> 1;
+ currentPixel++;
+
+ yield return b & 0x01;
+ currentPixel++;
+ }
+ else if (bpp == 4)
+ {
+ yield return b >> 4;
+ currentPixel++;
+
+ if (currentPixel < sizeInPixels) yield return b & 0x0F;
+ currentPixel++;
+ }
+ else if (bpp == 8)
+ {
+ yield return b;
+ currentPixel++;
+ }
+ else
+ {
+ throw new InvalidOperationException($"Unexpected indexed bitmap BPP value ({bpp})");
+ }
+ }
+ }
+ }
+
+ private sealed class IcoBitmapPixelSource(byte[] image, int width, int height)
+ : BitmapPixelSource(PixelFormats.Bgra32bpp, width, height, width * 4)
+ {
+ protected override ReadOnlySpan Span => image;
+ }
+
+ private static byte ReadUInt8(Stream stream)
+ {
+ var b = stream.ReadByte();
+ if (b == -1)
+ {
+ throw new InvalidDataException($"Not enough data to decode image");
+ }
+
+ return (byte)b;
+ }
+
+ private static uint ReadUInt32(Stream stream)
+ {
+ var b1 = stream.ReadByte();
+ if (b1 == -1)
+ {
+ throw new InvalidDataException($"Not enough data to decode image");
+ }
+
+ var b2 = stream.ReadByte();
+ if (b1 == -1)
+ {
+ throw new InvalidDataException($"Not enough data to decode image");
+ }
+
+ var b3 = stream.ReadByte();
+ if (b1 == -1)
+ {
+ throw new InvalidDataException($"Not enough data to decode image");
+ }
+
+ var b4 = stream.ReadByte();
+ if (b1 == -1)
+ {
+ throw new InvalidDataException($"Not enough data to decode image");
+ }
+
+ return (uint)(b1 | (b2 << 8) | (b3 << 16) | (b4 << 24));
+ }
+
+ private static ushort ReadUInt16(Stream stream)
+ {
+ var b1 = stream.ReadByte();
+ if (b1 == -1)
+ {
+ throw new InvalidDataException($"Not enough data to decode image");
+ }
+
+ var b2 = stream.ReadByte();
+ if (b1 == -1)
+ {
+ throw new InvalidDataException($"Not enough data to decode image");
+ }
+
+ return (ushort)(b1 | (b2 << 8));
+ }
+
+ private static void ReadSpan(Stream stream, byte[] memory)
+ {
+ var size = 0;
+
+ while (size != memory.Length)
+ {
+ var rd = stream.Read(memory, size, memory.Length - size);
+ if (rd == 0)
+ {
+ throw new InvalidDataException($"Not enough data to decode image");
+ }
+
+ size += rd;
+ }
+ }
+}
diff --git a/src/MagicScaler/Ico/IcoFrameDecoderOptions.cs b/src/MagicScaler/Ico/IcoFrameDecoderOptions.cs
new file mode 100644
index 0000000..29ecce1
--- /dev/null
+++ b/src/MagicScaler/Ico/IcoFrameDecoderOptions.cs
@@ -0,0 +1,9 @@
+using PhotoSauce.MagicScaler;
+using System;
+
+namespace PhotoSauce.ManagedCodecs.Ico;
+
+public readonly struct IcoFrameDecoderOptions(Range frameRange) : IMultiFrameDecoderOptions
+{
+ readonly Range IMultiFrameDecoderOptions.FrameRange => frameRange;
+}
\ No newline at end of file