Phone photo -> composited postcard (canvas templates, stamp corner, EXIF-aware date/location, self-hosted handwriting font) with a small server for share links, day-context caption drafting off goonk's day-summary API, and Nominatim reverse geocoding. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
118 lines
4.6 KiB
JavaScript
118 lines
4.6 KiB
JavaScript
// 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;
|
|
}
|
|
},
|
|
};
|
|
})();
|