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.
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:
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:
| Part | Size | Meaning |
|---|---|---|
| Length | 4 bytes | Size of the data part only (not itself, the type or the CRC) |
| Type | 4 bytes | Four ASCII letters, such as IHDR, IDAT, IEND |
| Data | Length bytes | Chunk contents, which may be empty |
| CRC | 4 bytes | Checksum 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:
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:
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:
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:
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:
| Idea | Last chapter's DEMO header | PNG |
|---|---|---|
| Byte order | Little-endian (<) | Big-endian (>) |
| Signature | 4 bytes of text | 8 bytes designed to survive text-mode mangling |
| Structure | One fixed header | Repeating length-type-data-CRC chunks |
| Damage detection | None | CRC-32 per chunk |
Challenges
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.
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.
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.
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:
IHDRfirst,IDAT(one or more),IENDlast - IHDR:
struct.unpack(">IIBBBBB", data)gives width, height, depth, colour type, compression, filter, interlace - Pixels: join
IDATdata,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
wave module.