/** * 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", ); }); });