130 Widgets

Building tools. Learning to build tools. Learning to build learning tools.

7. PNG Swatch Generation from Scratch

Building valid PNG files from raw pixels using only Node’s zlib — no image library needed.

Why Generate PNGs?

The color picker needs 16×16 solid-color icons — one per preset, re-generated whenever the theme changes (because the same hue looks different at different saturation and lightness levels). We could bundle static PNGs, but they wouldn’t be theme-aware. We could use an image library, but that’s a heavy dependency for something this simple.

Instead, swatchGenerator.ts implements PNG encoding from first principles. A solid-color 16×16 PNG is about as simple as a PNG file can be, and it turns out that building one requires understanding exactly three things: the PNG chunk format, DEFLATE compression, and CRC-32 checksums.

PNG File Structure

Every PNG file follows the same structure:

PNG File ┌──────────────────────────────────────────────┐ │ 8-byte signature (magic bytes) │ ├──────────────────────────────────────────────┤ │ IHDR chunk (image header: dimensions, type) │ ├──────────────────────────────────────────────┤ │ IDAT chunk (compressed pixel data) │ ├──────────────────────────────────────────────┤ │ IEND chunk (end marker) │ └──────────────────────────────────────────────┘ Each chunk: ┌────────┬────────┬──────────┬─────────┐ │ length │ type │ data │ CRC-32 │ │ 4 bytes│ 4 bytes│ N bytes │ 4 bytes │ └────────┴────────┴──────────┴─────────┘

The signature is always the same eight bytes: 137 80 78 71 13 10 26 10. In ASCII, bytes 1–3 spell “PNG”. The surrounding bytes are designed to detect common file transfer corruptions (wrong line endings, stripped high bits, etc.).

Building Raw Pixel Data

function createSolidPng(hex: string, size: number): Buffer {
    const { r, g, b } = hexToRgb(hex);

    // Build raw image data: each row starts with a filter byte (0 = None)
    const rowLen = 1 + size * 4; // filter byte + RGBA * width
    const raw = Buffer.alloc(rowLen * size);
    for (let y = 0; y < size; y++) {
        const offset = y * rowLen;
        raw[offset] = 0; // filter: None
        for (let x = 0; x < size; x++) {
            const px = offset + 1 + x * 4;
            raw[px] = r;
            raw[px + 1] = g;
            raw[px + 2] = b;
            raw[px + 3] = 255; // fully opaque
        }
    }

PNG’s raw pixel format has a quirk: every row starts with a filter byte that tells the decoder how to interpret the row. Filter 0 means “None” — the bytes are literal RGBA values. For a solid color, no filtering is needed, so every row gets a zero byte prefix.

Each pixel is 4 bytes: red, green, blue, alpha. For a 16×16 image, that’s 16 × (1 + 16 × 4) = 16 × 65 = 1040 bytes of raw data.

Color Science

The hexToRgb function at the bottom of the file does the reverse of hslToHex: it takes a #rrggbb string and extracts three integers. The full color pipeline is: hue → HSL → hex string → RGB integers → PNG bytes. Each conversion happens at a different layer of the system.

DEFLATE Compression

    const compressed = zlib.deflateSync(raw);

One line. PNG requires its pixel data to be compressed with the DEFLATE algorithm (the same one used in ZIP files and gzip). Node’s built-in zlib module provides deflateSync, which compresses a buffer synchronously.

For a 16×16 solid-color image, DEFLATE compresses extremely well — every row is identical, so the algorithm finds massive repetition. The 1040 bytes of raw data typically compress to around 30–40 bytes.

Assembling PNG Chunks

    // Assemble PNG file
    const chunks: Buffer[] = [];

    // Signature
    chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));

    // IHDR
    const ihdr = Buffer.alloc(13);
    ihdr.writeUInt32BE(size, 0);  // width
    ihdr.writeUInt32BE(size, 4);  // height
    ihdr[8] = 8;                   // bit depth
    ihdr[9] = 6;                   // color type: RGBA
    ihdr[10] = 0;                  // compression
    ihdr[11] = 0;                  // filter
    ihdr[12] = 0;                  // interlace
    chunks.push(pngChunk('IHDR', ihdr));

    // IDAT
    chunks.push(pngChunk('IDAT', compressed));

    // IEND
    chunks.push(pngChunk('IEND', Buffer.alloc(0)));

    return Buffer.concat(chunks);
}

The IHDR chunk is 13 bytes of metadata:

The IDAT chunk wraps the DEFLATE-compressed pixel data. IEND is an empty chunk that signals the end of the file. Each chunk is wrapped by the pngChunk helper:

function pngChunk(type: string, data: Buffer): Buffer {
    const length = Buffer.alloc(4);
    length.writeUInt32BE(data.length, 0);

    const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data]);

    const crc = crc32(typeAndData);
    const crcBuf = Buffer.alloc(4);
    crcBuf.writeUInt32BE(crc >>> 0, 0);

    return Buffer.concat([length, typeAndData, crcBuf]);
}

Every chunk follows the same format: 4-byte length, 4-byte type name, N bytes of data, and a 4-byte CRC-32 checksum computed over the type and data bytes (but not the length).

CRC-32 Checksums

const crcTable: number[] = [];
for (let n = 0; n < 256; n++) {
    let c = n;
    for (let k = 0; k < 8; k++) {
        c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    }
    crcTable[n] = c;
}

function crc32(buf: Buffer): number {
    let crc = 0xffffffff;
    for (let i = 0; i < buf.length; i++) {
        crc = crcTable[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
    }
    return crc ^ 0xffffffff;
}

CRC-32 (Cyclic Redundancy Check, 32-bit) is an error-detection algorithm. PNG requires it on every chunk to detect data corruption. The implementation is the standard table-driven approach:

  1. Build a 256-entry lookup table at module load time. Each entry encodes the CRC contribution of one byte value. The magic constant 0xedb88320 is the bit-reversed form of the standard CRC-32 polynomial.
  2. Process each byte: XOR it with the current CRC, look up the result in the table, and XOR with the shifted CRC. This is a standard algorithm you’ll find in the PNG specification, RFC 1952 (gzip), and countless other formats.
  3. Final XOR: The initial and final XOR with 0xffffffff is part of the CRC-32 specification (it improves detection of certain error patterns).
Architecture Note

The entire PNG encoder is about 90 lines of code. Compare this to pulling in a dependency like sharp (which includes native binaries and weighs tens of megabytes) or pngjs (which adds another package to audit and maintain). For a solid-color 16×16 swatch, the hand-rolled encoder is not just sufficient — it’s the obviously correct choice. Know when to reach for a library and when to just write the code.

Caching and Cache Invalidation

export function generateColorSwatch(
    storageDir: string,
    hex: string,
    size: number = 16
): vscode.Uri {
    const fileName = `swatch-${hex.replace('#', '')}.png`;
    const filePath = path.join(storageDir, fileName);

    if (!fs.existsSync(filePath)) {
        const pngBuffer = createSolidPng(hex, size);
        fs.mkdirSync(storageDir, { recursive: true });
        fs.writeFileSync(filePath, pngBuffer);
    }

    return vscode.Uri.file(filePath);
}

Swatches are cached by hex color in the extension’s global storage directory. If a swatch for #2a3847 already exists, it’s reused. The filename encodes the hex color, so the cache is self-keying.

export function clearSwatchCache(storageDir: string): void {
    if (!fs.existsSync(storageDir)) {
        return;
    }
    for (const file of fs.readdirSync(storageDir)) {
        if (file.startsWith('swatch-') && file.endsWith('.png')) {
            fs.unlinkSync(path.join(storageDir, file));
        }
    }
}

When the user switches themes, the entire swatch cache is cleared because every swatch needs to be regenerated with new saturation and lightness values. This is called from the theme change listener in extension.ts (Section 8).

Checkpoint

You’ve seen how to encode a valid PNG from raw pixels using nothing but Node’s built-in zlib, some buffer arithmetic, and a CRC-32 table. The final section ties everything together: the extension lifecycle, event listeners, and the status bar.