// EXIF in an original's bytes, read with exifreader, which reads it from JPEG, // HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports. // `backup-metadata --exif` records every EXIF tag it finds except the // thumbnail's. `Photo.exif()` returns every tag, the thumbnail's included, and // `Photo`'s typed methods return the common fields picked from them here. import ExifReader, { type ExpandedTags } from "exifreader"; // The EXIF tags in `bytes` (`exif`), the embedded thumbnail's tags // (`Thumbnail`), and where the EXIF block lies in `bytes` (`metadataRange`). // Undefined when exifreader cannot read the file at all, such as a video. An // EXIF block it finds but reads no tag from comes back as an empty `exif`. A // tag exifreader has no name for is keyed `undefined-`. Each tag's // `computed` holds its value as a string or number, or as an array of them for // a tag with several values, such as `GPSLatitude`'s `[40, 26, 46]`. A // fraction with a zero denominator computes to null. export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { try { return ExifReader.loadView( new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), { expanded: true, computed: true, includeOffsets: true, includeUnknown: true, includeTags: { exif: true, thumbnail: true }, }, ); } catch { return undefined; } }; // Every EXIF tag of an original, keyed by name, each as exifreader decodes it // (see `readExifTags`). The embedded thumbnail's own tags are under // `Thumbnail`, so its `Orientation` or `ImageWidth` cannot hide the main // image's. export type ExifTags = Omit, "Thumbnail"> & { Thumbnail?: Omit< NonNullable, "type" | "image" | "base64" >; }; // Every EXIF tag in `bytes`: `{}` when the file has no EXIF, exifreader cannot // read its EXIF, or it is not an image exifreader reads. export const readAllExifTags = (bytes: Uint8Array): ExifTags => { const tags = readExifTags(bytes); if (!tags?.Thumbnail) return tags?.exif ?? {}; // exifreader puts the thumbnail's JPEG image beside its tags, as `type`, // `image` and `base64`. The image is not a tag, so it is left out. const { type: _type, image: _image, base64: _base64, ...thumbnail } = tags.Thumbnail; return { ...tags.exif, Thumbnail: thumbnail }; }; // The common EXIF fields of an original, one for each of `Photo`'s typed // methods. Each is absent when the file lacks it. export interface PhotoExif { make?: string; model?: string; lensModel?: string; // When the photo was taken, by the camera's clock. EXIF writes this as text // with no time zone, and it is read as if it were UTC: the Date's UTC // fields are the clock reading, which is the moment it was taken only when // the clock was set to UTC. dateTimeOriginal?: Date; // The camera clock's offset from UTC, such as "+02:00". offsetTimeOriginal?: string; // Seconds. exposureTime?: number; fNumber?: number; iso?: number; // Millimetres. focalLength?: number; // The EXIF orientation code, 1 to 8. orientation?: number; // Decimal degrees, negative south of the equator and west of Greenwich. gpsLatitude?: number; gpsLongitude?: number; // Metres, negative below sea level. gpsAltitude?: number; } // exifreader gives "" for a tag whose value lies outside the // file; that tag is left out like one the file lacks. const asString = (v: unknown): string | undefined => typeof v === "string" && v.length > 0 && v !== "" ? v : undefined; const asNumber = (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : undefined; // EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading // in a Date's UTC fields. const asDate = (v: unknown): Date | undefined => { const m = typeof v === "string" ? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v) : null; if (!m) return undefined; const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`); return Number.isNaN(date.getTime()) ? undefined : date; }; // GPSLatitude and GPSLongitude hold degrees, minutes and seconds, computed as // three numbers. This is them in decimal degrees, negative when `ref`, the // GPSLatitudeRef or GPSLongitudeRef tag, is `negativeRef` ("S" or "W"). // Without that tag the hemisphere is unknown, so it is undefined. const asDegrees = ( dms: unknown, ref: unknown, negativeRef: string, ): number | undefined => { if (!Array.isArray(dms) || ref === undefined) return undefined; const [d, m, s] = dms.map(asNumber); if (d === undefined || m === undefined || s === undefined) return undefined; const degrees = d + m / 60 + s / 3600; return ref === negativeRef ? -degrees : degrees; }; // The common fields picked from an original's EXIF tags, `readAllExifTags`'s // result: `{}` when there are none. export const readPhotoExif = (tags: ExifTags): PhotoExif => { const altitude = asNumber(tags.GPSAltitude?.computed); const fields: PhotoExif = { make: asString(tags.Make?.computed), model: asString(tags.Model?.computed), lensModel: asString(tags.LensModel?.computed), dateTimeOriginal: asDate(tags.DateTimeOriginal?.computed), offsetTimeOriginal: asString(tags.OffsetTimeOriginal?.computed), exposureTime: asNumber(tags.ExposureTime?.computed), fNumber: asNumber(tags.FNumber?.computed), // Only when the tag holds a single number, as most cameras write it. iso: asNumber(tags.ISOSpeedRatings?.computed), focalLength: asNumber(tags.FocalLength?.computed), orientation: asNumber(tags.Orientation?.computed), gpsLatitude: asDegrees( tags.GPSLatitude?.computed, tags.GPSLatitudeRef?.computed, "S", ), gpsLongitude: asDegrees( tags.GPSLongitude?.computed, tags.GPSLongitudeRef?.computed, "W", ), // A GPSAltitudeRef of 1 means the altitude is below sea level. gpsAltitude: altitude !== undefined && tags.GPSAltitudeRef?.value === 1 ? -altitude : altitude, }; // Leave out what the file lacks, so a missing field is absent rather than // present and undefined. return Object.fromEntries( Object.entries(fields).filter(([, v]) => v !== undefined), ) as PhotoExif; };