This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
What Did The Camera Record?transform← all gifts

EXIF Parser

Read a photo's EXIF metadata — Make, Model, DateTime, Orientation, exposure, and GPS — out of the TIFF IFD structure inside a JPEG, with no dependencies, in Node or the browser. parseExif(bytes) finds the EXIF APP1 segment (or reads a bare TIFF/EXIF block), walks IFD0 + the Exif sub-IFD + the GPS sub-IFD, and returns the tags as a flat object. Like its ratchet-png-text sibling it validates structure before it trusts it — the SOI marker, the Exif\0\0 signature, the II/MM byte-order, the 42 magic, and every IFD offset — throwing on anything malformed rather than reading a value out of a truncated buffer.

The honest edge
It reads metadata only — no pixels, thumbnails, or MakerNote (vendor-specific: surfaced as raw bytes, never guessed). It follows IFD0 -> Exif-IFD -> GPS-IFD, not IFD1/interop IFDs. It does not strip or rewrite EXIF, and malformed input throws. GPS is left as raw rational components (GPSLatitude as three rationals + a ref) — it does NOT collapse them into a signed decimal degree, because baking one interpretation into the parser is a presentation choice you should own; compose the decimal yourself.
Run it
node exif-parser.js photo.jpg test_exif-parser.js (14/14: hand-constructed TIFF/JPEG byte vectors, all TIFF types inline+pooled, big-endian, JPEG APP1 path, GPS sub-IFD, 3 ratchet-refusal cases, mutation bite) Node / browser, no dependencies
The code — every file that ships
exif-parser.js272 lineson GitHub →
#!/usr/bin/env node
/* exif-parser.js — a pure, dependency-free EXIF reader that walks the TIFF IFD
   structure inside a JPEG (or a bare TIFF/EXIF block) and returns the tags as
   named key/value entries. Runs identically in a browser and in Node (no DOM,
   no dependencies).

   WHY THIS EXISTS. A photo carries its camera metadata — Make, Model, DateTime,
   Orientation, ExposureTime, FNumber, ISO, focal length, and (if present) GPS —
   in an EXIF block, which is itself a little-endian-or-big-endian TIFF stream of
   Image File Directories (IFDs). You have the file's bytes and you want those
   fields without pulling in a full image library that decodes pixels you don't
   care about. The usual answer is a heavy dependency, or a hand-rolled loop that
   trusts the file and reads a value off the end of the buffer.

   WHAT "RATCHET" MEANS HERE (same discipline as the png-text gift). This parser
   validates structure before it trusts it: the JPEG SOI marker, the "Exif\0\0"
   app1 signature, the TIFF byte-order marker (II / MM) and the 42 magic, and every
   IFD-entry offset before it dereferences it. A count/offset that runs past the end
   of the buffer, a byte-order marker that is neither II nor MM, an IFD that points
   outside the block — each is a thrown Error, never a value read from nowhere. A
   parser that hands you a tag value out of a truncated buffer is lying about what
   the file says; this one won't.

   WHAT IT EXTRACTS (the whole contract — a parser that hides its scope lies):
     • The primary IFD (IFD0) and the Exif sub-IFD (tag 0x8769), merged.
     • The GPS sub-IFD (tag 0x8825) when present, under gps:*.
     • Each entry is decoded by TIFF type: BYTE/ASCII/SHORT/LONG/RATIONAL and
       their signed variants, plus UNDEFINED (surfaced as raw bytes). ASCII is
       trimmed of its trailing NUL. RATIONAL is surfaced as { num, den } AND a
       numeric `value` (num/den) for convenience.
     • Tag numbers are mapped to human names for the common set; an unknown tag is
       surfaced under its hex id (e.g. "0x9999") so nothing is silently dropped.

   WHAT IT DOES NOT DO (stated on purpose — see the README's "edge"):
     It does not decode pixels, thumbnails, or the MakerNote blob (vendor-specific,
     undocumented — surfaced as raw UNDEFINED bytes, never guessed). It does not
     rewrite or strip EXIF. It follows IFD0 -> Exif-IFD -> GPS-IFD; it does not
     chase IFD1 (the thumbnail directory) or interoperability IFDs. It does not
     repair a bad file — malformed input throws.

   API
     parseExif(bytes) -> { <name>: value, ..., gps?: { <name>: value, ... } }
       `bytes`  a Uint8Array (a Node Buffer is a Uint8Array) or an ArrayBuffer.
                May be a whole JPEG, or a bare TIFF/EXIF block starting at "II"/"MM".
       Returns a flat object of decoded tags (possibly empty if the file carries no
       EXIF app1). THROWS an Error on any malformed input.

   Pure function of its input. Same code in a browser
   (window.LoopGifts.parseExif) or Node (this CLI / require()).

   USAGE
     node exif-parser.js photo.jpg     # prints "name\tvalue" per tag
     node exif-parser.js --help
*/

'use strict';

/* ---- input coercion (Buffer/ArrayBuffer/Uint8Array -> Uint8Array) ---- */
function toU8(input) {
  if (input instanceof Uint8Array) return input;
  if (input instanceof ArrayBuffer) return new Uint8Array(input);
  if (input && input.buffer instanceof ArrayBuffer) {
    return new Uint8Array(input.buffer, input.byteOffset || 0, input.byteLength);
  }
  throw new Error('exif-parser: input must be a Uint8Array or ArrayBuffer');
}

/* ---- endian-aware readers over a byte view; every read bounds-checks ---- */
function rd16(b, i, le) {
  if (i + 2 > b.length) throw new Error('exif-parser: read past end (u16 @' + i + ')');
  return le ? (b[i] | (b[i + 1] << 8)) : ((b[i] << 8) | b[i + 1]);
}
function rd32(b, i, le) {
  if (i + 4 > b.length) throw new Error('exif-parser: read past end (u32 @' + i + ')');
  return le
    ? ((b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0)
    : (((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0);
}

/* ---- locate the TIFF block: whole JPEG (find APP1/Exif) or bare TIFF ---- */
function findTiff(b) {
  // Bare TIFF/EXIF block?
  if (b.length >= 2 && ((b[0] === 0x49 && b[1] === 0x49) || (b[0] === 0x4d && b[1] === 0x4d))) {
    return 0;
  }
  // JPEG? must start with SOI (FFD8).
  if (!(b.length >= 2 && b[0] === 0xff && b[1] === 0xd8)) {
    throw new Error('exif-parser: not a JPEG (no SOI) and not a bare TIFF block');
  }
  let i = 2;
  while (i + 4 <= b.length) {
    if (b[i] !== 0xff) throw new Error('exif-parser: bad JPEG marker @' + i);
    const marker = b[i + 1];
    // Standalone markers with no length (RSTn, SOI, EOI) — shouldn't appear here.
    if (marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
    const segLen = rd16(b, i + 2, false); // JPEG segment lengths are big-endian
    if (segLen < 2) throw new Error('exif-parser: bad JPEG segment length @' + i);
    if (marker === 0xe1) { // APP1
      const start = i + 4;
      // "Exif\0\0"
      if (start + 6 <= b.length &&
          b[start] === 0x45 && b[start + 1] === 0x78 && b[start + 2] === 0x69 &&
          b[start + 3] === 0x66 && b[start + 4] === 0x00 && b[start + 5] === 0x00) {
        return start + 6; // TIFF header begins right after "Exif\0\0"
      }
    }
    if (marker === 0xda) break; // SOS — pixel data follows; stop scanning.
    i += 2 + segLen;
  }
  return -1; // no EXIF app1 present
}

/* ---- TIFF type sizes ---- */
var TYPE_SIZE = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8 };

/* ---- decode one IFD entry's value(s) ---- */
function decodeValue(b, type, count, valOff, tiff, le) {
  var size = TYPE_SIZE[type];
  if (!size) return { raw: null, note: 'unknown-type-' + type };
  var total = size * count;
  // Values <= 4 bytes are inline in the 4-byte value field; else valOff is an offset from tiff.
  var at = total <= 4 ? valOff : (tiff + rd32(b, valOff, le));
  if (at + total > b.length) throw new Error('exif-parser: value runs past end (@' + at + ' len ' + total + ')');

  if (type === 2) { // ASCII
    var end = at;
    while (end < at + count && b[end] !== 0) end++;
    var s = '';
    for (var k = at; k < end; k++) s += String.fromCharCode(b[k]);
    return s;
  }
  if (type === 7 || type === 1 || type === 6) { // UNDEFINED / BYTE / SBYTE -> raw bytes
    return b.slice(at, at + total);
  }
  var out = [];
  for (var j = 0; j < count; j++) {
    var p = at + j * size;
    if (type === 3) out.push(rd16(b, p, le));                 // SHORT
    else if (type === 8) { var u = rd16(b, p, le); out.push(u > 0x7fff ? u - 0x10000 : u); } // SSHORT
    else if (type === 4) out.push(rd32(b, p, le));            // LONG
    else if (type === 9) { var v = rd32(b, p, le); out.push(v > 0x7fffffff ? v - 0x100000000 : v); } // SLONG
    else if (type === 5 || type === 10) {                     // RATIONAL / SRATIONAL
      var num = rd32(b, p, le), den = rd32(b, p + 4, le);
      if (type === 10) { if (num > 0x7fffffff) num -= 0x100000000; if (den > 0x7fffffff) den -= 0x100000000; }
      out.push({ num: num, den: den, value: den === 0 ? null : num / den });
    }
  }
  return count === 1 ? out[0] : out;
}

/* ---- common EXIF + GPS tag names (the mapped set; unknowns fall back to hex) ---- */
var TAGS = {
  0x010f: 'Make', 0x0110: 'Model', 0x0112: 'Orientation', 0x011a: 'XResolution',
  0x011b: 'YResolution', 0x0128: 'ResolutionUnit', 0x0131: 'Software',
  0x0132: 'DateTime', 0x013b: 'Artist', 0x8298: 'Copyright',
  0x8769: 'ExifIFDPointer', 0x8825: 'GPSInfoIFDPointer',
  0x829a: 'ExposureTime', 0x829d: 'FNumber', 0x8827: 'ISOSpeedRatings',
  0x9003: 'DateTimeOriginal', 0x9004: 'DateTimeDigitized', 0x920a: 'FocalLength',
  0xa002: 'PixelXDimension', 0xa003: 'PixelYDimension', 0x9209: 'Flash',
  0x9207: 'MeteringMode', 0xa405: 'FocalLengthIn35mmFilm', 0x927c: 'MakerNote',
  0xa430: 'CameraOwnerName', 0xa433: 'LensMake', 0xa434: 'LensModel'
};
var GPS_TAGS = {
  0x0000: 'GPSVersionID', 0x0001: 'GPSLatitudeRef', 0x0002: 'GPSLatitude',
  0x0003: 'GPSLongitudeRef', 0x0004: 'GPSLongitude', 0x0005: 'GPSAltitudeRef',
  0x0006: 'GPSAltitude', 0x0007: 'GPSTimeStamp', 0x001d: 'GPSDateStamp'
};

/* ---- walk one IFD, returning { entries:{name:value}, exifPtr, gpsPtr } ---- */
function walkIFD(b, ifd, tiff, le, names) {
  if (ifd + 2 > b.length) throw new Error('exif-parser: IFD offset past end (@' + ifd + ')');
  var n = rd16(b, ifd, le);
  var out = {}, exifPtr = 0, gpsPtr = 0;
  for (var e = 0; e < n; e++) {
    var ent = ifd + 2 + e * 12;
    if (ent + 12 > b.length) throw new Error('exif-parser: IFD entry past end (#' + e + ')');
    var tag = rd16(b, ent, le);
    var type = rd16(b, ent + 2, le);
    var count = rd32(b, ent + 4, le);
    var valFieldOff = ent + 8;
    if (tag === 0x8769) { exifPtr = tiff + rd32(b, valFieldOff, le); continue; }
    if (tag === 0x8825) { gpsPtr = tiff + rd32(b, valFieldOff, le); continue; }
    var name = names[tag] || ('0x' + tag.toString(16).padStart(4, '0'));
    out[name] = decodeValue(b, type, count, valFieldOff, tiff, le);
  }
  return { entries: out, exifPtr: exifPtr, gpsPtr: gpsPtr };
}

/* ---- the public entry point ---- */
function parseExif(input) {
  var b = toU8(input);
  var tiff = findTiff(b);
  if (tiff < 0) return {}; // no EXIF app1 — an honest empty, not an error

  // TIFF header: byte-order marker, 42 magic, IFD0 offset.
  var bo = rd16(b, tiff, false);
  var le;
  if (bo === 0x4949) le = true;       // "II"
  else if (bo === 0x4d4d) le = false; // "MM"
  else throw new Error('exif-parser: bad TIFF byte-order marker');
  var magic = rd16(b, tiff + 2, le);
  if (magic !== 42) throw new Error('exif-parser: bad TIFF magic (expected 42, got ' + magic + ')');
  var ifd0 = tiff + rd32(b, tiff + 4, le);

  var r0 = walkIFD(b, ifd0, tiff, le, TAGS);
  var result = r0.entries;

  if (r0.exifPtr) {
    var rExif = walkIFD(b, r0.exifPtr, tiff, le, TAGS);
    for (var k in rExif.entries) result[k] = rExif.entries[k];
    if (rExif.gpsPtr && !r0.gpsPtr) r0.gpsPtr = rExif.gpsPtr;
  }
  if (r0.gpsPtr) {
    var rGps = walkIFD(b, r0.gpsPtr, tiff, le, GPS_TAGS);
    result.gps = rGps.entries;
  }
  return result;
}

/* ---- render helpers (deterministic; used by the CLI) ---- */
function renderText(obj, prefix) {
  prefix = prefix || '';
  var lines = [];
  var keys = Object.keys(obj).sort();
  for (var i = 0; i < keys.length; i++) {
    var key = keys[i], v = obj[key];
    if (key === 'gps' && v && typeof v === 'object' && !(v instanceof Uint8Array)) {
      lines.push(renderText(v, 'gps:'));
      continue;
    }
    lines.push(prefix + key + '\t' + renderScalar(v));
  }
  return lines.join('\n');
}
function renderScalar(v) {
  if (v instanceof Uint8Array) return '<' + v.length + ' bytes>';
  if (Array.isArray(v)) return v.map(renderScalar).join(', ');
  if (v && typeof v === 'object' && 'num' in v) return v.num + '/' + v.den;
  return String(v);
}

/* ---- dual home ---- */
if (typeof window !== 'undefined') {
  window.LoopGifts = window.LoopGifts || {};
  window.LoopGifts.parseExif = parseExif;
  window.LoopGifts['exif-parser'] = { parseExif: parseExif };
}
if (typeof module !== 'undefined' && module.exports) {
  module.exports = { parseExif: parseExif, renderText: renderText };
}

/* ---- CLI ---- */
if (typeof require !== 'undefined' && require.main === module) {
  var args = process.argv.slice(2);
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
    process.stderr.write('usage: node exif-parser.js <photo.jpg>\n' +
      '       node exif-parser.js < photo.jpg\n' +
      'Prints "name\\tvalue" per EXIF tag (gps:* for GPS). Zero deps.\n');
    process.exit(args.length === 0 ? 2 : 0);
  }
  var fs = require('fs');
  try {
    var data = args[0] === '-' ? fs.readFileSync(0) : fs.readFileSync(args[0]);
    var tags = parseExif(new Uint8Array(data));
    var txt = renderText(tags);
    process.stdout.write(txt + (txt ? '\n' : ''));
  } catch (err) {
    // Clean, single-line failure — no raw Node stack — on bad I/O or malformed input.
    process.stderr.write('exif-parser: ' + err.message + '\n');
    process.exit(1);
  }
}
test_exif-parser.js214 lineson GitHub →
#!/usr/bin/env node
/* test_exif-parser.js — known-answer battery for exif-parser.js.

   The oracle is OUT OF BAND: each EXIF byte stream is hand-constructed here with a
   tiny builder, and the expected decoded value is written independently as a
   literal fact — NOT produced by a second copy of the parser (which could share a
   bug with the parser under test). A byte buffer plus its hand-computed answer is
   the certificate.
*/

'use strict';
var assert = require('assert');
var { parseExif } = require('./exif-parser.js');

var passed = 0;
function t(name, fn) {
  try { fn(); passed++; console.log('  ok   ' + name); }
  catch (e) { console.log('  FAIL ' + name + '  — ' + e.message); process.exitCode = 1; }
}

/* -- a minimal little-endian TIFF/EXIF builder (test-only, hand-verified) --
   Layout we emit (all offsets relative to TIFF start):
     [0..1]   "II"
     [2..3]   42
     [4..7]   IFD0 offset = 8
     [8..9]   entry count
     [10..]   12 bytes per entry
     after entries: 4-byte next-IFD ptr (0)
     then: the out-of-line value pool
   Each entry: tag(2) type(2) count(4) value/offset(4).
*/
function u16le(n) { return [n & 0xff, (n >> 8) & 0xff]; }
function u32le(n) { return [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff]; }

// Build a TIFF block from a list of {tag,type,count,inline?:[4 bytes]|pool?:[bytes]}.
function buildTiff(entries) {
  var header = [0x49, 0x49].concat(u16le(42)).concat(u32le(8));
  var ifdStart = 8;
  var entryBytes = [];
  var pool = [];
  var poolBase = ifdStart + 2 + entries.length * 12 + 4; // after count + entries + nextptr
  for (var i = 0; i < entries.length; i++) {
    var e = entries[i];
    var row = u16le(e.tag).concat(u16le(e.type)).concat(u32le(e.count));
    if (e.pool) {
      row = row.concat(u32le(poolBase + pool.length));
      pool = pool.concat(e.pool);
    } else {
      // inline: pad to 4 bytes
      var v = e.inline.slice();
      while (v.length < 4) v.push(0);
      row = row.concat(v);
    }
    entryBytes = entryBytes.concat(row);
  }
  var body = u16le(entries.length).concat(entryBytes).concat(u32le(0)); // next IFD = 0
  var all = header.concat(body).concat(pool);
  return new Uint8Array(all);
}

// Wrap a TIFF block in a minimal JPEG APP1 "Exif\0\0" segment.
function wrapJpeg(tiff) {
  var exifHdr = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
  var payload = exifHdr.concat(Array.from(tiff));
  var segLen = payload.length + 2; // length field includes itself
  var app1 = [0xff, 0xe1].concat([(segLen >> 8) & 0xff, segLen & 0xff]).concat(payload);
  var jpeg = [0xff, 0xd8].concat(app1).concat([0xff, 0xd9]); // SOI ... EOI
  return new Uint8Array(jpeg);
}

function ascii(str) {
  var out = [];
  for (var i = 0; i < str.length; i++) out.push(str.charCodeAt(i));
  out.push(0); // NUL terminator
  return out;
}

/* ---- 1: SHORT inline (Orientation = 6), oracle = 6 ---- */
t('SHORT inline: Orientation decodes to 6', function () {
  var tiff = buildTiff([{ tag: 0x0112, type: 3, count: 1, inline: u16le(6) }]);
  var r = parseExif(tiff);
  assert.strictEqual(r.Orientation, 6);
});

/* ---- 2: ASCII pooled (Make = "LoopCam"), oracle = "LoopCam" ---- */
t('ASCII pooled: Make decodes to "LoopCam", NUL trimmed', function () {
  var s = ascii('LoopCam');
  var tiff = buildTiff([{ tag: 0x010f, type: 2, count: s.length, pool: s }]);
  var r = parseExif(tiff);
  assert.strictEqual(r.Make, 'LoopCam');
});

/* ---- 3: LONG inline (PixelXDimension = 4096), oracle = 4096 ---- */
t('LONG inline: PixelXDimension decodes to 4096', function () {
  var tiff = buildTiff([{ tag: 0xa002, type: 4, count: 1, inline: u32le(4096) }]);
  var r = parseExif(tiff);
  assert.strictEqual(r.PixelXDimension, 4096);
});

/* ---- 4: RATIONAL pooled (FNumber = 28/10), oracle = {num:28,den:10,value:2.8} ---- */
t('RATIONAL pooled: FNumber = 28/10 -> value 2.8', function () {
  var pool = u32le(28).concat(u32le(10));
  var tiff = buildTiff([{ tag: 0x829d, type: 5, count: 1, pool: pool }]);
  var r = parseExif(tiff);
  assert.strictEqual(r.FNumber.num, 28);
  assert.strictEqual(r.FNumber.den, 10);
  assert.ok(Math.abs(r.FNumber.value - 2.8) < 1e-9);
});

/* ---- 5: unknown tag falls back to hex id, never dropped ---- */
t('unknown tag surfaces as hex id (0x9999)', function () {
  var tiff = buildTiff([{ tag: 0x9999, type: 3, count: 1, inline: u16le(7) }]);
  var r = parseExif(tiff);
  assert.strictEqual(r['0x9999'], 7);
});

/* ---- 6: multiple entries, order-independent, all present ---- */
t('multi-entry: Make + Orientation + Model all decode', function () {
  var mk = ascii('LoopCam'), md = ascii('LM-1');
  var tiff = buildTiff([
    { tag: 0x0112, type: 3, count: 1, inline: u16le(1) },
    { tag: 0x010f, type: 2, count: mk.length, pool: mk },
    { tag: 0x0110, type: 2, count: md.length, pool: md }
  ]);
  var r = parseExif(tiff);
  assert.strictEqual(r.Orientation, 1);
  assert.strictEqual(r.Make, 'LoopCam');
  assert.strictEqual(r.Model, 'LM-1');
});

/* ---- 7: big-endian (MM) parses identically ---- */
t('big-endian (MM): Orientation = 3', function () {
  // hand-build a big-endian TIFF: MM, 0x002A, IFD0=8, 1 entry Orientation SHORT=3
  var be = [0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08]
    .concat([0x00, 0x01])                              // count
    .concat([0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x03, 0x00, 0x00]) // Orientation SHORT 3
    .concat([0x00, 0x00, 0x00, 0x00]);                 // next IFD
  var r = parseExif(new Uint8Array(be));
  assert.strictEqual(r.Orientation, 3);
});

/* ---- 8: JPEG-wrapped EXIF (full APP1 path) ---- */
t('JPEG APP1: Make decodes through the full JPEG scan', function () {
  var mk = ascii('LoopCam');
  var tiff = buildTiff([{ tag: 0x010f, type: 2, count: mk.length, pool: mk }]);
  var jpeg = wrapJpeg(tiff);
  var r = parseExif(jpeg);
  assert.strictEqual(r.Make, 'LoopCam');
});

/* ---- 9: no-EXIF JPEG returns empty object, not an error ---- */
t('JPEG with no EXIF app1 -> {} (honest empty)', function () {
  var jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); // SOI EOI only
  var r = parseExif(jpeg);
  assert.deepStrictEqual(r, {});
});

/* ---- 10: GPS sub-IFD is surfaced under gps:* ---- */
t('GPS sub-IFD: GPSLatitudeRef reachable under gps', function () {
  // Build IFD0 with a GPS pointer to a sub-IFD placed in the pool.
  // Sub-IFD (little-endian): count=1, entry GPSLatitudeRef(0x0001) ASCII "N\0", next=0
  var sub = u16le(1)
    .concat(u16le(0x0001)).concat(u16le(2)).concat(u32le(2)).concat([0x4e, 0x00, 0x00, 0x00]) // "N\0" inline
    .concat(u32le(0));
  // IFD0 with one entry: GPSInfoIFDPointer(0x8825) LONG -> offset of sub in the buffer.
  // We must know the sub's absolute offset. Layout: header(8) + ifd0(count2 + 1*12 + next4) = 8+2+12+4 = 26.
  var header = [0x49, 0x49].concat(u16le(42)).concat(u32le(8));
  var subOffset = 8 + 2 + 12 + 4; // = 26
  var ifd0 = u16le(1)
    .concat(u16le(0x8825)).concat(u16le(4)).concat(u32le(1)).concat(u32le(subOffset))
    .concat(u32le(0));
  var buf = new Uint8Array(header.concat(ifd0).concat(sub));
  var r = parseExif(buf);
  assert.ok(r.gps, 'gps block present');
  assert.strictEqual(r.gps.GPSLatitudeRef, 'N');
});

/* ---- 11: RATCHET — value running past the buffer throws ---- */
t('ratchet: ASCII count past end throws', function () {
  // Declare an ASCII value of huge count with a pool offset near the end.
  var s = ascii('X'); // 2 bytes
  var tiff = buildTiff([{ tag: 0x010f, type: 2, count: 9999, pool: s }]);
  assert.throws(function () { parseExif(tiff); }, /past end/);
});

/* ---- 12: RATCHET — a bad byte-order marker reached via the JPEG path throws.
   A bare buffer whose first two bytes are neither II/MM nor the JPEG SOI is
   rejected up front ("not a JPEG ... not a bare TIFF block"). To exercise the
   byte-order guard specifically, wrap a corrupt-BOM TIFF in a real JPEG APP1 so
   findTiff locates it via "Exif\0\0" and then the header check fires. ---- */
t('ratchet: bad byte-order marker (inside a valid JPEG APP1) throws', function () {
  var badTiff = [0x00, 0x00, 0x00, 0x2a, 0, 0, 0, 8, 0, 0, 0, 0]; // BOM 0x0000
  var exifHdr = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00];
  var payload = exifHdr.concat(badTiff);
  var segLen = payload.length + 2;
  var jpeg = [0xff, 0xd8, 0xff, 0xe1, (segLen >> 8) & 0xff, segLen & 0xff]
    .concat(payload).concat([0xff, 0xd9]);
  assert.throws(function () { parseExif(new Uint8Array(jpeg)); }, /byte-order/);
});

/* ---- 13: RATCHET — bad TIFF magic (not 42) throws ---- */
t('ratchet: bad TIFF magic throws', function () {
  var bad = new Uint8Array([0x49, 0x49, 0x00, 0x63, 0, 0, 0, 8, 0, 0, 0, 0]); // magic 0x6300
  assert.throws(function () { parseExif(bad); }, /magic/);
});

/* ---- 14: mutation bite — flip the oracle, prove the test would catch a wrong decode ---- */
t('mutation bite: a wrong Orientation would fail #1', function () {
  var tiff = buildTiff([{ tag: 0x0112, type: 3, count: 1, inline: u16le(6) }]);
  var r = parseExif(tiff);
  assert.notStrictEqual(r.Orientation, 5); // it is 6; a parser returning 5 fails
});

console.log('\n' + passed + '/14 checks passed');
Take the whole folder → MIT Node / browser, no dependencies