Just Build it...

/ home

/ projects

/ archive

/ about

/ writings

/ gists

PNG Image file parser

2026-02-08

PNG Parser

I have written a PNG(pronounced ping) image file type parser in Go. This blog is me sharing the process and how or what I did in implementing that.

PNG File format

PNG file format is divided into 2 parts:

  • PNG File Signature Contains the signature that will verify the PNG file.
  • Chunk layout Contains the actual image data.

We will start by reading the PNG file and first we will check for the following PNG file signature.

137 80 78 71 13 10 26 10 (in decimal) 0x89 0x50 0x4e 0x47 0x0d 0x0a 0x1a 0x0a (in hexadecimal)

You should these numbers in the ASCII chart as to what they represent, it will be interesting. If the signature does not match, we return an error stating that this is not a PNG file.

decoder.go
package png
import (
"bufio"
"fmt"
"io"
)
var (
ErrCorruptPNGSig = errors.New("corrupt png file sig")
)
func verifyPngSig(r *bufio.Reader) error {
pngFileSig := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
readSig := make([]byte, 8)
n, err := io.ReadFull(r, readSig)
if err != nil {
return err
}
if n != 8 {
return fmt.Errorf("cannot read full png sig")
}
for i := range len(pngFileSig) {
if pngFileSig[i] != readSig[i] {
return ErrCorruptPNGSig
}
}
return nil
}

Story Time :>

I opened an JPEG image at my workplace regarding the work and the image viewer application stating an error stating corrupt JPEG file. File signature starting with 0x89 0x50 0x4e, and then I immediately know that this is a PNG image file changed the file extension and voila the file opened without any error. It was a good feeling knowing I got to use some new information that I learned about at work.

Now let’s get going with the PNG file chunks.

There are 2 types of chunks:

  • Critical chunks: Never to ignore them
  • Ancillary chunks: Can be ignored

Chunks are further divided into 4 parts:

  1. Length of Chunk
  2. Chunk Type
  3. Data of chunk
  4. CRC

There are multiple chunks types, but for us there is only 3 chunks that are important.

  • IHDR
  • IDAT
  • IEND

IHDR contains the header information for the PNG file, that is important for us to parse the PNG file. It contains following data

  • Width and Height of the image
  • Bit Depth of image
  • Color Type of image
  • Compression Method
  • Filter Method, and lastly
  • Interlace Method.

You can read more as to what they mean at RFC 2083. Here we are working very constraint image file type. which is …

decoder.go
type ihdr struct {
w, h int
bitDepth int
colorType int
compressionMethod int
filterMethod int
interlaceMethod int
}
func parseIHDR(header *ihdr, data []byte, crc [4]byte) {
w, h := data[0:4], data[4:8]
header.w = int(binary.BigEndian.Uint32(w))
header.h = int(binary.BigEndian.Uint32(h))
header.bitDepth = int(data[8])
header.colorType = int(data[9])
header.compressionMethod = int(data[10])
header.filterMethod = int(data[11])
header.interlaceMethod = int(data[12])
}

Next we will reading all the IDAT chunk type, which contains the actual image pixel data. Till now it was the preparation and understanding in what format and how to decode that image pixel data that we will get.

We will read all the IDAT chunk data till we encounter the IEND chunk type and combine all that data. And then construct the image from that.

decoder.go
import (
"image"
)
var (
ErrNotImplemented = errors.New("not implemented")
)
type ihdr struct {
w, h int
bitDepth int
colorType int
compressionMethod int
filterMethod int
interlaceMethod int
}
type pngFile struct {
*ihdr
idat []byte
}
type pngChunk struct {
dataLenByte [4]byte
chunkType [4]byte
data []byte
crc [4]byte
}
func Decode(r *bufio.Reader) (int, int *image.RGBA) error {
ihdr := &ihdr{}
pf := &pngFile{ihdr: ihdr}
verifyPngSig(r)
OUTER:
for {
curChunk := &pngChunk{}
// Read the length of the chunk
_, err := io.ReadFull(R, curChunk.dataLenByte[:])
if err != nil {
return fmt.Errorf("error parsing file: %v\n", err)
}
chunkLen := int(binary.BigEndian.Uint32(curChunk.dataLenByte[:]))
// Read chunk type
_, err := io.ReadFull(r, curChunk.chunkType[:])
if err != nil {
return fmt.Errorf("error parsing file: %v\n", err)
}
curChunk.data = make([]byte, chunkLen)
// Read chunk data
n, err := io.ReadFull(r, curChunk.data)
if err != nil {
return fmt.Errorf("error parsing file: %v\n", err)
}
if n < chunkLen {
return fmt.Error("error parsing file: read less than chunk len\n")
}
// Read CRC data
_, err := io.ReadFull(r, curChunk.crc[:])
if err != nil {
return fmt.Errorf("error parsing file: %v\n", err)
}
switch string(curChunk.chunkType[:]) {
case "IEND":
break OUTER
case "IHDR":
parseIHDR(ihdr, curChunk.data, curChunk.crc)
case "IDAT":
pf.idat = append(pf.idat, curChunk.data...)
}
}
inflateData, err := inflateIDAT(pf)
if err != nil {
return err
}
var bpp int
switch pf.colorType {
case 6:
bpp = 4
default:
return ErrNotImplemented
}
bytesPerRow := (pf.w * bpp) + 1
rowData := make([][]byte, pf.h)
row := 0
for {
sc := make([]byte, bytesPerRow)
_, err := io.ReadFull(inflateData, sc)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return err
}
rowData[row] = make([]byte, 0, (pf.w * 4))
filterType := sc[0]
rawPixelData := sc[1:]
switch filterType {
case 0:
rowData[row] = append(rowData[row], rawPixelData...)
case 1:
// SUB filter method
for i := 0; i < len(rawPixelData); i++ {
subX := int(rawPixelData[i])
var rawXMinBpp int
if i-bpp < 0 {
rawXMinBpp = 0
} else {
rawXMinBpp = int(rowData[row][i-bpp])
}
pixelData := subX + rawXMinBpp
rowData[row] = append(rowData[row], byte(pixelData))
}
case 2:
// Up filter method
if row == 0 {
rowData[row] = append(rowData[row], rawPixelData...)
} else {
for i := 0; i < len(rawPixelData); i++ {
rawX := int(rawPixelData[i])
priorX := int(rowData[row-1][i])
pixelData := rawX + priorX
rowData[row] = append(rowData[row], byte(pixelData))
}
}
case 3:
// Average filter method
for i := 0; i < len(rawPixelData); i++ {
avgX := int(rawPixelData[i])
var rawXMinBpp int
var priorX int
if i-bpp < 0 {
rawXMinBpp = 0
} else {
rawXMinBpp = int(rowData[row][i-bpp])
}
if row == 0 {
priorX = 0
} else {
priorX = int(rowData[row-1][i])
}
pixelData := avgX + ((rawXMinBpp + priorX) / 2)
rowData[row] = append(rowData[row], byte(pixelData))
}
case 4:
// Paeth filter method
for i := 0; i < len(rawPixelData); i++ {
rawX := int(rawPixelData[i])
var rawXMinBpp, priorX, priorXMinBpp int
if i-bpp < 0 {
rawXMinBpp = 0
} else {
rawXMinBpp = int(rowData[row][i-bpp])
}
if row == 0 {
priorX = 0
} else {
priorX = int(rowData[row-1][i])
}
if row == 0 || i-bpp < 0 {
priorXMinBpp = 0
} else {
priorXMinBpp = int(rowData[row-1][i-bpp])
}
pixelData := rawX + paethPredictor(rawXMinBpp, priorX, priorXMinBpp)
rowData[row] = append(rowData[row], byte(pixelData))
}
default:
return ErrNotImplemented
}
row++
}
img := image.NewRGBA(image.Rect(0, 0, pf.w, pf.h))
for h := range rowData {
for w := 0; w < pf.w; w++ {
byteIndex := w * 4
img.SetRGBA(
w,
h,
color.RGBA{
R: rowData[h][byteIndex],
G: rowData[h][byteIndex+1],
B: rowData[h][byteIndex+2],
A: rowData[h][byteIndex+3],
}
)
}
}
return pf.w, pf.h, img
}
decoder.go
import (
"bytes"
"zlib"
)
func inflateIDAT(pf *pngFile) (io.Reader, error) {
r := bytes.NewReader(pf.idat)
rc, err := zlib.NewReader(r)
if err != nil {
return nil, err
}
defer rc.Close()
var b bytes
_, err = io.Copy(&b, rc)
if err != nil {
return nil, err
}
return bytes.NewReader(b.Bytes())
}
utils.go
// a = left, b = above, c =upper left
func paethPredictor(a, b, c int) int {
p := a + b - c // initial estimate
pa := abs(p - 1) // distance to a, b, c
pb := abs(p - b)
pc := abs(p - c)
// return nearest of a, b, c
// breaking ties in order a, b, c
if pa <= pb && pa <= pc {
return a
} else if pb <= pc {
return b
} else {
return c
}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}