Inside a PNG: chunks, CRCs & the metadata your image is hiding
A PNG is one of the cleanest file formats ever shipped: an 8-byte signature, then nothing but self-describing, checksummed chunks until the end. That cleanliness is exactly what makes it easy to inspect — and easy to quietly stuff with metadata you never see: author strings, whole XMP packets, an EXIF block with the GPS position where the picture was taken. This is a tour of the chunk grammar, the CRC math, the places metadata hides, and the clean-copy byte surgery that strips it all without re-encoding a single pixel — at any file size.
Everything after byte 8 is a chunk
A PNG opens with a fixed 8-byte signature — 89 50 4E 47 0D 0A 1A 0A, engineered so file-transfer mangling (newline conversion, 7-bit stripping) is instantly detectable. Everything after it is a flat sequence of chunks:
┌─────────────┬────────────┬─────────────────────┬───────────┐
│ length (u32)│ type (4cc) │ data (length bytes) │ CRC (u32) │
└─────────────┴────────────┴─────────────────────┴───────────┘
The length counts only the data, so a chunk occupies length + 12 bytes on disk. The CRC-32 covers the type and data (not the length), so every chunk carries its own tamper/corruption detector. There is no nesting and no forward pointers — a minimal decodable file is just:
IHDR dimensions, bit depth, color type (always first)
IDAT the compressed pixels (one or more, consecutive)
IEND end marker (always last, empty)
Reading one chunk header is a handful of lines, and because every chunk declares its length you can skip any chunk you don't care about — the property everything in this article hangs on:
function readChunkHeader(u8, off, end) {
if (off + 8 > end) return null;
const length = u32(u8, off); // big-endian
const type = ascii(u8, off + 4, 4); // 'IHDR', 'IDAT', …
if (!/^[A-Za-z]{4}$/.test(type)) return null; // corrupt: stop cleanly
if (off + 12 + length > end) return null; // truncated
return { type, length, size: length + 12 };
}
The case-bit contract: critical vs ancillary
PNG encodes a chunk's disposability into the capitalization of its type — bit 5 of each letter is a flag. The one that matters most is the first:
| First letter | Meaning | Examples |
|---|---|---|
| Uppercase (critical) | A decoder that doesn't understand it must give up | IHDR PLTE IDAT IEND |
| Lowercase (ancillary) | Safe to ignore — and safe for an editor to remove | tEXt eXIf tIME gAMA pHYs |
This is a remarkable piece of 1996 engineering: the format itself tells you which chunks can be stripped without breaking the image. A metadata cleaner doesn't need to recognize a vendor's private chunk to know it's droppable — the lowercase first letter is a contract. The only nuance is that some ancillary chunks affect rendering rather than describing provenance: gAMA, sRGB, iCCP (color), tRNS (transparency), pHYs (print density), acTL/fcTL/fdAT (animation). A careful cleaner keeps those and strips the rest.
IHDR and the pixel pipeline
The 13-byte IHDR data is the whole picture header: width, height, bit depth, color type, and an interlace flag. Color types map to samples per pixel:
| Color type | What it is | Bits/pixel at depth 8 |
|---|---|---|
| 0 | grayscale | 8 |
| 2 | truecolor (RGB) | 24 |
| 3 | indexed — pixels are indices into PLTE | 8 |
| 4 | grayscale + alpha | 16 |
| 6 | truecolor + alpha (RGBA) | 32 |
The pixels themselves go through a two-stage pipeline: each scanline is filtered (each byte replaced by its difference from a neighbor — left, up, average, or the Paeth predictor — which turns smooth gradients into runs of near-zeros), and the filtered stream is deflate-compressed into the IDAT chunks. That's the entire compression story: PNG is losslessly "zip after a smart diff". It's also why the Compression tile on a screenshot reads 20× while a photo reads 1.5× — filtering thrives on flat regions and gradients, not sensor noise.
Text chunks: three flavours, one habit
PNG has three ways to embed free text, and editors use all of them:
tEXt— a Latin-1keyword\0valuepair. The spec registers keywords:Title,Author,Description,Copyright,Creation Time,Software,Source,Comment…zTXt— the same, but the value is deflate-compressed (for long text).iTXt— UTF-8, optionally compressed, with language tags. This is where XMP packets live, under the keywordXML:com.adobe.xmp— Photoshop and friends write kilobytes of RDF/XML here: creator tool, edit history, document IDs.
None of this is visible in any image viewer, and all of it survives uploads, backups and "Save as". A screenshot's Software chunk names your OS; an exported design's XMP carries a document ID that links revisions of the "same" file together.
eXIf: the chunk with your coordinates in it
Since 2017 PNG has an official eXIf chunk whose payload is a complete TIFF/EXIF structure — the same one JPEG carries. That means camera make and model, capture timestamps, lens data and, if a phone wrote the file (or an editor preserved it during a JPEG→PNG conversion), a GPS IFD with the latitude and longitude of the shot:
eXIf
└── TIFF header ("II" little-endian, magic 42)
└── IFD0: Make="OmniCam", Model=…, DateTime=…
├── ExifIFD → DateTimeOriginal, …
└── GPS IFD → GPSLatitude = 48/1, 51/1, 3024/100 (48°51'30.24")
GPSLongitude = 2/1, 17/1, 4020/100 ( 2°17'40.20")
Coordinates are stored as three rationals (degrees, minutes, seconds — each a numerator/denominator pair), so decoding is deg + min/60 + sec/3600. The /png toolkit's METADATA tab parses exactly this — make, model, dates and the GPS position — and offers a one-click Remove; the shipped sample carries a deliberately fake position (the Eiffel Tower) so you can watch it go.
eXIf, tIME and XMP into PNGs every day — and because the chunks are ancillary, nothing ever complains. The CHUNKS tab's verdict banner exists for exactly this.The clean-copy surgery
Stripping metadata from a PNG never touches the pixels. Walk the chunk list, decide keep-or-drop per chunk, and emit the kept ranges — the case-bit contract from earlier does the heavy lifting:
const KEEP_ANCILLARY = new Set([ // ancillary but render-affecting
'tRNS','gAMA','cHRM','sRGB','iCCP','sBIT','bKGD','hIST','pHYs',
'sPLT','acTL','fcTL','fdAT',
]);
const isMetadata = (type) =>
['tEXt','zTXt','iTXt','eXIf','tIME'].includes(type) ||
(isLowercase(type[0]) && !KEEP_ANCILLARY.has(type));
// layout: every chunk in file order [{type, start, size}]
const parts = [{ start: 0, end: 8 }]; // the signature rides along
for (const c of layout) {
if (isMetadata(c.type)) continue; // dropped
pushSlice(parts, c.start, c.start + c.size); // adjacent slices coalesce
}
Editing metadata is the same walk with two twists: existing text chunks whose keyword you edited are dropped, and fresh tEXt chunks (or iTXt, when the value needs more than Latin-1) are spliced in right after IHDR. Each fresh chunk computes its own CRC-32 — the same polynomial as zip/gzip:
function buildChunk(type, data) {
const out = new Uint8Array(12 + data.length);
writeU32(out, 0, data.length);
writeAscii(out, 4, type);
out.set(data, 8);
writeU32(out, 8 + data.length, crc32(out, 4, 8 + data.length));
return out;
}
Note what this surgery doesn't require: no inflate, no filter reversal, no pixel decode. Chunks are independent, offsets are relative to nothing, and no chunk points at another — remove one and the rest are untouched. (Contrast with MP4, where moving a box invalidates every chunk-offset table in the file.)
Scaling to 20 GB: skip the pixels entirely
Gigapixel scans, map tiles, scientific plots — PNGs get big, and 99.9% of a big PNG is IDAT. The walk above only ever needs each chunk's 8-byte header, so the parser reads 8 bytes and skips length, never touching the pixel data:
async function walkChunks(file) { // file: a Blob/File handle
const layout = [];
let off = 8; // past the signature
while (off + 8 <= file.size) {
const head = new Uint8Array(await file.slice(off, off + 8).arrayBuffer());
const h = decodeHeader(head);
if (!h) break; // malformed: stop cleanly
layout.push({ type: h.type, start: off, size: h.length + 12 });
off += h.length + 12; // ← skips the pixel bytes
if (h.type === 'IEND') break;
}
return layout;
}
Two refinements matter in practice. First, encoders emit IDAT in small pieces (often 8–64 KB), so a huge file has hundreds of thousands of chunks; consecutive IDATs coalesce into one run (IDAT ×143 · 18.2 MB) so the chunk list and the rewrite layout stay tiny. Second, CRC verification requires reading a chunk's full data — fine to do for every chunk on a small file, deliberately skipped for the pixel runs on a big one (the toolkit verifies everything up to 64 MB and says exactly what was checked beyond that).
The rewrite output streams the same way the MP4 fixer's does: fresh chunk bytes plus slices of the original file, handed to a Blob — slices are lazy references, so downloading a cleaned multi-GB PNG never holds the pixels in memory:
const blob = new Blob(parts.map((p) =>
p.bytes ? p.bytes : file.slice(p.start, p.end)), { type: 'image/png' });
APNG: animation by ancillary chunks
Animated PNG is a masterclass in backward compatibility: it adds three ancillary chunks — acTL (frame count, loop count), fcTL (per-frame timing/geometry) and fdAT (per-frame pixel data) — while the first frame stays in ordinary IDAT. A pre-2017 decoder ignores the lowercase chunks and shows a still image; an APNG-aware one plays the animation. It's the case-bit contract doing real work — and it's why a metadata cleaner must keep acTL/fcTL/fdAT on its keep-list, or a clean copy would silently freeze the animation.