Read GPSAltitude without its reference tag; keep unnamed EXIF tags
check / check (push) Successful in 1m25s

exif() takes the altitude from the GPSAltitude tag and negates it only when
GPSAltitudeRef is 1, so a file with GPSAltitude and no GPSAltitudeRef keeps its
altitude, above sea level. A test covers that file.

backup-metadata --exif keeps the EXIF tags exifreader has no name for. The
comments in src/exif.ts say the thumbnail's tags are not kept and that a tag
with several values computes to an array.

Model: opus-5-5
This commit is contained in:
2026-10-01 19:35:52 +00:00
parent 9c0c7c9489
commit e623fd2015
2 changed files with 38 additions and 4 deletions
+14 -4
View File
@@ -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-<tag number>`. 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.
+24
View File
@@ -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",