✳ OMNIVIEWER PNG viewer Hidden messages ← back

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.

On this page The chunk structure The case-bit contract IHDR & the pixel pipeline Text chunks eXIf & the GPS position The clean-copy surgery Scaling to 20 GB APNG

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 letterMeaningExamples
Uppercase (critical)A decoder that doesn't understand it must give upIHDR PLTE IDAT IEND
Lowercase (ancillary)Safe to ignore — and safe for an editor to removetEXt 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 typeWhat it isBits/pixel at depth 8
0grayscale8
2truecolor (RGB)24
3indexed — pixels are indices into PLTE8
4grayscale + alpha16
6truecolor + 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:

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.

The quiet leak. "PNGs don't have EXIF" was true for twenty years, and most privacy checklists still say it. Meanwhile phones, screenshot tools and converters write 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.

See it on a real file

Open the /png toolkit → Drop any PNG: the CHUNKS tab shows this whole structure on your file, with CRC verdicts and the one-click clean copy. 100% local. Try the sample → A generated sunset carrying every metadata flavour — tEXt, zTXt, an XMP packet, tIME and an eXIf block with a (fake) GPS position to strip.