Building tools. Learning to build tools. Learning to build learning tools.
Building valid PNG files from raw pixels using only Node’s zlib — no image library needed.
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.
Every PNG file follows the same structure:
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.).
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.
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.
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.
// 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).
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:
0xedb88320 is the
bit-reversed form of the standard CRC-32 polynomial.
0xffffffff is part of
the CRC-32 specification (it improves detection of certain error patterns).
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.
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).
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.