mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
exif.test.ts: the case "falls back to the file date" now expects no date, since IFD0's DateTime is the file's change date, not the date the photo was taken.
87 lines
3.7 KiB
TypeScript
87 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { readPhotoExif } from '../exif';
|
|
|
|
// A JPEG with an APP1 Exif block, built by hand: a TIFF header, IFD0 with
|
|
// pointers to the Exif and GPS IFDs, and their values after them.
|
|
|
|
type Value = { tag: number; type: 2 | 4 | 5; ascii?: string; long?: number; rationals?: [number, number][] };
|
|
|
|
function tiff(little: boolean, ifds: { ifd0: Value[]; exif: Value[]; gps: Value[] }): Uint8Array {
|
|
const buf = new DataView(new ArrayBuffer(1024));
|
|
const u16 = (at: number, v: number) => buf.setUint16(at, v, little);
|
|
const u32 = (at: number, v: number) => buf.setUint32(at, v, little);
|
|
buf.setUint16(0, little ? 0x4949 : 0x4d4d);
|
|
u16(2, 42);
|
|
u32(4, 8);
|
|
const sizeOf = (n: number) => 2 + n * 12 + 4;
|
|
const at0 = 8;
|
|
const atExif = at0 + sizeOf(ifds.ifd0.length + 2);
|
|
const atGps = atExif + sizeOf(ifds.exif.length);
|
|
let data = atGps + sizeOf(ifds.gps.length);
|
|
const write = (at: number, values: Value[]) => {
|
|
u16(at, values.length);
|
|
values.forEach((v, i) => {
|
|
const e = at + 2 + i * 12;
|
|
u16(e, v.tag);
|
|
u16(e + 2, v.type);
|
|
if (v.type === 2) {
|
|
const bytes = [...new TextEncoder().encode(v.ascii ?? ''), 0];
|
|
u32(e + 4, bytes.length);
|
|
const target = bytes.length <= 4 ? e + 8 : data;
|
|
if (bytes.length > 4) { u32(e + 8, data); data += bytes.length + (bytes.length % 2); }
|
|
bytes.forEach((b, j) => buf.setUint8(target + j, b));
|
|
} else if (v.type === 4) {
|
|
u32(e + 4, 1);
|
|
u32(e + 8, v.long ?? 0);
|
|
} else {
|
|
const r = v.rationals ?? [];
|
|
u32(e + 4, r.length);
|
|
u32(e + 8, data);
|
|
r.forEach(([num, den]) => { u32(data, num); u32(data + 4, den); data += 8; });
|
|
}
|
|
});
|
|
u32(at + 2 + values.length * 12, 0);
|
|
};
|
|
write(at0, [...ifds.ifd0, { tag: 0x8769, type: 4, long: atExif }, { tag: 0x8825, type: 4, long: atGps }]);
|
|
write(atExif, ifds.exif);
|
|
write(atGps, ifds.gps);
|
|
return new Uint8Array(buf.buffer.slice(0, data));
|
|
}
|
|
|
|
function jpeg(block: Uint8Array): Uint8Array {
|
|
const header = new TextEncoder().encode('Exif\0\0');
|
|
const length = 2 + header.length + block.length;
|
|
// SOI, an APP0 first (as cameras write JFIF before Exif), then APP1, then the scan.
|
|
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
|
return new Uint8Array([0xff, 0xd8, ...app0, 0xff, 0xe1, length >> 8, length & 0xff, ...header, ...block, 0xff, 0xda, 0, 2, 0xff, 0xd9]);
|
|
}
|
|
|
|
const photo = (little: boolean) => jpeg(tiff(little, {
|
|
ifd0: [{ tag: 0x0132, type: 2, ascii: '2026:11:01 09:00:00' }],
|
|
exif: [{ tag: 0x9003, type: 2, ascii: '2026:10:31 14:05:30' }, { tag: 0x9011, type: 2, ascii: '+01:00' }],
|
|
gps: [
|
|
{ tag: 1, type: 2, ascii: 'N' }, { tag: 2, type: 5, rationals: [[52, 1], [31, 1], [1234, 100]] },
|
|
{ tag: 3, type: 2, ascii: 'W' }, { tag: 4, type: 5, rationals: [[13, 1], [24, 1], [0, 1]] },
|
|
],
|
|
}));
|
|
|
|
describe('readPhotoExif', () => {
|
|
it('reads the date taken and the GPS position, in both byte orders', () => {
|
|
for (const little of [false, true]) {
|
|
expect(readPhotoExif(photo(little))).toEqual({ taken: '2026-10-31T14:05:30+01:00', lat: 52.52009, lon: -13.4 });
|
|
}
|
|
});
|
|
|
|
it('gives no date when only the date the file changed is there', () => {
|
|
const bytes = jpeg(tiff(false, { ifd0: [{ tag: 0x0132, type: 2, ascii: '2026:11:01 09:00:00' }], exif: [], gps: [] }));
|
|
expect(readPhotoExif(bytes)).toEqual({});
|
|
});
|
|
|
|
it('gives nothing for a JPEG without EXIF, a PNG or broken bytes', () => {
|
|
expect(readPhotoExif(new Uint8Array([0xff, 0xd8, 0xff, 0xda, 0, 2, 0xff, 0xd9]))).toEqual({});
|
|
expect(readPhotoExif(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))).toEqual({});
|
|
const cut = photo(false).slice(0, 40);
|
|
expect(() => readPhotoExif(cut)).not.toThrow();
|
|
});
|
|
});
|