The favicon tool in an image editor I run used to write every entry in its favicon.ico as a PNG. Browsers are fine with that. Then I loaded the file with System.Drawing from Windows PowerShell, and it threw:
Exception calling "ToBitmap" with "0" argument(s):
"Requested range extends past the end of the array."
The file was not corrupt. Every byte of the container was legal. The payload encoding of the small entries was the problem — and the usual advice about it ("Windows can't read PNG entries") is too vague to act on, because most of Windows reads them fine.
So I measured which consumers do and which don't.
Two payload formats in one container
An ICO file is a tiny archive:
ICONDIR 6 bytes reserved(0), type(1 = ICO), image count
ICONDIRENTRY 16 bytes xN width, height, colours, reserved,
planes, bpp, byte length, offset
payload N blobs one image per entry
The payload of each entry can be either of two things, and the directory entry does not say which:
-
DIB — a
BITMAPINFOHEADERfollowed by raw pixels. The original format. - PNG — a complete PNG file, magic bytes and all, dropped in as a blob. Added in Windows Vista, mainly for 256x256.
PNG entries are convenient in a browser, because canvas.toBlob() hands you one for free. That is why so much generator code emits PNG at every size.
What I measured
I generated two .ico files that differ only in payload encoding: same artwork, same 16/32/48 entries, one written as PNG, one as 32-bit DIB. Then I ran four checks on Windows 11 (Windows PowerShell 5.1.26100, .NET Framework 4.8.1, release key 533509).
| Check | PNG entries | DIB entries |
|---|---|---|
new System.Drawing.Icon(path) |
passes | passes |
.ToBitmap() |
throws Requested range extends past the end of the array
|
pixels and alpha intact |
| Entry extracted and loaded as a standalone PNG | fine, 16/32/48 all Format32bppArgb
|
n/a |
PrivateExtractIcons (user32) at 16/32/48 |
all three extracted correctly | all three |
Two things fall out of that table.
The Win32 icon loader is not the problem. On Windows 11, user32's icon extraction reads PNG entries at 16, 32 and 48 without complaint. If you have read that small PNG entries "don't show up in Explorer", I could not reproduce that failure on this path.
System.Drawing is. And the entry itself is valid — pulled out of the container, the same bytes load as a PNG image without complaint. It is specifically PNG-inside-ICO that Icon.ToBitmap() refuses.
Why System.Drawing fails, and why the docs suggest it shouldn't
System.Drawing grew PNG-frame support in .NET Framework 4.6. It is governed by an opt-out switch:
Switch.System.Drawing.DontSupportPngFramesInIcons
The catch is how the default is chosen. It is not the runtime version — the machine above runs .NET Framework 4.8.1. It is the target framework of the host application. An app that targets anything below 4.6 gets the legacy default, and powershell.exe (Windows PowerShell 5.1) gets it too.
You can watch the mechanism work. Same file, same session, with the switch flipped first:
$sw = 'Switch.System.Drawing.DontSupportPngFramesInIcons'
[AppContext]::SetSwitch($sw, $false)
Add-Type -AssemblyName System.Drawing
$icon = New-Object System.Drawing.Icon('C:\png-entries.ico')
$icon.ToBitmap() # now succeeds
Which means the failure has nothing to do with how current your Windows is. It follows the consumer, and it is invisible from the outside: you cannot tell by looking at a tool whether it will read your icon. Any .NET Framework app that uses System.Drawing with the legacy default — internal utilities, build scripts, installers, resource pipelines, older WinForms tools — silently sits in the failing column.
So what should you write?
16, 32 and 48 as uncompressed 32-bit DIB. PNG only at 256x256.
DIB costs you bytes and buys you a format that has no opt-out switch anywhere. At these sizes the bytes are nothing: 16 + 32 + 48 as DIB is about 15 KB.
At 256 the trade flips — an uncompressed 256x256 DIB is 256 KB of XOR bitmap plus 8 KB of mask — and no consumer that old is asking for a 256px icon anyway.
For a favicon you can stop at 48 entirely. Browsers never ask the .ico for more, and bigger artwork is better served as separate PNG files plus an apple-touch-icon. Add the 256 entry only when the same file also has to act as a desktop or application icon.
Writing a 32-bit DIB entry
Four rules, and each one breaks differently:
1. The height field is doubled. A DIB entry stores an XOR bitmap (the colours) and an AND bitmap (the 1-bit legacy transparency mask), stacked. biHeight must be size * 2 even though the image is size tall. Get it wrong and readers take half your image, or reject the entry.
2. Rows go bottom-up. BMP scanlines start at the bottom. Canvas ImageData starts at the top. Somebody has to flip.
3. Channels are BGRA, not RGBA. Swap the red and blue bytes.
4. The AND mask still has to exist, padded to a 4-byte boundary per row: ceil(size / 32) * 4. With 32bpp the alpha channel carries the real transparency, so leaving the mask all zeros is fine for anything that reads alpha. A consumer that ignores alpha uses this mask instead, and all-zero means "fully opaque" — transparent areas come out as a black box there. If you care about that tier, set the mask bit for every pixel whose alpha is 0.
function bmpEntry(canvas: HTMLCanvasElement, size: number) {
const ctx = canvas.getContext('2d')!
const src = ctx.getImageData(0, 0, size, size).data
const xorBytes = size * size * 4
// AND mask rows are aligned to 4 bytes
const maskStride = Math.ceil(size / 32) * 4
const andBytes = maskStride * size
const out = new Uint8Array(40 + xorBytes + andBytes)
const dv = new DataView(out.buffer)
dv.setUint32(0, 40, true) // BITMAPINFOHEADER size
dv.setInt32(4, size, true) // width
dv.setInt32(8, size * 2, true) // height: XOR + AND stacked
dv.setUint16(12, 1, true) // planes
dv.setUint16(14, 32, true) // bits per pixel
dv.setUint32(16, 0, true) // BI_RGB - no compression
dv.setUint32(20, xorBytes + andBytes, true) // biSizeImage
// bottom-up rows, BGRA channel order
for (let y = 0; y < size; y++) {
let s = (size - 1 - y) * size * 4
let d = 40 + y * size * 4
for (let x = 0; x < size; x++) {
out[d] = src[s + 2]! // B
out[d + 1] = src[s + 1]! // G
out[d + 2] = src[s]! // R
out[d + 3] = src[s + 3]! // A
s += 4
d += 4
}
}
return out
}
(TypeScript; drop the types and the ! for plain JS.)
Note what is not in there: no BITMAPFILEHEADER. A standalone .bmp starts with a 14-byte file header; an ICO entry does not. Prepend one and every reader misparses the entry.
Wrapping the entries in a container
type Entry = { size: number, data: Uint8Array }
function buildIco(entries: Entry[]): Uint8Array {
const headerSize = 6 + entries.length * 16
const bytes = entries.reduce((n, e) => n + e.data.length, 0)
const buf = new Uint8Array(headerSize + bytes)
const dv = new DataView(buf.buffer)
dv.setUint16(0, 0, true) // reserved
dv.setUint16(2, 1, true) // type 1 = icon
dv.setUint16(4, entries.length, true) // image count
let offset = headerSize
entries.forEach((e, i) => {
const o = 6 + i * 16
const n = e.size >= 256 ? 0 : e.size
buf[o] = n // width
buf[o + 1] = n // height
dv.setUint16(o + 4, 1, true) // planes
dv.setUint16(o + 6, 32, true) // bpp
dv.setUint32(o + 8, e.data.length, true) // byte length
dv.setUint32(o + 12, offset, true) // offset
buf.set(e.data, offset)
offset += e.data.length
})
return buf
}
Width and height are single bytes, so 256 does not fit. The spec says write 0 and let the reader infer 256.
How to check your own file
Browsers are the worst test — they accept everything. These four catch real failures.
1. Dump the entries. PNG payloads start with 89 50 4E 47:
const buf = require('fs').readFileSync('favicon.ico')
const n = buf.readUInt16LE(4)
for (let i = 0; i < n; i++) {
const o = 6 + i * 16
const size = buf[o] === 0 ? 256 : buf[o]
const off = buf.readUInt32LE(o + 12)
const len = buf.readUInt32LE(o + 8)
const kind = buf.readUInt32BE(off) === 0x89504e47 ? 'PNG' : 'DIB'
console.log(`${size}x${size} ${kind} ${len} bytes`)
}
You want DIB on 16, 32 and 48.
2. Hand it to System.Drawing in a legacy host. Windows PowerShell 5.1 is exactly that host, which makes it a convenient stand-in for every tool in the failing column. Do not flip the AppContext switch here — the point is to fail the way those tools fail:
Add-Type -AssemblyName System.Drawing
$icon = New-Object System.Drawing.Icon('C:\favicon.ico')
$icon.ToBitmap().Save('C:\check.png')
Note that the constructor is not the test. It succeeds on a file ToBitmap() cannot decode.
3. Look at it in Explorer. Put the .ico in a folder and switch the view between Small icons, Medium icons and Large icons — each view pulls a different entry, so a blank at one size points straight at the broken one. For the 16px path specifically, make a desktop shortcut and set its icon to the file (Properties → Change Icon). The Change Icon dialog itself only previews one size, so it is not a substitute for the view switching.
4. Check the small sizes on their own. 16x16 is what a browser tab, a window title bar and Explorer's small-icon view use (a HiDPI tab pulls 32 instead). It is also the size where artwork downscaled from one big PNG turns to mush. Render each size from the source rather than resampling the largest one.
The short version
- An ICO entry is either a DIB or a PNG, and the directory does not tell you which
- user32's icon extraction reads PNG entries at 16, 32 and 48;
System.Drawingin .NET Framework apps with the legacy default does not, and that includes Windows PowerShell 5.1 - The switch is
Switch.System.Drawing.DontSupportPngFramesInIcons, and its default follows the app's target framework, not the installed runtime - So: DIB for 16/32/48, PNG only at 256, and for a favicon you can stop at 48
-
biHeight = size * 2, bottom-up rows, BGRA, AND mask padded to 4 bytes per row, noBITMAPFILEHEADER - 256 is written as
0in the single-byte width and height fields
I wrote this encoder for editpot, a browser-based image editor whose favicon tool builds the .ico on the client — which is why it had to be plain Uint8Array work rather than a native icon library.
Top comments (0)