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');