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
200 lines
7.8 KiB
TypeScript
200 lines
7.8 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: `readPhotoExif` 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 { 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
|
|
];
|
|
|
|
// 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("readPhotoExif", () => {
|
|
it("reads the common fields of a valid JPEG", () => {
|
|
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
|
|
expect(readPhotoExif(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(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(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
|
gpsAltitude: 12.5,
|
|
});
|
|
});
|
|
|
|
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 fields for %s", (_, input) => {
|
|
expect(readPhotoExif(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("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",
|
|
);
|
|
});
|
|
});
|