Hi! Here is a PNG writer I wrote as a utility as part of a hobby project I’ve been working on, where I preferred to minimise dependencies and keep things simple. It hope it serves as a good way of understanding the PNG format! Note that this doesn’t compress, and it only supports RGBA; but it can be useful for tiny pixel art images.

Let’s start with the prerequisites:

RFC 1951 Deflate stream

The first thing needed is a function to convert an arbitrary buffer into a ‘deflate stream’. A deflate stream is made up of blocks of up to 64kb. The simplest possible compliant deflate block looks like this:

  • 1 byte: 1 = this is the final block; 0 = this is not the final block.
  • 2 bytes: data length, lowest-significant-byte first.
  • 2 bytes: length 1’s complement (all bits flipped).
  • N bytes: data

In Rust, this looks like:

fn to_deflate_stream(input: &[u8]) -> Vec<u8> {
    if input.is_empty() {
        return vec![1, 0, 0, 0xff, 0xff]; // 1 block with no content.
    let mut output = Vec::<u8>::new();
    let chunks = input.chunks(0xffff);
    let final_index = chunks.len() - 1;
    for (index, chunk) in chunks.enumerate() {
        let is_final = index == final_index;
        output.push(if is_final { 1 } else { 0 });
        let len = chunk.len();
        let len_lsb = (len & 0xff) as u8;
        let len_msb = (len >> 8) as u8;
        output.push(len_lsb); // Max len.
        output.push(!len_lsb); // 1's complement of len.

RFC 1950 Zlib stream

The next building block necessary is to convert a data buffer into a Zlib stream. This consists of adding a header and footer to a Deflate stream. The simplest possible header for the uncompressed case is 0x7801, meaning:

  • A standard window size of 7 (meaningless when not compressed).
  • A method of 8 (meaning deflate).
  • A 1 in the second byte for a checksum, no dictionary bit set, and 0 for the compression level bits.

The footer is an ADLER32 checksum.

In Rust, this looks like:

fn to_zlib_stream(input: &[u8]) -> Vec<u8> {
    // Header.
    let mut output = Vec::<u8>::new();
    output.push(0x78); // CMF byte. Bits 0-3=method, 4-7=info/window size. Method=8, Window size=7.
    output.push(1); // FLG byte. Bits 0-4=fcheck, 5=fdict which we dont want so 0, 6-7=flevel where 0 means fastest.

    // Body.
    let deflated = to_deflate_stream(input);

    // Checksum.
    // See:
    let mut a: u32 = 1;
    let mut b: u32 = 0;
    for data in input {
        a = (a + (*data as u32)) % 65521;
        b = (b + a) % 65521;
    output.push((b >> 8) as u8);
    output.push((b & 0xff) as u8);
    output.push((a >> 8) as u8);
    output.push((a & 0xff) as u8);



Another important piece of the puzzle is the ability to calculate CRCs for a buffer.

I can’t claim to understand how these work. If interested, you could read more here:

In Rust, it looks like:

fn crc(data: &[u8]) -> u32 {
    // Make the CRC table first.
    // An optimised implementation would only do this once.
    let mut crc_table: [u32; 256] = [0; 256];
    for n in 0..256 {
        let mut c: u32 = n as u32;
        for _k in 0..8 {
            if c & 1 == 1 {
                c = 0xedb88320u32 ^ (c >> 1);
            } else {
                c = c >> 1;
        crc_table[n] = c;

    // Calculate the CRC.
    let mut crc: u32 = 0xffffffff;
    for b in data {
        let index = ((crc ^ (*b as u32)) & 0xff) as usize;
        crc = crc_table[index] ^ (crc >> 8);


And now we have the prerequisites for generating the PNG. PNG files consist of a header then at least 3 blocks. The following describes the RGBA case, which has no palette:

The header looks like: 0x89 “PNG” CR LF EOF LF.

Each block looks like:

  • Data length: 4 bytes, most significant byte first.
  • Type: 4 ascii chars.
  • Data
  • 4 bytes CRC of the type and data.

The compulsory 3 blocks are: IHDR, IDAT, and IEND.


The header block’s data consists of:

  • Width and height: 4 bytes each, MSB first.
  • Bits per pixel: 1 byte.
  • Type: 6 means RGBA.
  • Compression: always 0 which means Zlib.
  • Filter method: always 0.
  • Interlacing flag.


The data block consists of RGBA samples, left to right, top to bottom, in a Zlib stream. At the start of each row, a 0 is prefixed which means ‘no filter applies to this row’.


The end block has empty data. I’d be willing to bet you could omit this block and no parser would care.

The PNG generator code looks like the following in Rust:

fn png(width: u32, height: u32, rgba: &[u32]) -> Vec<u8> {
    let mut output = Vec::<u8>::new();

    // Header.
    output.push(0x0d); // Cr
    output.push(0x0a); // Lf
    output.push(0x1a); // Eof
    output.push(0x0a); // Lf

    // Build IHDR.
    let mut ihdr_type_and_data = Vec::<u8>::new();
    append_msb(&mut ihdr_type_and_data, width);
    append_msb(&mut ihdr_type_and_data, height);
    ihdr_type_and_data.push(8); // 8bpp.
    ihdr_type_and_data.push(6); // RGBA.
    ihdr_type_and_data.push(0); // Compression method: zlib.
    ihdr_type_and_data.push(0); // Filter method.
    ihdr_type_and_data.push(0); // No interlace.
    let ihdr_len = ihdr_type_and_data.len() - 4; // Minus the type.
    let ihdr_crc = crc(&ihdr_type_and_data);
    // Append IHDR to output.
    append_msb(&mut output, ihdr_len as u32);
    append_msb(&mut output, ihdr_crc);

    // Build image data.
    let mut idat_data = Vec::<u8>::new();
    let mut rgba_iter = rgba.iter();
    for _y in 0..height {
        idat_data.push(0); // Each line is prepended a filter type byte (0).
        for _x in 0..width {
            let rgba =;
            idat_data.push((rgba >> 24) as u8);
            idat_data.push((rgba >> 16) as u8); // 'as u8' truncates upper bits for us.
            idat_data.push((rgba >> 8) as u8);
            idat_data.push((*rgba) as u8);
    let compressed_idat_data = to_zlib_stream(&idat_data);

    // Build IDAT.
    let mut idat_type_and_data = Vec::<u8>::new();
    let idat_len = idat_type_and_data.len() - 4; // Minus the type.
    let idat_crc = crc(&idat_type_and_data);
    // Append IDAT to output.
    append_msb(&mut output, idat_len as u32);
    append_msb(&mut output, idat_crc);

    // IEND (no data).
    append_msb(&mut output, 0); // Length.
    output.push(b'I'); // Type.
    let iend_crc = crc(b"IEND");
    append_msb(&mut output, iend_crc);


fn append_msb(vec: &mut Vec<u8>, value: u32) {
    vec.push((value >> 24) as u8);
    vec.push((value >> 16) as u8);
    vec.push((value >> 8) as u8);
    vec.push((*value) as u8);

And that’s it! That’s the basics of writing PNGs in around 150LOC.

The full code can be seen here:

Feel free to use it in your projects by copying the file in; I haven’t published this as crate, I think it’s too simple to justify that.

Thanks for reading, I hope this is helpful, God bless :)

Photo by Tengyart on Unsplash

Thanks for reading! And if you want to get in touch, I'd love to hear from you: chris.hulbert at gmail.

Chris Hulbert

(Comp Sci, Hons - UTS)

iOS Developer (Freelancer / Contractor) in Australia.

I have worked at places such as Google, Cochlear, Assembly Payments, News Corp, Fox Sports, NineMSN, FetchTV, Woolworths, and Westpac, among others. If you're looking for help developing an iOS app, drop me a line!

Get in touch:
[email protected]
my resume

 Subscribe via RSS