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:

  1. ICONDIR header (6 bytes): File identifier
  2. ICONDIRENTRY directory (16 bytes each): Describes each image location
  3. 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:

  1. Smaller size: PNG has compression. A 256×256 icon might be a few KB as PNG, but 256KB as BMP
  2. Transparency: BMP alpha channel handling is complex; PNG supports it natively
  3. 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