Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

Writing favicon.ico by hand in the browser with 40 lines of TypeScript

A favicon package sounds like a job for a server or at least a hefty npm package. You need a favicon.ico with several sizes, a few PNG files, an Apple touch icon, a maskable icon for Android and a web manifest. It turns

Writing favicon.ico by hand in the browser with 40 lines of TypeScript

A favicon package sounds like a job for a server or at least a hefty npm package. You need a favicon.ico with several sizes, a few PNG files, an Apple touch icon, a maskable icon for Android and a web manifest. It turns out every piece of that can be produced in the browser with a canvas, a DataView and a little arithmetic. No uploads, no image library.

We built exactly that for the free favicon and PWA icon package builder on RadarMakers, and the core is small enough to walk through in one post. The canvas drawing is ordinary, so this post focuses on the two parts that are less obvious: the ICO container and the maskable safe zone.

The ICO format is smaller than you think

Since Windows Vista, an ICO file may contain PNG images instead of old style bitmaps. That changes everything, because the canvas already gives you PNG bytes through canvas.toBlob. What is left is the container, and the container is tiny:

  1. ICONDIR, 6 bytes: a reserved zero, the type, which is 1 for an icon, and the number of images.
  2. ICONDIRENTRY, 16 bytes per image: width, height, color count, a reserved byte, planes, bits per pixel, the byte length of the image and the offset where it starts.
  3. The image data. Readers find each image through the offset in its entry. Our encoder simply writes the PNG files one after another in the same order as the entries.

Byte layout of a favicon.ico with three PNG images: a 6 byte ICONDIR, three 16 byte ICONDIRENTRY blocks at offsets 6, 22 and 38, and PNG data from offset 54

The eight fields of one 16 byte ICONDIRENTRY with their sizes and example values: width 16, height 16, colors 0, reserved 0, planes 1, bit count 32, bytes in image n1, image offset 54

The multibyte fields in the header and in the directory entries are little endian. The PNG data keeps its own encoding and is copied in unchanged. There is one trap: width and height are single bytes, so a 256 pixel image cannot be stored as 256. The format writes it as 0, and readers interpret 0 as 256.

Here is the whole encoder, exactly as it runs on our site:

const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];

export type IcoImage = { size: number; png: Uint8Array };

export function encodeIco(images: IcoImage[]): Uint8Array {
  if (images.length === 0 || images.length > 0xffff) throw new Error("ICO needs between 1 and 65535 images.");
  let total = 6 + 16 * images.length;
  for (const image of images) {
    if (!Number.isInteger(image.size) || image.size < 1 || image.size > 256) throw new Error("ICO image size must be 1 to 256.");
    if (image.png.length < PNG_SIGNATURE.length || PNG_SIGNATURE.some((byte, i) => image.png[i] !== byte)) throw new Error("ICO image is not a PNG.");
    total += image.png.length;
  }
  const out = new Uint8Array(total);
  const view = new DataView(out.buffer);
  view.setUint16(0, 0, true);             // reserved
  view.setUint16(2, 1, true);             // type: 1 = icon
  view.setUint16(4, images.length, true); // image count
  let offset = 6 + 16 * images.length;
  images.forEach((image, index) => {
    const entry = 6 + 16 * index;
    const dimension = image.size === 256 ? 0 : image.size;
    view.setUint8(entry, dimension);                     // width
    view.setUint8(entry + 1, dimension);                 // height
    view.setUint8(entry + 2, 0);                         // color count, 0 for true color
    view.setUint8(entry + 3, 0);                         // reserved
    view.setUint16(entry + 4, 1, true);                  // planes
    view.setUint16(entry + 6, 32, true);                 // bits per pixel
    view.setUint32(entry + 8, image.png.length, true);   // bytes in this image
    view.setUint32(entry + 12, offset, true);            // where the image starts
    out.set(image.png, offset);
    offset += image.png.length;
  });
  return out;
}

Two decisions in there are worth explaining.

First, the function computes the total size before it writes anything. One Uint8Array of the right length, one DataView over it, no concatenation in a loop. It also keeps the offsets honest: the first image starts at 6 + 16 * count, and each following one starts where the previous one ended.

Second, it checks the eight byte PNG signature. That is a sanity check for output we trust, not a PNG validator: a truncated file or a corrupt chunk would still pass. If a canvas export ever hands back an empty or odd blob, though, you get an exception instead of a broken icon. The encoder declares 32 bits per pixel and assumes compatible PNG input that matches the size in its entry. It does not verify the PNG dimensions, bit depth or color type.

Feeding it is plain canvas work. This is an illustrative version of the helper, which our component calls toBytes:

function pngBytes(canvas: HTMLCanvasElement): Promise<Uint8Array> {
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => {
      if (!blob) return reject(new Error("The browser could not encode a PNG."));
      blob.arrayBuffer().then((buffer) => resolve(new Uint8Array(buffer)), reject);
    }, "image/png");
  });
}

async function buildFavicon(c16: HTMLCanvasElement, c32: HTMLCanvasElement, c48: HTMLCanvasElement): Promise<Uint8Array> {
  return encodeIco([
    { size: 16, png: await pngBytes(c16) },
    { size: 32, png: await pngBytes(c32) },
    { size: 48, png: await pngBytes(c48) },
  ]);
}

Our package uses 16, 32 and 48 pixel entries in the ICO and separate PNG files for the larger installation icons referenced from the manifest. An ICO can hold bigger images too, up to 256 pixels, if you need them.

How to check the result

Look at the icon at its intended size first, because only your eyes will tell you that a detailed logo turns into mush at 16 pixels. Then validate the file itself with independent parsers:

identify favicon.ico
# favicon.ico[0] PNG 16x16 16x16+0+0 8-bit sRGB 86B ...
# favicon.ico[1] PNG 32x32 32x32+0+0 8-bit sRGB 108B ...
# favicon.ico[2] PNG 48x48 48x48+0+0 8-bit sRGB 389B ...

python3 -c "
from PIL import Image
im = Image.open('favicon.ico')
for size in sorted(im.info['sizes']):
    frame = im.ico.getimage(size)
    frame.load()
    print(size, frame.size, frame.mode)
"
# (16, 16) (16, 16) RGBA
# (32, 32) (32, 32) RGBA
# (48, 48) (48, 48) RGBA

The Pillow snippet decodes every image instead of only listing the sizes, so it can expose invalid offsets or damaged PNG data. It does not bound decoding by the length field, so our unit tests check the declared lengths and offsets separately. When ImageMagick and Pillow agree, that is good evidence the container is right, though not a formal proof. Our unit tests cover the other side: they assert the header, every directory field and every offset for a synthetic three image fixture, and separately check that 256 is written as 0. They test the container layout, not PNG decoding or how a browser renders the icon.

Maskable icons: the safe zone is a circle

Android launchers crop app icons to whatever shape the device uses: a circle, a squircle, a rounded square or a teardrop. A manifest icon marked "purpose": "maskable" promises that nothing important lies outside the area that survives every one of those masks.

The W3C manifest spec defines that area as a circle centered on the icon with a radius of 40 percent of the icon size, measured on the smaller side for an icon that is not square. On a 512 pixel icon that is a circle with a 204.8 pixel radius. Everything outside it may be cut off.

The common mistake is to scale the logo to fit a smaller square and hope. A square logo that fills 80 percent of the width has corners that stick out of the circle, because the corners of a square sit further from the center than its edges. The correct condition is about the corners: half of the logo's diagonal must not exceed the safe radius.

A naive 80 percent square has corners outside the 40 percent safe circle, while a square of 56.6 percent fits with its corners on the circle

That turns into one extra line next to the usual contain fit:

export const SAFE_ZONE_RADIUS = 0.4;

export function maskableRect(srcW: number, srcH: number, size: number, paddingPercent: number) {
  const box = size * (1 - (2 * paddingPercent) / 100);
  const byPadding = Math.min(box / srcW, box / srcH);
  const byCircle = (2 * SAFE_ZONE_RADIUS * size) / Math.hypot(srcW, srcH);
  const scale = Math.min(byPadding, byCircle);
  const w = srcW * scale;
  const h = srcH * scale;
  return { x: (size - w) / 2, y: (size - h) / 2, w, h };
}

byPadding is the normal contain fit with the user's padding. byCircle is the largest scale at which the diagonal still fits: the scaled diagonal is scale * Math.hypot(srcW, srcH), and it must be at most the circle's diameter, 2 * 0.4 * size. Taking the smaller of the two means the padding setting can make the logo smaller, but it can never push it past the safe zone. The function expects positive image dimensions and a padding value that has already been validated, which the app does before it gets here.

For a centered square logo, and when padding does not impose a smaller limit, the diagonal rule allows a side of about 56.6 percent of the icon, which is 0.8 / Math.SQRT2. For a wide wordmark it allows much more width, because a long thin rectangle has a short diagonal relative to its width. That is the reason to use the diagonal instead of a fixed inset: it gives each source rectangle the largest size allowed by both constraints. The check works on the image's bounding rectangle, so a logo with transparent margins or an irregular outline ends up a little smaller than strictly necessary, which is the safe direction to be wrong in.

The spec allows transparent pixels in a maskable icon, but the launcher then fills them with a color of its own choosing. We paint an opaque background edge to edge before drawing the logo, so the final color is the one you picked.

Packaging it all up

The final step is a ZIP with ten files: the ICO, six PNG sizes, the maskable PNG, site.webmanifest and a short install note. The images are already compressed, so a ZIP in "store" mode, meaning no compression at all, is enough. Store mode needs a local header and the file data for each entry, a CRC32 per file, the central directory and the end of central directory record, and our implementation fits in under a hundred lines with no dependency. It takes the file date as a parameter and defaults to a fixed timestamp instead of reading the clock, so the same files in the same order produce the same bytes every time, which is handy in tests.

The snippet that goes into <head> is short:

<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48">
<link rel="icon" href="/icon-32.png" type="image/png" sizes="32x32">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="theme-color" content="#ffffff">

What we took away from it

  • Binary formats that look intimidating are often a header and a list of offsets. Read the spec once, write the sizes down, and a DataView does the rest.
  • Check what you embed, and be honest about how much the check proves. A signature test costs one line and catches the failures you are most likely to see.
  • Geometry rules beat magic numbers. "Half the diagonal must fit in a circle of 40 percent" is easier to trust than "use 20 percent padding", and it treats square logos and wordmarks fairly.
  • Look at the icon at its real size, then validate the file with independent parsers such as ImageMagick and Pillow, not with a screenshot of a browser tab.

If you have built something similar, or found a reader that disagrees with the ICO layout above, I would like to hear about it in the comments.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.