exif(): read HEIF/HEIC originals with exifreader (closes #145) #147
+14
-4
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user