/** * 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 = { 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(); 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; exif: Map | null; gps: Map | null; ifd1: Map | 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(); 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, 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 = ` 3456/100 0 2024-05-01T10:00:00Z © Jane Doe Berlin 52.520095 13.401577 `; /** 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 . */ 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 { let pos = 0; return new ReadableStream({ pull(controller) { if (pos >= data.length) { controller.close(); return; } controller.enqueue(data.slice(pos, pos + size)); pos += size; }, }); }