check / check (push) Waiting to run
Two readPhotoExif tests on hand-built GPS IFDs: a GPSLatitude with GPSLatitudeRef "S" gives a negative gpsLatitude, and a GPSLatitude and GPSLongitude without their reference tags give neither field. Model: opus-5-5
369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
/**
|
|
* Tests for reading EXIF (`src/exif.ts`) and the image metadata
|
|
* `quak backup-metadata --exif` records.
|
|
*
|
|
* The originals come from users' libraries, so a truncated or corrupt file
|
|
* must neither hang the read nor throw out of it: `readAllExifTags` gives `{}`,
|
|
* and `backup-metadata` tells an EXIF block it cannot read apart from a file
|
|
* that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below
|
|
* is a short hand-built byte array; the HEIC is a real file.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
readAllExifTags,
|
|
readExifTags,
|
|
readPhotoExif,
|
|
} from "../../src/exif.js";
|
|
import { extractImageMetadata } from "../../src/metadata-backup.js";
|
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
|
|
|
const SOI = [0xff, 0xd8]; // start of image
|
|
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan
|
|
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
|
|
const APP0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
|
const ZERO_LENGTH_APP0 = [0xff, 0xe0, 0x00, 0x00];
|
|
|
|
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
|
|
const TIFF_ORIENTATION_6 = [
|
|
0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12,
|
|
0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x00,
|
|
];
|
|
|
|
// A big-endian TIFF block holding Orientation 6 and DateTimeOriginal
|
|
// "0000:00:00 00:00:00", which a camera with an unset clock writes.
|
|
const TIFF_UNSET_DATE = [
|
|
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
|
// The first IFD, at 8: two entries, then no next IFD.
|
|
...[0x00, 0x02],
|
|
// Orientation (0x0112), SHORT, 6.
|
|
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
|
|
// The Exif IFD's offset (0x8769), LONG, 38.
|
|
...[0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x26],
|
|
...[0x00, 0x00, 0x00, 0x00],
|
|
// The Exif IFD, at 38: one entry, then no next IFD.
|
|
...[0x00, 0x01],
|
|
// DateTimeOriginal (0x9003), 20 ASCII bytes at 56.
|
|
...[0x90, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x38],
|
|
...[0x00, 0x00, 0x00, 0x00],
|
|
...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
|
|
];
|
|
|
|
// A big-endian TIFF block holding a GPSLatitude of 33° 30' 0" and a
|
|
// GPSLatitudeRef of "S".
|
|
const TIFF_SOUTHERN_LATITUDE = [
|
|
...[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: two entries, then no next IFD.
|
|
...[0x00, 0x02],
|
|
// GPSLatitudeRef (0x0001), 2 ASCII bytes: "S".
|
|
...[0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x53, 0x00, 0x00, 0x00],
|
|
// GPSLatitude (0x0002), three RATIONALs at 56.
|
|
...[0x00, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x38],
|
|
...[0x00, 0x00, 0x00, 0x00],
|
|
...[0x00, 0x00, 0x00, 0x21, 0x00, 0x00, 0x00, 0x01], // 33/1, at 56
|
|
...[0x00, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x01], // 30/1
|
|
...[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01], // 0/1
|
|
];
|
|
|
|
// A big-endian TIFF block holding a GPSLatitude of 40° 26' 46" and a
|
|
// GPSLongitude of 79° 58' 56", and neither GPSLatitudeRef nor GPSLongitudeRef.
|
|
const TIFF_POSITION_WITHOUT_REFS = [
|
|
...[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: two entries, then no next IFD.
|
|
...[0x00, 0x02],
|
|
// GPSLatitude (0x0002), three RATIONALs at 56.
|
|
...[0x00, 0x02, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x38],
|
|
// GPSLongitude (0x0004), three RATIONALs at 80.
|
|
...[0x00, 0x04, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x50],
|
|
...[0x00, 0x00, 0x00, 0x00],
|
|
...[0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x01], // 40/1, at 56
|
|
...[0x00, 0x00, 0x00, 0x1a, 0x00, 0x00, 0x00, 0x01], // 26/1
|
|
...[0x00, 0x00, 0x00, 0x2e, 0x00, 0x00, 0x00, 0x01], // 46/1
|
|
...[0x00, 0x00, 0x00, 0x4f, 0x00, 0x00, 0x00, 0x01], // 79/1, at 80
|
|
...[0x00, 0x00, 0x00, 0x3a, 0x00, 0x00, 0x00, 0x01], // 58/1
|
|
...[0x00, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x01], // 56/1
|
|
];
|
|
|
|
// A big-endian TIFF block holding Orientation 6 and a Make whose value lies
|
|
// past the end of the file.
|
|
const TIFF_MAKE_PAST_END = [
|
|
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
|
// The first IFD, at 8: two entries, then no next IFD.
|
|
...[0x00, 0x02],
|
|
// Make (0x010f), 6 ASCII bytes at 4096.
|
|
...[0x01, 0x0f, 0x00, 0x02, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x10, 0x00],
|
|
// Orientation (0x0112), SHORT, 6.
|
|
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
|
|
...[0x00, 0x00, 0x00, 0x00],
|
|
];
|
|
|
|
// A big-endian TIFF block with one IFD entry that exifreader has no name for:
|
|
// tag 0xc000 (49152), SHORT, 7.
|
|
const TIFF_UNNAMED_TAG = [
|
|
...[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],
|
|
// Tag 0xc000, SHORT, 7.
|
|
...[0xc0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00],
|
|
...[0x00, 0x00, 0x00, 0x00],
|
|
];
|
|
|
|
// A big-endian TIFF block holding Orientation 6, and a thumbnail IFD holding
|
|
// its own Orientation 1 and a 4-byte JPEG thumbnail.
|
|
const TIFF_WITH_THUMBNAIL = [
|
|
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
|
// The first IFD, at 8: one entry, then the thumbnail IFD at 26.
|
|
...[0x00, 0x01],
|
|
// Orientation (0x0112), SHORT, 6.
|
|
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
|
|
...[0x00, 0x00, 0x00, 0x1a],
|
|
// The thumbnail IFD, at 26: three entries, then no next IFD.
|
|
...[0x00, 0x03],
|
|
// Orientation (0x0112), SHORT, 1.
|
|
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00],
|
|
// JPEGInterchangeFormat (0x0201), LONG: the thumbnail is at 68.
|
|
...[0x02, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x44],
|
|
// JPEGInterchangeFormatLength (0x0202), LONG: 4 bytes.
|
|
...[0x02, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04],
|
|
...[0x00, 0x00, 0x00, 0x00],
|
|
...[0xff, 0xd8, 0xff, 0xd9], // the thumbnail, at 68: an empty JPEG
|
|
];
|
|
|
|
// An APP1 segment whose length field matches its data.
|
|
const app1 = (data: number[]): number[] => {
|
|
const len = data.length + 2;
|
|
return [0xff, 0xe1, len >> 8, len & 0xff, ...data];
|
|
};
|
|
|
|
const bytes = (...parts: number[][]): Uint8Array =>
|
|
new Uint8Array(parts.flat());
|
|
|
|
describe("readAllExifTags", () => {
|
|
it("keys a tag exifreader has no name for by its number", () => {
|
|
const data = [...EXIF_HEADER, ...TIFF_UNNAMED_TAG];
|
|
expect(readAllExifTags(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
|
"undefined-49152": {
|
|
id: 49152,
|
|
value: 7,
|
|
description: 7,
|
|
computed: 7,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("puts the thumbnail's tags under Thumbnail, without its image", () => {
|
|
const input = bytes(
|
|
SOI,
|
|
app1([...EXIF_HEADER, ...TIFF_WITH_THUMBNAIL]),
|
|
SOS,
|
|
);
|
|
// exifreader finds the thumbnail's image.
|
|
expect(readExifTags(input)?.Thumbnail?.type).toBe("image/jpeg");
|
|
const tags = readAllExifTags(input);
|
|
expect(tags.Orientation?.value).toBe(6);
|
|
expect(Object.keys(tags.Thumbnail ?? {}).sort()).toEqual([
|
|
"JPEGInterchangeFormat",
|
|
"JPEGInterchangeFormatLength",
|
|
"Orientation",
|
|
]);
|
|
expect(tags.Thumbnail?.Orientation?.value).toBe(1);
|
|
expect(readPhotoExif(tags)).toStrictEqual({ orientation: 6 });
|
|
});
|
|
});
|
|
|
|
describe("readPhotoExif", () => {
|
|
it("reads the common fields of a valid JPEG", () => {
|
|
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
|
|
expect(
|
|
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
|
|
).toStrictEqual({
|
|
orientation: 6,
|
|
});
|
|
});
|
|
|
|
it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => {
|
|
const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE];
|
|
expect(
|
|
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
|
|
).toStrictEqual({
|
|
orientation: 6,
|
|
});
|
|
});
|
|
|
|
it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => {
|
|
const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF];
|
|
expect(
|
|
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
|
|
).toStrictEqual({
|
|
gpsAltitude: 12.5,
|
|
});
|
|
});
|
|
|
|
it("reads a GPSLatitude with GPSLatitudeRef S as south of the equator", () => {
|
|
const data = [...EXIF_HEADER, ...TIFF_SOUTHERN_LATITUDE];
|
|
expect(
|
|
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
|
|
).toStrictEqual({
|
|
gpsLatitude: -33.5,
|
|
});
|
|
});
|
|
|
|
it("gives no gpsLatitude or gpsLongitude without their reference tags", () => {
|
|
const data = [...EXIF_HEADER, ...TIFF_POSITION_WITHOUT_REFS];
|
|
const tags = readAllExifTags(bytes(SOI, app1(data), SOS));
|
|
// The position is read; only its hemisphere is unknown.
|
|
expect(tags.GPSLatitude?.computed).toStrictEqual([40, 26, 46]);
|
|
expect(tags.GPSLongitude?.computed).toStrictEqual([79, 58, 56]);
|
|
expect(readPhotoExif(tags)).toStrictEqual({});
|
|
});
|
|
|
|
it("gives no make for a Make whose value lies past the end of the file", () => {
|
|
const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END];
|
|
expect(
|
|
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
|
|
).toStrictEqual({
|
|
orientation: 6,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"a file that is not an image",
|
|
new TextEncoder().encode("just some text, not an image"),
|
|
],
|
|
[
|
|
"a PNG without EXIF",
|
|
bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
],
|
|
["a JPEG without EXIF", bytes(SOI, APP0, SOS)],
|
|
// A length under 8 cannot hold the six-byte "Exif\0\0" header. This one
|
|
// has length 7 and holds only "Exif\0", so a read past its end would
|
|
// take the next segment's bytes as EXIF.
|
|
[
|
|
"an APP1 segment too short to hold the Exif header",
|
|
bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS),
|
|
],
|
|
[
|
|
"an APP1 segment holding just the Exif header",
|
|
bytes(SOI, app1(EXIF_HEADER), SOS),
|
|
],
|
|
[
|
|
"a JPEG truncated inside a segment header",
|
|
bytes(SOI, [0xff, 0xe1, 0x00]),
|
|
],
|
|
["a JPEG that ends before the image data", bytes(SOI, APP0)],
|
|
// A length of 0 would step a scan by 2 bytes at a time through the
|
|
// rest of the file, reading garbage as markers.
|
|
[
|
|
"a zero-length segment",
|
|
bytes(
|
|
SOI,
|
|
ZERO_LENGTH_APP0,
|
|
ZERO_LENGTH_APP0,
|
|
ZERO_LENGTH_APP0,
|
|
ZERO_LENGTH_APP0,
|
|
SOS,
|
|
),
|
|
],
|
|
["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)],
|
|
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
|
|
[
|
|
"a segment length that runs past the end of the file",
|
|
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
|
],
|
|
// "XX" where the TIFF byte order belongs.
|
|
[
|
|
"an EXIF block that cannot be parsed",
|
|
bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS),
|
|
],
|
|
])("returns no tags and no fields for %s", (_, input) => {
|
|
expect(readAllExifTags(input)).toStrictEqual({});
|
|
expect(readPhotoExif(readAllExifTags(input))).toStrictEqual({});
|
|
});
|
|
});
|
|
|
|
describe("extractImageMetadata", () => {
|
|
it("parses EXIF from a valid JPEG", () => {
|
|
const meta = extractImageMetadata(
|
|
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
|
|
);
|
|
expect(meta?.exifError).toBeUndefined();
|
|
expect(meta?.exif).toMatchObject({ Orientation: { value: 6 } });
|
|
});
|
|
|
|
it("parses EXIF from a HEIC", () => {
|
|
const meta = extractImageMetadata(HEIC_WITH_EXIF);
|
|
expect(meta?.exifError).toBeUndefined();
|
|
expect(meta?.exif).toMatchObject({
|
|
Make: { value: ["Canon"] },
|
|
Model: { value: ["EOS R5"] },
|
|
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
|
|
Orientation: { value: 6 },
|
|
GPSLatitudeRef: { value: ["N"] },
|
|
});
|
|
});
|
|
|
|
it("keys a tag exifreader has no name for by its number", () => {
|
|
const meta = extractImageMetadata(
|
|
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_UNNAMED_TAG]), SOS),
|
|
);
|
|
expect(meta?.exifError).toBeUndefined();
|
|
expect(meta?.exif).toMatchObject({ "undefined-49152": { value: 7 } });
|
|
});
|
|
|
|
it("returns nothing for a file that is not an image", () => {
|
|
const text = new TextEncoder().encode("just some text, not an image");
|
|
expect(extractImageMetadata(text)).toBeUndefined();
|
|
});
|
|
|
|
it("records the reason when the JPEG is malformed", () => {
|
|
const meta = extractImageMetadata(
|
|
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
|
);
|
|
expect(meta?.exif).toBeUndefined();
|
|
expect(meta?.exifError).toBe(
|
|
"no tag could be read from the EXIF block",
|
|
);
|
|
});
|
|
|
|
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
|
|
// The raw bytes are the whole EXIF block as exifreader finds it: for a
|
|
// JPEG, the APP1 segment, marker and length included.
|
|
const segment = app1([...EXIF_HEADER, 0x58, 0x58]);
|
|
const meta = extractImageMetadata(bytes(SOI, segment, SOS));
|
|
expect(meta?.exif).toBeUndefined();
|
|
expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64"));
|
|
expect(meta?.exifError).toBe(
|
|
"no tag could be read from the EXIF block",
|
|
);
|
|
});
|
|
});
|