Reading a PNG

Binary File Formats in Practice

Chapter 5 ยท Reading a PNG

The previous chapters built the toolkit: bytes, integers and byte order, struct, signatures and headers. A PNG image is a good first real format because it uses all of them and is simple to walk. A PNG is a fixed 8-byte signature followed by a sequence of chunks, each with the same layout, and each protected by a checksum. This chapter writes a tiny PNG by hand, reads it back chunk by chunk, and checks every length and CRC along the way. It builds on Binary File Formats in Practice 3 and Binary File Formats in Practice 4.

What was run
Every snippet and solution in this chapter was executed, and the output shown in the comments is what Python printed.

The Signature

Every PNG starts with the same eight bytes, the signature from Binary File Formats in Practice 4. Nothing else in the file is at a guessable position, so the reader checks this first and refuses anything else:

import struct, zlib SIG = b"\x89PNG\r\n\x1a\n" print(len(SIG), SIG.hex(" ")) # 8 89 50 4e 47 0d 0a 1a 0a

The Chunk Layout

After the signature, everything is a chunk. Each one has four parts, and the integers are big-endian (network order), the opposite of the little-endian headers in the last chapter:

PartSizeMeaning
Length4 bytesSize of the data part only (not itself, the type or the CRC)
Type4 bytesFour ASCII letters, such as IHDR, IDAT, IEND
DataLength bytesChunk contents, which may be empty
CRC4 bytesChecksum of the type and the data (not the length)

So a chunk always occupies length + 12 bytes, and the next chunk starts right after it. The three chunks every simple PNG needs are IHDR (the image header, always first), one or more IDAT (the compressed pixel data) and IEND (always last, with no data). The specification defines more, such as palettes and text; this chapter ignores them (check against the specification for the full list).

CRC With zlib

A CRC (cyclic redundancy check) is a 32-bit fingerprint of some bytes. If even one bit changes, the fingerprint almost certainly changes, so a reader can detect accidental damage. PNG uses the same CRC-32 that zlib.crc32 computes. It is not a security feature: anyone who edits a chunk can simply recompute the CRC.

Writing a chunk is therefore three struct.pack pieces and one checksum. The empty IEND chunk is a handy test, because its bytes are the same in every PNG:

def chunk(ctype, data=b""): body = ctype + data return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) print(zlib.crc32(b"IEND"), hex(zlib.crc32(b"IEND"))) # 2923585666 0xae426082 print(chunk(b"IEND").hex(" ")) # 00 00 00 00 49 45 4e 44 ae 42 60 82

Read the output left to right: a zero length, the letters IEND, and the CRC ae 42 60 82. Those last four bytes are the same at the end of every PNG you will ever open.

Building a Tiny Image

The IHDR data is 13 bytes: width and height (4 bytes each), then five single bytes: bit depth, colour type, compression method, filter method and interlace method. Colour type 0 means greyscale and bit depth 8 gives one byte per pixel. The pixel data itself is each row preceded by a one-byte filter type (0 means "none"), then the whole stream compressed with zlib and stored in IDAT:

def make_png(rows): h, w = len(rows), len(rows[0]) ihdr = struct.pack(">IIBBBBB", w, h, 8, 0, 0, 0, 0) raw = b"".join(b"\x00" + bytes(r) for r in rows) return SIG + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND") png = make_png([[0, 64, 128, 255], [255, 128, 64, 0]]) print(png[:8] == SIG, png[8:33].hex(" ")) # True 00 00 00 0d 49 48 44 52 00 00 00 04 00 00 00 02 08 00 00 00 00 5a c3 22 bf

Take that hex apart with what you know. 00 00 00 0d is length 13. 49 48 44 52 is the ASCII for IHDR. Then width 4, height 2, depth 8, and four zero bytes. The final four bytes, 5a c3 22 bf, are the CRC of the type and the 13 data bytes.

Walking the Chunks

A reader repeats one step: read the length and type, check the chunk fits inside the file, slice out the data, read the stored CRC and compare. A generator keeps the loop tidy. Notice that every length is checked against the real data size before it is used, the habit that Binary File Formats in Practice 9 develops fully:

def read_chunks(raw): if raw[:8] != SIG: raise ValueError("not a PNG: bad signature") pos = 8 while pos < len(raw): if pos + 8 > len(raw): raise ValueError("truncated chunk header") length, ctype = struct.unpack_from(">I4s", raw, pos) end = pos + 12 + length if end > len(raw): raise ValueError(f"chunk {ctype!r} runs past end of file") data = raw[pos + 8:pos + 8 + length] (stored,) = struct.unpack_from(">I", raw, pos + 8 + length) if stored != zlib.crc32(ctype + data): raise ValueError(f"CRC mismatch in {ctype!r}") yield ctype, data pos = end for ctype, data in read_chunks(png): print(ctype, len(data)) # b'IHDR' 13 # b'IDAT' 18 # b'IEND' 0

The IDAT size of 18 is what this machine's zlib produced for this tiny image; another zlib version or setting may give a different number, but the chunk structure is identical (check).

Reading the Header and the Pixels

Collecting the chunks into a dictionary is fine for this simple file (a real PNG may have several IDAT chunks whose data must be joined before decompressing, as Challenge 3 does). Unpacking IHDR gives the image properties, and zlib.decompress reverses the compression of the pixel data:

chunks = dict(read_chunks(png)) w, h, depth, color, comp, filt, interlace = struct.unpack(">IIBBBBB", chunks[b"IHDR"]) print(w, h, depth, color, comp, filt, interlace) # 4 2 8 0 0 0 0 raw = zlib.decompress(chunks[b"IDAT"]) print(raw.hex(" ")) # 00 00 40 80 ff 00 ff 80 40 00 stride = 1 + w # one filter byte plus one byte per pixel print([list(raw[y * stride + 1:(y + 1) * stride]) for y in range(h)]) # [[0, 64, 128, 255], [255, 128, 64, 0]]

The two rows come back exactly as they went in. This only works because every row used filter type 0. Real encoders usually choose other filters (the specification lists five) that store each pixel relative to its neighbours, because that compresses better. Decoding those is more work, so a robust reader checks the filter byte and refuses what it does not support, as Challenge 3 does.

Catching Damage

Flip a single bit inside the IHDR data and the stored CRC no longer matches, so the reader stops before it ever trusts the width:

bad = bytearray(png) bad[19] ^= 0x01 # last byte of the width field try: list(read_chunks(bytes(bad))) except ValueError as e: print("ValueError:", e) # ValueError: CRC mismatch in b'IHDR'
Check the CRC first, then interpret
Verify a chunk's CRC before unpacking its fields. A corrupt length or width can make later code allocate huge buffers or loop for ages; failing at the CRC keeps bad numbers out of the rest of the program. Remember the limits: a CRC catches accidental damage, not deliberate tampering.
IdeaLast chapter's DEMO headerPNG
Byte orderLittle-endian (<)Big-endian (>)
Signature4 bytes of text8 bytes designed to survive text-mode mangling
StructureOne fixed headerRepeating length-type-data-CRC chunks
Damage detectionNoneCRC-32 per chunk

Challenges

Exercise 1

Write list_chunks(raw) that walks a PNG and returns a list of (type, length) pairs without checking CRCs. It must reject a wrong signature, a chunk whose length runs past the end of the data, and a file with too few bytes left to hold a chunk header. Build a 3 by 1 greyscale PNG by hand and show the list plus all three failures.

๐Ÿ“„ View solution
Exercise 2

Write verify(raw) that returns a list of (type, ok) pairs, where ok says whether each chunk's stored CRC matches. Show a clean 2 by 2 image, then the same image with one bit of the IHDR width flipped, then with the stored CRC byte itself damaged, and finally with an IDAT length byte damaged (which should raise an error, not report a CRC problem). Explain in a comment why the last case differs.

๐Ÿ“„ View solution
Exercise 3

Write write_gray_png(path, rows) and read_gray_png(path) for 8-bit greyscale images. The reader checks every CRC, joins all IDAT data, rejects any image that is not 8-bit greyscale and non-interlaced, rejects any row with a filter other than 0, and returns the rows. In a temporary folder, write a 4 by 3 image, read it back and compare, then damage one byte inside IHDR and show the error. Remove the folder and confirm it is gone.

๐Ÿ“„ View solution

Quick Reference

  • Signature: b"\x89PNG\r\n\x1a\n", 8 bytes, checked first
  • Chunk: length (4, big-endian) + type (4) + data + CRC (4); total length + 12
  • CRC covers: type and data only, computed with zlib.crc32
  • Essential chunks: IHDR first, IDAT (one or more), IEND last
  • IHDR: struct.unpack(">IIBBBBB", data) gives width, height, depth, colour type, compression, filter, interlace
  • Pixels: join IDAT data, zlib.decompress, then each row is a filter byte plus its pixels
  • Always: check each length against the data you really have, and verify the CRC before using a chunk
Coming next
Binary File Formats in Practice 6 moves from images to sound: the WAV file, a RIFF container of chunks with a format description, and writing your own tone with Python's wave module.