When your camera or phone saves a JPEG it staples on a block of EXIF metadata: make and model, lens, the exact moment the shutter fired, aperture, shutter speed, ISO, focal length, orientation — and, if location was on, GPS coordinates. That block is not magic. It is a tiny, self-describing TIFF structure tucked inside the JPEG's APP1 segment, and you can parse it with nothing but a DataView over the file's bytes. No exif-js, no upload — FileReader.readAsArrayBuffer keeps everything in the browser.
A JPEG is a chain of markers
Every JPEG starts with 0xFFD8 (Start Of Image). After it come segments, each introduced by 0xFF + a marker byte, then a 2-byte big-endian length. Walk them until you hit APP1 (0xFFE1) or the start of scan (0xFFDA), skipping each segment by its length. An APP1 can hold other things (XMP), so confirm the six identifier bytes "Exif\0\0" before trusting it — the TIFF block begins right after them.
if (view.getUint16(0) !== 0xFFD8) return null; // not a JPEG
let off = 2;
while (off + 4 <= view.byteLength){
const marker = view.getUint16(off);
if ((marker & 0xFF00) !== 0xFF00) break; // lost sync
if (marker === 0xFFDA) break; // start of scan, done
const size = view.getUint16(off + 2); // length (big-endian)
if (marker === 0xFFE1 &&
view.getUint32(off + 4) === 0x45786966 && // "Exif"
view.getUint16(off + 8) === 0x0000){ // \0\0
const tiff = off + 10; // TIFF header starts here
}
off += 2 + size;
}
Endianness is not optional
The TIFF header's first two bytes decide everything downstream: 0x4949 = "II" (little-endian) or 0x4D4D = "MM" (big-endian). A sanity check follows — the number 42 (0x002A) read in that order — then a 4-byte offset to the first directory, IFD0. Read one integer with the wrong byte order and every offset points into garbage.
const bo = view.getUint16(tiff);
const little = bo === 0x4949 ? true : bo === 0x4D4D ? false : null;
if (view.getUint16(tiff + 2, little) !== 0x002A) throw new Error("bad magic 42");
const ifd0Off = view.getUint32(tiff + 4, little); // offset from `tiff`
An IFD is a list of 12-byte entries
A directory is a 2-byte count, then that many 12-byte entries, then a pointer to the next IFD. Each entry is: 2-byte tag, 2-byte type, 4-byte count, and a 4-byte value-or-offset — all in the declared byte order.
function readIFD(view, little, tiff, ifdOff){
const out = {};
const n = view.getUint16(ifdOff, little);
for (let i = 0; i < n; i++){
const e = ifdOff + 2 + i * 12;
out[view.getUint16(e, little)] = {
type: view.getUint16(e + 2, little),
count: view.getUint32(e + 4, little),
value: readValue(view, little, tiff, e)
};
}
return out;
}
The trickiest rule: inline vs offset
The value field is only four bytes. Each type has a width (BYTE/ASCII = 1, SHORT = 2, LONG = 4, RATIONAL = 8); multiply by the count. If the total fits in four bytes the value sits inline in the entry; otherwise those four bytes are an offset (from the TIFF start) to where the real data lives.
const bytes = (TYPE_SIZE[type] || 1) * count;
let p = bytes <= 4 ? e + 8 : tiff + view.getUint32(e + 8, little);
Rationals, not floats
EXIF stores exposure, aperture and GPS as pairs of integers — numerator over denominator. ExposureTime is literally [1, 250] → 1/250 s; FNumber [28, 10] → f/2.8. A GPS coordinate is three rationals (degrees, minutes, seconds) folded into one decimal, negated for the S/W hemispheres so a single number drops onto a map.
const rat = r => r[1] ? r[0] / r[1] : 0;
function gpsDecimal(dms, ref){ // [[d,d'],[m,m'],[s,s']] + ref
let deg = rat(dms[0]) + rat(dms[1]) / 60 + rat(dms[2]) / 3600;
return (ref === "S" || ref === "W") ? -deg : deg;
}
The camera settings live in a separate EXIF sub-IFD (tag 0x8769) and location in the GPS IFD (0x8825) — both just offsets, read with the very same readIFD. And plenty of files have no EXIF at all: PNGs, screenshots, anything a chat app re-encoded, so every stage returns cleanly and the UI says "no EXIF found" instead of throwing.
Drop one of your own photos and watch it decode, byte by byte, at https://dev48v.infy.uk/solve/day56-exif-viewer.html
Top comments (0)