// Minimal EXIF reader — just enough to pull DateTimeOriginal and GPS lat/lon // out of a JPEG, with no dependency (this project's "no build step" family // doesn't pull in npm packages for the client). Not a general-purpose EXIF // library: unsupported/malformed data is treated as "nothing found," never // thrown, since the caller always has upload-date/no-location fallbacks. window.PostcardExif = (() => { const TYPE_SIZES = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 }; function readValue(view, type, count, offset, littleEndian) { // Only the types actually used by the tags we read (ASCII, SHORT, // LONG, RATIONAL) — anything else returns null rather than guessing. if (type === 2) { // ASCII let s = ''; for (let i = 0; i < count - 1; i++) s += String.fromCharCode(view.getUint8(offset + i)); return s; } if (type === 3) return view.getUint16(offset, littleEndian); // SHORT if (type === 4) return view.getUint32(offset, littleEndian); // LONG if (type === 5) { // RATIONAL const num = view.getUint32(offset, littleEndian); const den = view.getUint32(offset + 4, littleEndian); return den === 0 ? 0 : num / den; } return null; } function readIfd(view, tiffStart, ifdOffset, littleEndian) { const entries = {}; const count = view.getUint16(tiffStart + ifdOffset, littleEndian); for (let i = 0; i < count; i++) { const entryOffset = tiffStart + ifdOffset + 2 + i * 12; const tag = view.getUint16(entryOffset, littleEndian); const type = view.getUint16(entryOffset + 2, littleEndian); const valueCount = view.getUint32(entryOffset + 4, littleEndian); const size = (TYPE_SIZES[type] || 1) * valueCount; const valueOffset = size > 4 ? tiffStart + view.getUint32(entryOffset + 8, littleEndian) : entryOffset + 8; if (type === 5 && valueCount === 3) { // GPSLatitude/Longitude: [degrees, minutes, seconds] rationals. entries[tag] = [0, 1, 2].map(i => readValue(view, type, 2, valueOffset + i * 8, littleEndian)); } else { entries[tag] = readValue(view, type, valueCount, valueOffset, littleEndian); } } return entries; } function dmsToDecimal(dms, ref) { if (!Array.isArray(dms) || dms.length !== 3) return null; const [d, m, s] = dms; let dec = d + m / 60 + s / 3600; if (ref === 'S' || ref === 'W') dec = -dec; return dec; } function parseDateTimeOriginal(str) { // "YYYY:MM:DD HH:MM:SS" — EXIF's own format, always local time with no // timezone info, so this is treated as a plain calendar date. const m = /^(\d{4}):(\d{2}):(\d{2})/.exec(str || ''); if (!m) return null; return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); } function parse(buffer) { const view = new DataView(buffer); if (view.getUint16(0) !== 0xffd8) return null; // not a JPEG let offset = 2; while (offset < view.byteLength - 4) { const marker = view.getUint16(offset); if ((marker & 0xff00) !== 0xff00) break; // not a valid marker, bail out if (marker === 0xffd8) { offset += 2; continue; } if (marker === 0xffda || marker === 0xffd9) break; // start of scan / EOI — no more APPn markers past this const segLength = view.getUint16(offset + 2); if (marker === 0xffe1) { const exifStart = offset + 4; if (view.getUint32(exifStart) === 0x45786966) { // "Exif" const tiffStart = exifStart + 6; const littleEndian = view.getUint16(tiffStart) === 0x4949; const ifd0Offset = view.getUint32(tiffStart + 4, littleEndian); const ifd0 = readIfd(view, tiffStart, ifd0Offset, littleEndian); let date = null; let lat = null, lon = null; if (ifd0[0x8769] != null) { const exifIfd = readIfd(view, tiffStart, ifd0[0x8769], littleEndian); date = parseDateTimeOriginal(exifIfd[0x9003]); } if (ifd0[0x8825] != null) { const gpsIfd = readIfd(view, tiffStart, ifd0[0x8825], littleEndian); lat = dmsToDecimal(gpsIfd[0x0002], gpsIfd[0x0001]); lon = dmsToDecimal(gpsIfd[0x0004], gpsIfd[0x0003]); } return { date, lat, lon }; } } offset += 2 + segLength; } return null; } return { // Returns { date: Date|null, lat: number|null, lon: number|null }, or // null on any parse failure — never throws. async read(file) { try { const buffer = await file.arrayBuffer(); return parse(buffer); } catch { return null; } }, }; })();