check / check (push) Successful in 1m21s
photo.exif() and backup-metadata --exif read EXIF with exifreader, which reads JPEG, HEIC/HEIF, AVIF, PNG and WebP, so a HEIC original or a live photo's HEIC image gets its EXIF. exifreader replaces exif-reader and the hand-written JPEG segment scan. PhotoExif is unchanged. The exif field of backup-metadata --exif is now exifreader's tag output, and exifRaw holds the whole EXIF block it could not read. Tests use a real 933-byte HEIC fixture, test/exif.heic, whose recipe is in test/exif-heic.ts. Model: opus-5-5
150 lines
5.6 KiB
TypeScript
150 lines
5.6 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,
|
|
];
|
|
|
|
// 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.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",
|
|
);
|
|
});
|
|
});
|