How JPEG Hides Metadata: Building an EXIF Parser from Scratch
How JPEG Hides Metadata: Building an EXIF Parser from Scratch#
Published: 2026-05-02 04:01
When you snap a photo, your gallery app shows not just the image but also the timestamp, aperture, and GPS coordinates. Where does this data live? Let’s crack open a JPEG file and build an EXIF parser.
JPEG Segments#
A JPEG file isn’t one monolithic block — it’s a series of Segments. Each starts with 0xFF followed by a marker byte:
0xFF 0xD8 → SOI (Start of Image)
0xFF 0xE1 → APP1 (EXIF data segment)
...
0xFF 0xD9 → EOI (End of Image)
EXIF data lives inside the 0xFF 0xE1 APP1 marker. So the first step is scanning the binary data for 0xFFE1:
const buffer = await file.arrayBuffer()
const data = new Uint8Array(buffer)
let exifOffset = -1
for (let i = 0; i < data.length - 1; i++) {
if (data[i] === 0xFF && data[i + 1] === 0xE1) {
exifOffset = i
break
}
}
if (exifOffset === -1) {
// This image has no EXIF data
}
Once we find the APP1 marker, the real work begins.
Endianness Detection#
EXIF metadata uses the TIFF format internally. The first two bytes tell you the byte order — 0x4949 (“II”) means Little-Endian, 0x4D4D (“MM”) means Big-Endian:
const tiffOffset = exifOffset + 4
const byteOrderLE = data[tiffOffset] === 0x49
// Validate the TIFF magic number (must be 0x002A)
const byteOrderVal = readUint16(data, tiffOffset + 2, byteOrderLE)
if (byteOrderVal !== 0x002A) {
// Not valid TIFF
}
The helper reads via DataView.getUint16 with the endian flag:
function readUint16(data: Uint8Array, offset: number, littleEndian: boolean): number {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
return view.getUint16(offset, littleEndian)
}
Getting the endianness wrong corrupts every value you read. This is the most common trap when writing an EXIF parser.
IFD Traversal: The Core Data Structure#
The heart of EXIF is the IFD (Image File Directory) — a table of entries, each exactly 12 bytes:
| Offset | Size | Meaning |
|---|---|---|
| 0 | 2 | Tag ID (which property) |
| 2 | 2 | Data type (ASCII, SHORT, LONG, RATIONAL, etc.) |
| 4 | 4 | Count of values |
| 8 | 4 | Value data or offset pointer |
The parser iterates through these entries:
const IFD_ENTRY_SIZE = 12
function parseIFD(data: Uint8Array, baseOffset: number, ifdOffset: number, littleEndian: boolean) {
const tags = []
const entryStart = baseOffset + ifdOffset
const entryCount = readUint16(data, entryStart, littleEndian)
for (let i = 0; i < entryCount; i++) {
const entryOffset = entryStart + 2 + i * IFD_ENTRY_SIZE
const tagId = readUint16(data, entryOffset, littleEndian)
const type = readUint16(data, entryOffset + 2, littleEndian)
const count = readUint32(data, entryOffset + 4, littleEndian)
// ...
}
}
When the value exceeds 4 bytes (like a RATIONAL which is 8 bytes), the last 4 bytes of the entry store an offset pointer instead of the value itself.
RATIONAL types (type=5) are the trickiest — they’re two UINT32s (numerator + denominator):
function readRational(data: Uint8Array, offset: number, littleEndian: boolean): number {
const num = readUint32(data, offset, littleEndian)
const den = readUint32(data, offset + 4, littleEndian)
return den !== 0 ? num / den : 0
}
Sub-IFDs: Exif and GPS#
Two special Tag IDs point to sub-IFDs:
0x8769→ Exif Sub-IFD (exposure, ISO, aperture)0x8825→ GPS Sub-IFD (latitude, longitude, altitude)
When the parser hits these, it recursively calls parseIFD using the offset stored in the value field:
if (tagId === 0x8769) {
const subIFDOffset = readUint32(data, valueOffset, littleEndian)
const subTags = parseIFD(data, baseOffset, subIFDOffset, littleEndian)
tags.push(...subTags)
continue
}
if (tagId === 0x8825) {
const gpsIFDOffset = readUint32(data, valueOffset, littleEndian)
const gpsTags = parseGPSIFD(data, baseOffset, gpsIFDOffset, littleEndian)
tags.push(...gpsTags)
continue
}
GPS Coordinates: DMS to Decimal#
Cameras store GPS as Degrees/Minutes/Seconds (DMS) — three RATIONAL values. Conversion to decimal:
function readRationalAsDegrees(data: Uint8Array, offset: number, littleEndian: boolean): number {
const deg = readRational(data, offset, littleEndian)
const min = readRational(data, offset + 8, littleEndian)
const sec = readRational(data, offset + 16, littleEndian)
return deg + min / 60 + sec / 3600
}
Then apply the sign from the reference fields:
latitude = latRef === 'S' ? -num : num
longitude = lonRef === 'W' ? -num : num
Human-Friendly Display#
Raw values aren’t useful to end users. Format them:
if (tagName === 'FNumber') {
displayValue = `f/${num.toFixed(1)}` // f/2.8
} else if (tagName === 'ExposureTime') {
displayValue = num < 1 ? `1/${Math.round(1 / num)} sec` : `${num} sec`
} else if (tagName === 'ISOSpeedRatings') {
displayValue = `ISO ${value}`
} else if (tagName === 'Flash') {
displayValue = v & 0x1 ? 'Fired' : 'Did not fire'
}
Zero-Dependency Browser Solution#
The entire EXIF parser uses only Uint8Array and DataView — roughly 300 lines of TypeScript. No third-party libraries needed.
Try it online: JsonKit EXIF Viewer. Drag a JPEG, see camera info, GPS on a map, and export as text.
Related tools: IP Geo Location | Port Checker | Text Deduplicator