exif(): read HEIF/HEIC originals with exifreader (closes #145) #147

Merged
clawbot merged 5 commits from issue-145-heif-exif into next 2026-10-01 22:34:09 +02:00
2 changed files with 44 additions and 1 deletions
Showing only changes of commit 0813163e4d - Show all commits
+5 -1
View File
@@ -58,8 +58,12 @@ export interface PhotoExif {
gpsAltitude?: number; gpsAltitude?: number;
} }
// exifreader gives "<faulty value>" for a tag whose value lies outside the
// file; that tag is left out like one the file lacks.
const asString = (v: unknown): string | undefined => const asString = (v: unknown): string | undefined =>
typeof v === "string" && v.length > 0 ? v : undefined; typeof v === "string" && v.length > 0 && v !== "<faulty value>"
? v
: undefined;
const asNumber = (v: unknown): number | undefined => const asNumber = (v: unknown): number | undefined =>
typeof v === "number" && Number.isFinite(v) ? v : undefined; typeof v === "number" && Number.isFinite(v) ? v : undefined;
+39
View File
@@ -63,6 +63,30 @@ const TIFF_ALTITUDE_WITHOUT_REF = [
...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44 ...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44
]; ];
// 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],
];
// An APP1 segment whose length field matches its data. // An APP1 segment whose length field matches its data.
const app1 = (data: number[]): number[] => { const app1 = (data: number[]): number[] => {
const len = data.length + 2; const len = data.length + 2;
@@ -94,6 +118,13 @@ describe("readPhotoExif", () => {
}); });
}); });
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(bytes(SOI, app1(data), SOS))).toStrictEqual({
orientation: 6,
});
});
it.each([ it.each([
[ [
"a file that is not an image", "a file that is not an image",
@@ -170,6 +201,14 @@ describe("extractImageMetadata", () => {
}); });
}); });
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", () => { it("returns nothing for a file that is not an image", () => {
const text = new TextEncoder().encode("just some text, not an image"); const text = new TextEncoder().encode("just some text, not an image");
expect(extractImageMetadata(text)).toBeUndefined(); expect(extractImageMetadata(text)).toBeUndefined();