check / check (push) Successful in 1m16s
Add a readPhotoExif test: EXIF whose DateTimeOriginal is 0000:00:00 00:00:00, as a camera with an unset clock writes it, gives no dateTimeOriginal while its other fields are still read. Remove the README sentence describing the backup's exif field in dumps from an earlier version. Model: opus-5-5
176 lines
6.8 KiB
TypeScript
176 lines
6.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
|
|
];
|
|
|
|
// 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.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",
|
|
);
|
|
});
|
|
});
|