ICO File Format Demystified: Building a PNG to ICO Converter from Scratch
ICO File Format Demystified: Building a PNG to ICO Converter from Scratch#
I recently needed to add favicon generation to a project. Thought I could just rename a PNG to .ico and call it a day. Nope. ICO is a proper binary container format with its own structure. Here’s what I learned.
ICO Structure: Not Just a Renamed Image#
An ICO file is a container that holds multiple icon images at different sizes. Three parts:
- ICONDIR header (6 bytes): File identifier
- ICONDIRENTRY directory (16 bytes each): Describes each image location
- Image data: Actual BMP or PNG data
┌─────────────────────────────────┐
│ ICONDIR Header (6B) │
├─────────────────────────────────┤
│ ICONDIRENTRY 1 (16B) │
│ ICONDIRENTRY 2 (16B) │
│ ... │
├─────────────────────────────────┤
│ Image Data 1 (PNG/BMP) │
│ Image Data 2 (PNG/BMP) │
│ ... │
└─────────────────────────────────┘
ICONDIR Header Breakdown#
// 6-byte file header
const header = new DataView(new ArrayBuffer(6))
header.setUint16(0, 0, true) // Reserved, must be 0
header.setUint16(2, 1, true) // Type: 1=icon, 2=cursor
header.setUint16(4, count, true) // Number of images
Gotcha: The third parameter true in setUint16 means little-endian. ICO format was invented by Windows, so naturally it uses Intel’s little-endian byte order.
ICONDIRENTRY Directory Entries#
Each entry is 16 bytes:
const entry = new Uint8Array(16)
entry[0] = width // Width (0 means 256)
entry[1] = height // Height (0 means 256)
entry[2] = 0 // Color count (0 means ≥256 colors)
entry[3] = 0 // Reserved, must be 0
entry[4] = 1 // Color planes (low byte)
entry[5] = 0 // Color planes (high byte)
entry[6] = 32 // Bits per pixel (low byte)
entry[7] = 0 // Bits per pixel (high byte)
// Remaining 8 bytes need DataView (multi-byte values)
const view = new DataView(entry.buffer)
view.setUint32(8, imageSize, true) // Image data size
view.setUint32(12, imageOffset, true) // Image data offset
The width/height quirk: ICO format designers didn’t anticipate 256×256 icons. So entry[0] and entry[1] can only store 0-255. The spec says: value of 0 means 256.
Canvas-based Multi-size Generation#
With the format understood, next step is resizing the source image. Canvas drawImage handles this:
async function resizeImage(img: HTMLImageElement, size: number): Promise<ArrayBuffer> {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')!
// Enable high-quality scaling
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
// Draw scaled image
ctx.drawImage(img, 0, 0, size, size)
// Export as PNG
return new Promise((resolve) => {
canvas.toBlob((blob) => {
blob!.arrayBuffer().then(resolve)
}, 'image/png')
})
}
imageSmoothingQuality has three options: low, medium, high. For small icon sizes, high-quality interpolation avoids aliasing.
Why PNG Over BMP?#
Traditional ICO files contain BMP data, but modern ICO supports embedded PNG. The reasons are simple:
- Smaller size: PNG has compression. A 256×256 icon might be a few KB as PNG, but 256KB as BMP
- Transparency: BMP alpha channel handling is complex; PNG supports it natively
- Good compatibility: Windows XP and later all support PNG format ICO
Assembling the ICO File: Binary Puzzle#
The key is calculating offsets correctly:
function buildIco(pngBuffers: ArrayBuffer[], sizes: number[]): ArrayBuffer {
const headerSize = 6
const entrySize = 16
const directorySize = headerSize + sizes.length * entrySize
// Calculate total size
let totalSize = directorySize
pngBuffers.forEach(buf => totalSize += buf.byteLength)
const ico = new ArrayBuffer(totalSize)
const view = new DataView(ico)
// Write header
view.setUint16(0, 0, true)
view.setUint16(2, 1, true)
view.setUint16(4, sizes.length, true)
// Write directory entries
let offset = directorySize
for (let i = 0; i < sizes.length; i++) {
const entry = new Uint8Array(ico, headerSize + i * entrySize, entrySize)
const size = sizes[i]
entry[0] = size >= 256 ? 0 : size
entry[1] = size >= 256 ? 0 : size
entry[2] = 0
entry[3] = 0
entry[4] = 1
entry[5] = 0
entry[6] = 32
entry[7] = 0
const entryView = new DataView(entry.buffer, entry.byteOffset, entry.byteLength)
entryView.setUint32(8, pngBuffers[i].byteLength, true)
entryView.setUint32(12, offset, true)
offset += pngBuffers[i].byteLength
}
// Write image data
let pos = directorySize
for (const buf of pngBuffers) {
new Uint8Array(ico, pos, buf.byteLength).set(new Uint8Array(buf))
pos += buf.byteLength
}
return ico
}
Handy trick: new Uint8Array(ico, offset, length).set(data) writes data directly into an ArrayBuffer at a specific position—no manual looping needed.
Complete Conversion Flow#
Putting it all together:
async function convertToIco(
imageSrc: string,
sizes: number[] = [16, 32, 48, 256]
): Promise<void> {
// 1. Load image
const img = new Image()
img.src = imageSrc
await new Promise((resolve) => { img.onload = resolve })
// 2. Generate PNGs at each size
const pngBuffers: ArrayBuffer[] = []
for (const size of sizes) {
const buf = await resizeImage(img, size)
pngBuffers.push(buf)
}
// 3. Assemble ICO
const icoBuffer = buildIco(pngBuffers, sizes)
// 4. Download
const blob = new Blob([icoBuffer], { type: 'image/x-icon' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'favicon.ico'
a.click()
URL.revokeObjectURL(url)
}
Practical Tips#
1. Size Selection Strategy#
Different contexts need different sizes:
| Context | Recommended Sizes |
|---|---|
| Website favicon | 16, 32, 180 |
| Windows app | 16, 32, 48, 256 |
| macOS app | 16, 32, 64, 128, 256, 512 |
macOS ICNS format requires even more sizes, but that’s another story.
2. Square Constraint#
ICO format only supports square icons. Non-square images need cropping or padding:
// Center-crop to square
function cropToSquare(img: HTMLImageElement): HTMLCanvasElement {
const size = Math.min(img.width, img.height)
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')!
const sx = (img.width - size) / 2
const sy = (img.height - size) / 2
ctx.drawImage(img, sx, sy, size, size, 0, 0, size, size)
return canvas
}
3. Small Size Rendering#
16×16 or 32×32 icons can look blurry after downscaling. A pro tip: export at larger sizes first, then use a dedicated icon editor to adjust small versions manually. Pixel-by-pixel tweaking beats automatic scaling.
The Tool#
Based on this research, I built: Image to ICO Converter
Features:
- Arbitrary size combinations (1-1024 pixels)
- Quick preset size selection
- Real-time preview at different sizes
- Multi-size ICO generation
The ICO format itself isn’t complex—the key is understanding the binary layout and calculating offsets correctly. Modern browsers make binary handling easy with DataView and ArrayBuffer.
Related: QR Code Generator | Barcode Generator