Reading an iPhone HEIC Photo's Hidden Location by Hand
Most iPhone photos are HEIC files, and most of them carry the exact GPS location where they were taken. When I built ToolOnTap’s EXIF viewer, I wanted it to show that location for HEIC photos in any browser. That sounds hard, because only Safari can decode HEIC images. But you don’t need to decode the picture to read its metadata. You only need to understand how the file is put together.
This is how I did it, starting from a real photo taken on an iPhone 16.
A HEIC file is a set of boxes
HEIC is the HEIF image format (ISO/IEC 23008-12), which is built on the ISO base media file format, the same family as MP4. The whole file is a sequence of boxes. Each box starts with its size in 4 bytes, then a 4-letter type, then its contents. Some boxes contain other boxes.
There are two special sizes: 1 means a 64-bit size follows the type, and 0 means the box runs to the end of the file. That’s all you need to walk through any box list:
function boxes(b: Uint8Array, view: DataView, from: number, to: number): Box[] {
const list: Box[] = []
let i = from
while (i + 8 <= to) {
let size = view.getUint32(i)
let header = 8
if (size === 1) { if (i + 16 > to) break; size = Number(view.getBigUint64(i + 8)); header = 16 }
else if (size === 0) size = to - i
if (size < header || i + size > to) break
list.push({ type: ascii(b, i + 4, 4), start: i, body: i + header, end: i + size })
i += size
}
return list
}
The last check matters. A box that claims to be bigger than the file means the file is damaged or cut off, and the loop stops instead of reading past the end.
Running this over the iPhone 16 photo gives three top-level boxes:
| Box | Size | What it holds |
|---|---|---|
ftyp |
52 bytes | The file type: which brands of HEIF it follows |
meta |
about 36 KB | The description of every item in the file |
mdat |
about 3.4 MB | The actual data: compressed image tiles, EXIF and the rest |
The surprise: one photo is 117 images
Inside meta, the iinf box lists the file’s items. I expected one image. The iPhone 16 photo has 117 HEVC-coded images, most of them 512 × 512 tiles, plus 3 grid items that assemble tiles into full images: the photo itself at 5712 × 4284 and smaller ones. Next to them are a few other items, and one of type Exif.
So an iPhone doesn’t store a photo as one big picture. It cuts it into tiles that can be decoded in parallel, and a grid item says how they fit together. For reading metadata, that’s good news: I can ignore all the tiles and look for the single Exif item.
Finding the Exif item: iinf and iloc
Two boxes inside meta lead to the EXIF bytes.
iinf (item info) has one infe entry per item, with the item’s ID and its 4-letter type. Loop over them and remember the ID of the one typed Exif:
const iinfVersion = b[iinf.body]!
let exifId = -1
for (const infe of boxes(b, view, iinf.body + 4 + (iinfVersion === 0 ? 2 : 4), iinf.end)) {
const v = b[infe.body]!
if (infe.type !== 'infe' || v < 2) continue
const idSize = v === 2 ? 2 : 4
const id = idSize === 2 ? view.getUint16(infe.body + 4) : view.getUint32(infe.body + 4)
if (ascii(b, infe.body + 4 + idSize + 2, 4) === 'Exif') { exifId = id; break }
}
The + 4 after each box start skips its version and flags. iinf and infe are “full boxes”, which start with 1 byte of version and 3 bytes of flags. The version changes the layout: a version 0 iinf stores its item count in 2 bytes and later versions in 4, and a version 2 infe has 2-byte IDs where version 3 has 4-byte ones. Most bugs in a parser like this are a field read with the wrong width.
iloc (item location) says where each item’s bytes are in the file. It’s compact and a little unusual. Its header gives the size of its own fields in 4-bit nibbles: how many bytes an offset takes, how many a length takes, and so on. The iPhone file uses 4-byte offsets and lengths. Then, for each item, it lists one or more extents, each an offset and a length into the file. Find the entry with the Exif item’s ID, and its extent points into mdat.
From the Exif item to the GPS coordinates
The Exif item starts with 4 bytes that say how far ahead the TIFF header is. That’s usually 6, to skip the text Exif\0\0:
const skip = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0)
return data.subarray(4 + skip)
From there it’s the same EXIF as in a JPEG, and EXIF is a small TIFF file:
- 2 bytes for the byte order:
II(Intel, little-endian) orMM(Motorola, big-endian); - the number 42, then the offset of the first directory (IFD0);
- directories made of 12-byte entries: a tag, a type, a count, and either the value or an offset to it.
IFD0 has the camera make and model. Tag 0x8769 points to the Exif directory with the shutter speed, aperture and the date taken. Tag 0x8825 points to the GPS directory. There the latitude and longitude are each three rational numbers (degrees, minutes, seconds, each a fraction), with N/S and E/W in separate tags. The viewer turns them into decimal coordinates and also reads the altitude and the direction the camera was facing.
Making it safe
Files from the internet are untrusted, and a parser that crashes or hangs on a bad file is a bug. A few rules kept this one safe:
- Every read checks that it’s inside the file, and a box that claims to be bigger than its parent stops the walk.
- Directory pointers are remembered, so a damaged file whose IFD points back to itself can’t make the reader loop forever.
- Anything unexpected returns “no EXIF found” instead of throwing.
The tests build a small HEIC file in memory, with ftyp, meta, iinf, iloc and mdat laid out by hand, and check that the viewer finds the location in it, and that a truncated copy fails cleanly. I also ran it on real photos from an iPhone 11, 13 Pro and 16.
Why not just use a library
ToolOnTap already loads heic2any to convert HEIC to JPG, and it’s about 1.3 MB because it includes a full HEVC decoder. Reading the metadata takes about 75 lines and no download at all, because it never touches the pixels. The EXIF viewer shows what a photo reveals, and Remove EXIF takes it out before you share it.