diff --git a/src/exif.ts b/src/exif.ts index 9d90df7..224631c 100644 --- a/src/exif.ts +++ b/src/exif.ts @@ -1,7 +1,7 @@ // 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; `Photo.exif()` -// returns the common fields picked from them here. +// `backup-metadata --exif` records every EXIF tag it finds except the +// thumbnail's; `Photo.exif()` returns the common fields picked from them here. import ExifReader, { type ExpandedTags } from "exifreader"; @@ -9,7 +9,11 @@ import ExifReader, { type ExpandedTags } from "exifreader"; // them (`gps`), 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`. -// Each tag's `computed` holds its value as a plain string or number. +// `exif` holds every tag except the thumbnail's; 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( @@ -18,6 +22,7 @@ export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { expanded: true, computed: true, includeOffsets: true, + includeUnknown: true, includeTags: { exif: true, gps: true }, }, ); @@ -77,6 +82,7 @@ export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { const tags = readExifTags(bytes); const exif = tags?.exif; const gps = tags?.gps; + const altitude = asNumber(exif?.GPSAltitude?.computed); const fields: PhotoExif = { make: asString(exif?.Make?.computed), model: asString(exif?.Model?.computed), @@ -91,7 +97,11 @@ export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { orientation: asNumber(exif?.Orientation?.computed), gpsLatitude: asNumber(gps?.Latitude), gpsLongitude: asNumber(gps?.Longitude), - gpsAltitude: asNumber(gps?.Altitude), + // A GPSAltitudeRef of 1 means the altitude is below sea level. + gpsAltitude: + altitude !== undefined && exif?.GPSAltitudeRef?.value === 1 + ? -altitude + : altitude, }; // Leave out what the file lacks, so a missing field is absent rather than // present and undefined. diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index 2deda57..2bdd533 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -46,6 +46,23 @@ const TIFF_UNSET_DATE = [ ...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56 ]; +// A big-endian TIFF block holding a GPSAltitude of 12.5 m and no +// GPSAltitudeRef. +const TIFF_ALTITUDE_WITHOUT_REF = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: one entry, then no next IFD. + ...[0x00, 0x01], + // The GPS IFD's offset (0x8825), LONG, 26. + ...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a], + ...[0x00, 0x00, 0x00, 0x00], + // The GPS IFD, at 26: one entry, then no next IFD. + ...[0x00, 0x01], + // GPSAltitude (0x0006), one RATIONAL at 44. + ...[0x00, 0x06, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2c], + ...[0x00, 0x00, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44 +]; + // An APP1 segment whose length field matches its data. const app1 = (data: number[]): number[] => { const len = data.length + 2; @@ -70,6 +87,13 @@ describe("readPhotoExif", () => { }); }); + it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => { + const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF]; + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + gpsAltitude: 12.5, + }); + }); + it.each([ [ "a file that is not an image",