Summary: uncompng is a small, single-file C library (or a Go port) for producing uncompressed PNG files. Doing so can be simpler, easier and quicker than producing a regular (well compressed) PNG file.
Julia Set Example
Sometimes I’ll have a program that produces a temporary 2-dimensional image file (e.g. a PNG file). Temporary means something like:
- it will undergo further optimizing (such as running pngcrush on it) before e.g. checking it into source control, shipping it in an app binary, serving it (repeatedly) over the network, etc.
- it will undergo further processing, e.g. stitching multiple temporary images into a larger one.
- I just want to eyeball the thing and will probably delete it an hour later.
Producing a good quality PNG file - one that is compressed - requires some moderately complex code and a moderate amount of CPU time (e.g. to calculate the optimal Huffman codes). But if you’re just producing a temporary file, you don’t always need to output a good PNG, just a valid one. It can be simpler, easier and quicker to produce an uncompressed PNG file. You could produce uncompressed BMP or PPM instead, but PNG is ubiquitous and widely understood by many software tools.
uncompng.c is a small (600-ish lines of code) single-file C library for producing uncompressed PNG files. It has no dependencies, not even on malloc. There’s also an equivalent uncompng Go package, which has more commentary (and its unit tests can easily use the image/png decoder from Go’s standard library).
For example, this tiny (50-ish line julia-gen.c program) produces an image of a Julia set.

Run it like this:
This example’s generated Julia Set image is grayscale (1 byte per pixel as an array), although the library can also do RGBA (4 bytes per pixel). At 256 × 256 pixels, this is 65536 bytes of data. The actual julia-temp.png file size is 65877, which is a 0.5% overhead.
The pngcrush’ed julia-final.png file’s size is 26155 bytes. In an amusing coincidence, this is still slightly larger than the sum of the C program (julia-gen.c) and C library (uncompng.c): 1499 + 24549 = 26048 bytes.
Uncompressed Chunks
Many compression formats (including the 2-dimensional PNG and the 1-dimensional ZLIB) consist of a header, multiple chunks (chunks can also be called blocks or frames or some other similar term) and a footer, all byte-aligned. In general, a compressed chunks’ length (in bytes) is hard to calculate a priori but, when using the uncompressed subset of a compression format, each uncompressed chunk is usually pretty trivial: a chunk-header, the literal (uncompressed) chunk-payload and a (possibly empty) chunk-footer.
The chunk-header and chunk-footer lengths are usually predictable, so it’s pretty straightforward, when slicing and wrapping an arbitrarily large input into uncompressed chunks, to select the input-slice lengths so that the wrapped-slice lengths have a known upper bound. This lets us use fixed-sized buffers and hence the C code doesn’t depend on malloc and the Go code can test that there are no dynamic memory allocations (other than a re-usable Encoder struct that holds its own fixed-size buffer).
uncompng.go has some commentary about the details of the PNG file format (for an “uncompressed PNG” encoder): an IHDR chunk (containing width and height), multiple IDAT chunks containing an “uncompressed ZLIB” encoding of the pixel data (including a zero “filter byte” before each row; zero means a no-op) and an IEND chunk. The rest of this blog post will walk through a simpler example, of just producing “uncompressed” ZLIB.
Uncompressed ZLIB
ZLIB (RFC 1950) just wraps DEFLATE (RFC 1951) with a header (almost always just two bytes: 78 01) and a 4-byte footer (an Adler-32 checksum).
A DEFLATE stream is just a series of blocks and an uncompressed block (wrapping N bytes) is literally those N bytes (N can range up to 65535, inclusive) with a 5-byte block-header and 0-byte block-footer. Of those five bytes:
- The first one is either 0x00 or 0x01, depending on whether this a non-final or final block. Byte values above 0x01 mean a compressed block, which is important if you’re implementing the entirety of the DEFLATE file format but not part of the subset of DEFLATE that we are producing.
- The middle two are N as a little-endian uint16.
- The last two are the same as the middle two, xor’ed with 0xFF 0xFF. These last two bytes are redundant but help detect data corruption.
For example, if N was 59 (which is 0x3B in hexadecimal) and the block wasn’t final, the five header bytes would be 00 3B 00 C4 FF.
N can range up to 65535 but, to make the subsequent hex dump easier to follow with the naked eye, we will always pick an N less than 64 so that each block (plus ZLIB header and footer if applicable) is exactly 64 bytes long (although the last one or two blocks can be shorter). We first insert placeholder bytes, to be filled in later, to visualize this alignment. Starting with some lorem ipsum nonsense as input data, here’s the padded partition:
Here’s a hex dump (16 bytes per row) of that ZLIB-encoded “loremipsum etc etc laborum” text, after filling in those placeholders to produce a valid ZLIB file. There’s an additional annotation, on the right hand side, of the bytes that aren’t the literal, uncompressed text. Specifically: (A) the 2-byte ZLIB header, (B) 5-byte DEFLATE block-headers and (C) the 4-byte ZLIB footer (an Adler-32 checksum).
A simple Go program (runnable on the Go playground) demonstrates constructing that ZLIB-formatted data and that ZLIB-decoding those 410 bytes recovers the original text’s 369 bytes.
Published: 2025-07-19
.png)
