exif() returns every EXIF tag in the file (closes #156)
check / check (push) Waiting to run

`photo.exif()` returns `ExifTags`: every EXIF tag exifreader reads from the file, keyed by tag name, each with exifreader's `id`, `value`, `description` and `computed`. The embedded thumbnail's tags are under `Thumbnail`, without the thumbnail image. A file with no EXIF, or a video, gives `{}`.

The thirteen typed methods stay. Each picks its field from the tags `exif()` returns through `readPhotoExif`, which now takes the tags instead of the bytes. GPS latitude and longitude are worked out from their tags and reference tags, since exifreader's computed position is not among the tags.

Also closes #148.

Model: opus-5-5
This commit is contained in:
2026-10-02 02:25:28 +00:00
committed by clawbot
parent 0c995a8c4f
commit 6dd22bcb73
8 changed files with 305 additions and 124 deletions
+80 -31
View File
@@ -1,19 +1,19 @@
// 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 the common fields picked from them here.
// 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 GPS position exifreader computes from
// them (`gps`), and where the EXIF block lies in `bytes` (`metadataRange`).
// 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`.
// `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.
// 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-<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(
@@ -23,7 +23,7 @@ export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
computed: true,
includeOffsets: true,
includeUnknown: true,
includeTags: { exif: true, gps: true },
includeTags: { exif: true, thumbnail: true },
},
);
} catch {
@@ -31,7 +31,35 @@ export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
}
};
// The common EXIF fields of an original. Each is absent when the file lacks it.
// 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<NonNullable<ExpandedTags["exif"]>, "Thumbnail"> & {
Thumbnail?: Omit<
NonNullable<ExpandedTags["Thumbnail"]>,
"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;
@@ -80,30 +108,51 @@ const asDate = (v: unknown): Date | undefined => {
return Number.isNaN(date.getTime()) ? undefined : date;
};
// The common fields of an original's EXIF: `{}` when the file has no EXIF,
// exifreader cannot read its EXIF, or it is not an image exifreader reads.
export const readPhotoExif = (bytes: Uint8Array): PhotoExif => {
const tags = readExifTags(bytes);
const exif = tags?.exif;
const gps = tags?.gps;
const altitude = asNumber(exif?.GPSAltitude?.computed);
// 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(exif?.Make?.computed),
model: asString(exif?.Model?.computed),
lensModel: asString(exif?.LensModel?.computed),
dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed),
offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed),
exposureTime: asNumber(exif?.ExposureTime?.computed),
fNumber: asNumber(exif?.FNumber?.computed),
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(exif?.ISOSpeedRatings?.computed),
focalLength: asNumber(exif?.FocalLength?.computed),
orientation: asNumber(exif?.Orientation?.computed),
gpsLatitude: asNumber(gps?.Latitude),
gpsLongitude: asNumber(gps?.Longitude),
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 && exif?.GPSAltitudeRef?.value === 1
altitude !== undefined && tags.GPSAltitudeRef?.value === 1
? -altitude
: altitude,
};