Files

118 lines
4.6 KiB
JavaScript
Raw Permalink Normal View History

2026-08-06 19:49:03 +02:00
// 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;
}
},
};
})();