Files
immich-folio/lib/__tests__/fixtures/location.ts
2026-10-02 18:14:23 +02:00

493 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Builders for real-world-shaped metadata fixtures: TIFF/EXIF with a GPS IFD
* in either byte order, XMP packets, JPEGs and HEIF files. Shared by
* lib/__tests__/locationScrub.test.ts and the download route tests.
*/
export const cat = (...parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
};
export const bytes = (...n: number[]) => new Uint8Array(n);
export const ascii = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)));
export const utf8 = (s: string) => new TextEncoder().encode(s);
export const be16 = (n: number) => bytes((n >> 8) & 0xff, n & 0xff);
export const be32 = (n: number) =>
bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
/** Index of `needle` in `hay`, or -1. */
export function indexOf(hay: Uint8Array, needle: Uint8Array, from = 0): number {
outer: for (let i = from; i + needle.length <= hay.length; i++) {
for (let k = 0; k < needle.length; k++) if (hay[i + k] !== needle[k]) continue outer;
return i;
}
return -1;
}
export const contains = (hay: Uint8Array, needle: Uint8Array) => indexOf(hay, needle) >= 0;
export const latin1 = (b: Uint8Array) => Buffer.from(b).toString('latin1');
// ── TIFF ─────────────────────────────────────────────────────────────────────
export const TYPE = { BYTE: 1, ASCII: 2, SHORT: 3, LONG: 4, RATIONAL: 5, UNDEFINED: 7 } as const;
const TYPE_SIZE: Record<number, number> = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1 };
export type TiffEntry =
| { tag: number; type: number; count: number; value: (le: boolean) => Uint8Array }
| { tag: number; ifd: string };
export interface TiffIfd {
name: string;
entries: TiffEntry[];
next?: string;
}
const u16 = (le: boolean, n: number) => (le ? bytes(n & 0xff, (n >> 8) & 0xff) : be16(n));
const u32 = (le: boolean, n: number) =>
le ? bytes(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff) : be32(n);
export const asciiValue = (s: string) => ({
type: TYPE.ASCII,
count: s.length + 1,
value: () => ascii(`${s}\0`),
});
export const shortValue = (...n: number[]) => ({
type: TYPE.SHORT,
count: n.length,
value: (le: boolean) => cat(...n.map((v) => u16(le, v))),
});
export const byteValue = (...n: number[]) => ({
type: TYPE.BYTE,
count: n.length,
value: () => bytes(...n),
});
export const rationalValue = (...pairs: Array<[number, number]>) => ({
type: TYPE.RATIONAL,
count: pairs.length,
value: (le: boolean) => cat(...pairs.flatMap(([a, b]) => [u32(le, a), u32(le, b)])),
});
export const undefinedValue = (data: Uint8Array) => ({
type: TYPE.UNDEFINED,
count: data.length,
value: () => data,
});
/** Lay out IFDs after the header, out-of-line values after the IFDs. */
export function buildTiff(le: boolean, ifds: TiffIfd[]): Uint8Array {
const offsets = new Map<string, number>();
let pos = 8;
for (const ifd of ifds) {
offsets.set(ifd.name, pos);
pos += 2 + ifd.entries.length * 12 + 4;
}
const data: Uint8Array[] = [];
let dataPos = pos;
const tables: Uint8Array[] = [];
for (const ifd of ifds) {
const parts: Uint8Array[] = [u16(le, ifd.entries.length)];
for (const e of ifd.entries) {
if ('ifd' in e) {
parts.push(u16(le, e.tag), u16(le, TYPE.LONG), u32(le, 1), u32(le, offsets.get(e.ifd)!));
continue;
}
const value = e.value(le);
expectLength(value.length, TYPE_SIZE[e.type] * e.count);
parts.push(u16(le, e.tag), u16(le, e.type), u32(le, e.count));
if (value.length <= 4) {
const inline = new Uint8Array(4);
inline.set(value);
parts.push(inline);
} else {
parts.push(u32(le, dataPos));
data.push(value);
dataPos += value.length;
if (value.length % 2) {
data.push(bytes(0));
dataPos++;
}
}
}
parts.push(u32(le, ifd.next ? offsets.get(ifd.next)! : 0));
tables.push(cat(...parts));
}
const header = le
? cat(ascii('II'), bytes(42, 0), u32(le, 8))
: cat(ascii('MM'), bytes(0, 42), u32(le, 8));
return cat(header, ...tables, ...data);
}
function expectLength(actual: number, expected: number) {
if (actual !== expected)
throw new Error(`fixture value is ${actual} bytes, expected ${expected}`);
}
export const LAT = rationalValue([52, 1], [31, 1], [1234, 100]);
export const LON = rationalValue([13, 1], [24, 1], [5678, 100]);
export const ALT = rationalValue([3456, 100]);
/** A camera EXIF block: IFD0, EXIF IFD, GPS IFD and an IFD1. */
export function cameraTiff(le: boolean, opts: { withGps?: boolean; xmp?: Uint8Array } = {}) {
const withGps = opts.withGps ?? true;
const ifd0: TiffEntry[] = [
{ tag: 0x010f, ...asciiValue('Canon') },
{ tag: 0x0110, ...asciiValue('Canon EOS R5') },
{ tag: 0x0112, ...shortValue(1) },
];
if (opts.xmp) ifd0.push({ tag: 0x02bc, ...undefinedValue(opts.xmp) });
ifd0.push({ tag: 0x8298, ...asciiValue('(c) Jane Doe') });
ifd0.push({ tag: 0x8769, ifd: 'exif' });
if (withGps) ifd0.push({ tag: 0x8825, ifd: 'gps' });
const ifds: TiffIfd[] = [
{ name: 'ifd0', entries: ifd0, next: 'ifd1' },
{
name: 'exif',
entries: [
{ tag: 0x829a, ...rationalValue([1, 250]) },
{ tag: 0x829d, ...rationalValue([28, 10]) },
{ tag: 0x8827, ...shortValue(100) },
{ tag: 0xa434, ...asciiValue('RF24-70mm F2.8 L IS USM') },
],
},
];
if (withGps) {
ifds.push({
name: 'gps',
entries: [
{ tag: 0x0000, ...byteValue(2, 3, 0, 0) },
{ tag: 0x0001, ...asciiValue('N') },
{ tag: 0x0002, ...LAT },
{ tag: 0x0003, ...asciiValue('E') },
{ tag: 0x0004, ...LON },
{ tag: 0x0005, ...byteValue(0) },
{ tag: 0x0006, ...ALT },
],
});
}
ifds.push({ name: 'ifd1', entries: [{ tag: 0x0103, ...shortValue(6) }] });
return buildTiff(le, ifds);
}
export interface ParsedEntry {
type: number;
count: number;
value: Uint8Array;
}
export interface ParsedTiff {
ifd0: Map<number, ParsedEntry>;
exif: Map<number, ParsedEntry> | null;
gps: Map<number, ParsedEntry> | null;
ifd1: Map<number, ParsedEntry> | null;
}
/** A small, strict TIFF reader standing in for exiftool. */
export function parseTiff(t: Uint8Array): ParsedTiff {
const le = t[0] === 0x49;
const r16 = (o: number) => (le ? t[o] | (t[o + 1] << 8) : (t[o] << 8) | t[o + 1]);
const r32 = (o: number) =>
le
? (t[o] | (t[o + 1] << 8) | (t[o + 2] << 16) | (t[o + 3] << 24)) >>> 0
: ((t[o] << 24) | (t[o + 1] << 16) | (t[o + 2] << 8) | t[o + 3]) >>> 0;
if (r16(2) !== 42) throw new Error('not TIFF');
const readIfd = (off: number) => {
const map = new Map<number, ParsedEntry>();
const count = r16(off);
if (off + 2 + count * 12 + 4 > t.length) throw new Error('IFD out of bounds');
let lastTag = -1;
for (let i = 0; i < count; i++) {
const e = off + 2 + i * 12;
const tag = r16(e);
if (tag <= lastTag) throw new Error('IFD entries out of order');
lastTag = tag;
const type = r16(e + 2);
const n = r32(e + 4);
const size = TYPE_SIZE[type] * n;
const at = size <= 4 ? e + 8 : r32(e + 8);
if (at + size > t.length) throw new Error('value out of bounds');
map.set(tag, { type, count: n, value: t.slice(at, at + size) });
}
return { map, next: r32(off + 2 + count * 12) };
};
const ifd0 = readIfd(r32(4));
const ptr = (m: Map<number, ParsedEntry>, tag: number) => {
const e = m.get(tag);
return e
? (le
? e.value[0] | (e.value[1] << 8) | (e.value[2] << 16) | (e.value[3] << 24)
: (e.value[0] << 24) | (e.value[1] << 16) | (e.value[2] << 8) | e.value[3]) >>> 0
: 0;
};
const exifOff = ptr(ifd0.map, 0x8769);
const gpsOff = ptr(ifd0.map, 0x8825);
return {
ifd0: ifd0.map,
exif: exifOff ? readIfd(exifOff).map : null,
gps: gpsOff ? readIfd(gpsOff).map : null,
ifd1: ifd0.next ? readIfd(ifd0.next).map : null,
};
}
export const asciiOf = (e: ParsedEntry | undefined) =>
e ? latin1(e.value).replace(/\0+$/, '') : undefined;
// ── XMP ──────────────────────────────────────────────────────────────────────
export const XMP_PACKET = `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:exif="http://ns.adobe.com/exif/1.0/"
xmlns:tiff="http://ns.adobe.com/tiff/1.0/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/"
xmlns:drone-dji="http://www.dji.com/drone-dji/1.0/"
tiff:Make="Canon"
exif:GPSLatitude="52,31.2057N"
exif:GPSLongitude='13,24.0946E'
drone-dji:GpsLatitude="52.520095"
photoshop:City="Berlin"
photoshop:Country="Germany">
<exif:GPSAltitude>3456/100</exif:GPSAltitude>
<exif:GPSAltitudeRef>0</exif:GPSAltitudeRef>
<exif:GPSTimeStamp>2024-05-01T10:00:00Z</exif:GPSTimeStamp>
<exif:GPSVersionID/>
<dc:rights><rdf:Alt><rdf:li xml:lang="x-default">© Jane Doe</rdf:li></rdf:Alt></dc:rights>
<Iptc4xmpExt:LocationCreated>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<Iptc4xmpExt:City>Berlin</Iptc4xmpExt:City>
<Iptc4xmpExt:GPSLatitude>52.520095</Iptc4xmpExt:GPSLatitude>
<Iptc4xmpExt:GPSLongitude>13.401577</Iptc4xmpExt:GPSLongitude>
</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:LocationCreated>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>`;
/** Strings from the packet above that must not survive. */
export const XMP_LOCATION_VALUES = [
'52,31.2057N',
'13,24.0946E',
'52.520095',
'13.401577',
'3456/100',
];
// ── JPEG ─────────────────────────────────────────────────────────────────────
/** JPEG segment: FFxx <length incl. itself> <payload>. */
export const seg = (marker: number, payload: Uint8Array) =>
cat(bytes(0xff, marker), be16(payload.length + 2), payload);
export const exifSegment = (tiff: Uint8Array) => seg(0xe1, cat(ascii('Exif\0\0'), tiff));
export const xmpSegment = (packet: string) =>
seg(0xe1, cat(ascii('http://ns.adobe.com/xap/1.0/\0'), utf8(packet)));
export const SOS = cat(bytes(0xff, 0xda), be16(8), bytes(1, 1, 0, 0, 0x3f, 0));
/** Entropy-coded data with byte stuffing and a restart marker. */
export const SCAN = bytes(0x12, 0xff, 0x00, 0x34, 0xff, 0xd0, 0x56, 0x78, 0xff, 0x00, 0x9a);
export const EOI = bytes(0xff, 0xd9);
export const ICC = cat(ascii('ICC_PROFILE\0'), bytes(1, 1), ascii('colour profile bytes'));
export const JFIF = cat(ascii('JFIF\0'), bytes(1, 2, 0, 0, 1, 0, 1, 0, 0));
/** Photoshop APP13 with an IPTC record and an EXIF resource. */
export function photoshopSegment(exif: Uint8Array) {
const iptc = cat(bytes(0x1c, 0x02, 0x74), be16(18), ascii('Copyright Jane Doe'));
const resource = (id: number, data: Uint8Array) =>
cat(
ascii('8BIM'),
be16(id),
bytes(0, 0),
be32(data.length),
data,
data.length % 2 ? bytes(0) : bytes(),
);
return seg(0xed, cat(ascii('Photoshop 3.0\0'), resource(0x0404, iptc), resource(0x0422, exif)));
}
/** A JPEG as a camera or phone writes it, with a second image appended (MPF). */
export function cameraJpeg(le: boolean) {
const primaryHead = cat(
bytes(0xff, 0xd8),
seg(0xe0, JFIF),
exifSegment(cameraTiff(le)),
xmpSegment(XMP_PACKET),
seg(
0xe1,
cat(
ascii('http://ns.adobe.com/xmp/extension/\0'),
ascii('GUID'),
ascii('exif:GPSLatitude="1"'),
),
),
seg(0xe2, ICC),
seg(
0xeb,
cat(
ascii('JP'),
bytes(0, 1),
be32(1),
be32(40),
ascii('jumbjumd'),
ascii('c2pa GPS 52.520095'),
),
),
photoshopSegment(cameraTiff(!le)),
seg(0xdb, bytes(0, ...new Array(64).fill(1))),
);
const primary = cat(primaryHead, SOS, SCAN, EOI);
const secondary = cat(bytes(0xff, 0xd8), exifSegment(cameraTiff(le)), SOS, SCAN, EOI);
return {
file: cat(primary, secondary),
primaryHeadLength: primaryHead.length,
primaryLength: primary.length,
};
}
/** Every APPn segment of the JPEG at `soi`, up to SOS. */
export function jpegSegments(b: Uint8Array, soi = 0) {
const out: Array<{ marker: number; payload: Uint8Array; start: number }> = [];
let pos = soi + 2;
while (pos + 4 <= b.length && b[pos] === 0xff && b[pos + 1] !== 0xda) {
const len = (b[pos + 2] << 8) | b[pos + 3];
out.push({ marker: b[pos + 1], payload: b.slice(pos + 4, pos + 2 + len), start: pos + 4 });
pos += 2 + len;
}
return out;
}
/** The TIFF block of the first EXIF APP1 in the JPEG at `soi`. */
export function exifOf(b: Uint8Array, soi = 0): Uint8Array | null {
const s = jpegSegments(b, soi).find(
(x) => x.marker === 0xe1 && latin1(x.payload.slice(0, 6)) === 'Exif\0\0',
);
return s ? s.payload.slice(6) : null;
}
// ── HEIF ─────────────────────────────────────────────────────────────────────
export const box = (type: string, ...content: Uint8Array[]) => {
const body = cat(...content);
return cat(be32(body.length + 8), ascii(type), body);
};
const infe = (id: number, type: string, extra: Uint8Array = bytes(0)) =>
box('infe', bytes(2, 0, 0, 0), be16(id), be16(0), ascii(type), extra);
export const IMAGE_DATA = ascii('HEVC-IMAGE-DATA-THAT-MUST-NOT-CHANGE');
/**
* A HEIC with an image item, an `Exif` item and an XMP `mime` item. Item data
* goes to `mdat`, or for `idatExif` the EXIF goes into `idat` (method 1).
* `metaLast` puts `meta` after `mdat`; `c2pa` appends a manifest `uuid` box.
*/
export function cameraHeic(
opts: {
le?: boolean;
idatExif?: boolean;
metaLast?: boolean;
c2pa?: boolean;
exifPayload?: Uint8Array;
} = {},
) {
const le = opts.le ?? false;
const exifItem = opts.exifPayload ?? cat(be32(6), ascii('Exif\0\0'), cameraTiff(le));
const xmpItem = utf8(XMP_PACKET);
const ftyp = box('ftyp', ascii('heic'), be32(0), ascii('mif1heic'));
const iinf = box(
'iinf',
bytes(0, 0, 0, 0),
be16(3),
infe(1, 'hvc1'),
infe(2, 'Exif'),
infe(3, 'mime', cat(bytes(0), ascii('application/rdf+xml\0'))),
);
// iloc v1: offset_size 4, length_size 4, base_offset_size 0, index_size 0.
const entry = (id: number, method: number, offset: number, length: number) =>
cat(be16(id), be16(method), be16(0), be16(1), be32(offset), be32(length));
const buildMeta = (o: { image: number; exif: number; xmp: number }) =>
box(
'meta',
bytes(0, 0, 0, 0),
box('hdlr', bytes(0, 0, 0, 0), be32(0), ascii('pict'), be32(0), be32(0), be32(0), bytes(0)),
iinf,
box(
'iloc',
bytes(1, 0, 0, 0),
bytes(0x44, 0x00),
be16(3),
entry(1, 0, o.image, IMAGE_DATA.length),
entry(2, opts.idatExif ? 1 : 0, o.exif, exifItem.length),
entry(3, 0, o.xmp, xmpItem.length),
),
...(opts.idatExif ? [box('idat', exifItem)] : []),
);
const mdatBody = opts.idatExif ? cat(IMAGE_DATA, xmpItem) : cat(IMAGE_DATA, exifItem, xmpItem);
const metaLength = buildMeta({ image: 0, exif: 0, xmp: 0 }).length;
const mdatStart = ftyp.length + (opts.metaLast ? 0 : metaLength) + 8;
const offsets = {
image: mdatStart,
exif: opts.idatExif ? 0 : mdatStart + IMAGE_DATA.length,
xmp: mdatStart + IMAGE_DATA.length + (opts.idatExif ? 0 : exifItem.length),
};
const meta = buildMeta(offsets);
const mdat = box('mdat', mdatBody);
const c2pa = opts.c2pa
? box(
'uuid',
bytes(
0xd8,
0xfe,
0xc3,
0xd6,
0x1b,
0x0e,
0x48,
0x3c,
0x92,
0x97,
0x58,
0x28,
0x87,
0x7e,
0xc4,
0x81,
),
ascii('c2pa manifest GPS 52.520095'),
)
: bytes();
const file = opts.metaLast ? cat(ftyp, mdat, meta, c2pa) : cat(ftyp, meta, mdat, c2pa);
return { file, imageAt: offsets.image };
}
/** The TIFF block of the EXIF item, read back by its known offset prefix. */
export function heicExif(file: Uint8Array): Uint8Array | null {
const at = indexOf(file, cat(be32(6), ascii('Exif\0\0')));
return at < 0 ? null : file.slice(at + 10);
}
/** Feed `data` as a stream cut into `size`-byte chunks. */
export function chunked(data: Uint8Array, size: number): ReadableStream<Uint8Array> {
let pos = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (pos >= data.length) {
controller.close();
return;
}
controller.enqueue(data.slice(pos, pos + size));
pos += size;
},
});
}