// Pure EXIF GPS helpers — tolerant of the varied shapes EXIF parsers return: // numbers (37.7749), arrays ([d, m, s]), rational strings ("37/1", "46/1", "26964/1000") // and arrays of rational strings. export function parseRational(value: unknown): number | null { if (typeof value === 'number') { return Number.isFinite(value) ? value : null; } if (typeof value === 'string') { const parts = value.trim().split('/'); if (parts.length === 2) { const num = Number(parts[0]); const den = Number(parts[1]); if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) return num / den; } const n = Number(value); return Number.isFinite(n) ? n : null; } return null; } function toDecimal(value: unknown, ref: string): number | null { // value: single decimal (number|string) OR array [deg, min, sec] if (Array.isArray(value)) { const d = parseRational(value[0]); const m = parseRational(value[1]); const s = value.length > 2 ? parseRational(value[2]) : 0; if (d === null || m === null || s === null || d < 0 || m < 0 || s < 0) return null; return d + m / 60 + s / 3600; } const n = parseRational(value); return n === null || n < 0 ? null : n; } export function parseExifCoordinate( raw: unknown, ref: unknown ): number | null { const absolute = toDecimal(raw, String(ref)); if (absolute === null || absolute === 0) return null; // ignore 0,0 placeholders const negative = ref === 'S' || ref === 'W'; return negative ? -absolute : absolute; } export interface ExifLike { GPSLatitude?: unknown; GPSLatitudeRef?: unknown; GPSLongitude?: unknown; GPSLongitudeRef?: unknown; GPSDateStamp?: unknown; GPSTimeStamp?: unknown; DateTimeOriginal?: unknown; } export function parseExifGPS(exif: ExifLike | null | undefined): { latitude: number; longitude: number } | null { if (!exif) return null; const lat = parseExifCoordinate(exif.GPSLatitude, exif.GPSLatitudeRef); const lon = parseExifCoordinate(exif.GPSLongitude, exif.GPSLongitudeRef); if (lat === null || lon === null) return null; if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; // invalid ranges if (lat === 0 && lon === 0) return null; return { latitude: lat, longitude: lon }; } const parseTimeStamp = (value: unknown): [number, number, number] | null => { if (!Array.isArray(value)) return null; const h = parseRational(value[0]); const m = parseRational(value[1]); const s = value.length > 2 ? parseRational(value[2]) : 0; if (h === null || m === null || s === null) return null; if (h > 23 || m > 59 || s > 61) return null; return [h, m, s]; }; // EXIF stores dates as "2023:01:02 15:04:05" — colons, not dashes. export function parseExifTimestamp(exif: ExifLike | null | undefined): number | null { if (!exif) return null; const raw = typeof exif.DateTimeOriginal === 'string' ? exif.DateTimeOriginal : ''; let dateText = raw.trim(); if (!dateText && typeof exif.GPSDateStamp === 'string') { const gpsDate = (exif.GPSDateStamp as string).trim(); const gpsTime = parseTimeStamp(exif.GPSTimeStamp); if (gpsTime) { dateText = `${gpsDate} ${gpsTime[0]}:${gpsTime[1]}:${gpsTime[2]}`; } else if (gpsDate) { dateText = `${gpsDate} 00:00:00`; } } if (!dateText) return null; // Normalize "2023:1:2 15:4:5" (any of : / - separators, unpadded parts) // into an ISO string "2023-01-02T15:04:05" before parsing. const m = dateText.match(/^(\d{4})[:/-](\d{1,2})[:/-](\d{1,2})(?:[ T]+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/); if (!m) return null; const two = (v: string) => v.padStart(2, '0'); const [, y, mo, d, h = '0', mi = '0', s = '0'] = m; const time = Date.parse(`${y}-${two(mo)}-${two(d)}T${two(h)}:${two(mi)}:${two(s)}`); return Number.isFinite(time) ? time : null; }