Files
TREK/server/tests/helpers/exif-jpeg.ts
T
2026-10-06 21:50:59 +02:00

148 lines
5.4 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
/**
* A real JPEG carrying the capture tags a phone writes, built byte by byte so
* no actual person's photo has to live in the repo.
*
* The image data is tests/fixtures/small-image.jpg (a decodable 1x1 JPEG); its
* JFIF APP0 is swapped for an Exif APP1 the way a camera lays the file out:
* SOI, APP1 "Exif", then the rest. Every value is written as the raw tag string
* or rational, so a test can also hand in a malformed one.
*/
export interface ExifJpegTags {
/** 0x9003, e.g. '2026:05:30 15:52:19'. */
DateTimeOriginal?: string;
/** 0x9004 (DateTimeDigitized). */
CreateDate?: string;
/** 0x9010, the offset of the IFD0 DateTime. */
OffsetTime?: string;
/** 0x9011, the offset of DateTimeOriginal. */
OffsetTimeOriginal?: string;
/** 0x9012, the offset of DateTimeDigitized. */
OffsetTimeDigitized?: string;
/** Decimal degrees, stored as the usual degree/minute/second rationals plus N/S and E/W. */
gps?: { lat: number; lng: number };
}
const BASE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
const BYTE = 1;
const ASCII = 2;
const LONG = 4;
const RATIONAL = 5;
interface Entry {
tag: number;
type: number;
count: number;
data: Buffer;
}
function ascii(tag: number, value: string): Entry {
const data = Buffer.from(`${value}\0`, 'latin1');
return { tag, type: ASCII, count: data.length, data };
}
function long(tag: number, value: number): Entry {
const data = Buffer.alloc(4);
data.writeUInt32LE(value);
return { tag, type: LONG, count: 1, data };
}
function rationals(tag: number, pairs: Array<[number, number]>): Entry {
const data = Buffer.alloc(pairs.length * 8);
pairs.forEach(([num, den], i) => {
data.writeUInt32LE(num, i * 8);
data.writeUInt32LE(den, i * 8 + 4);
});
return { tag, type: RATIONAL, count: pairs.length, data };
}
function dms(deg: number): Array<[number, number]> {
const abs = Math.abs(deg);
const d = Math.floor(abs);
const m = Math.floor((abs - d) * 60);
const s = Math.round(((abs - d) * 60 - m) * 60 * 10000);
return [
[d, 1],
[m, 1],
[s, 10000],
];
}
function ifdSize(entries: Entry[]): number {
const outOfLine = entries.reduce((sum, e) => sum + (e.data.length > 4 ? e.data.length + (e.data.length % 2) : 0), 0);
return 2 + entries.length * 12 + 4 + outOfLine;
}
/** One IFD at TIFF offset `at`, entries sorted by tag as the spec wants. */
function writeIfd(entries: Entry[], at: number): Buffer {
const sorted = [...entries].sort((a, b) => a.tag - b.tag);
const head = Buffer.alloc(2 + sorted.length * 12 + 4);
const tail: Buffer[] = [];
let dataAt = at + head.length;
head.writeUInt16LE(sorted.length, 0);
sorted.forEach((e, i) => {
const o = 2 + i * 12;
head.writeUInt16LE(e.tag, o);
head.writeUInt16LE(e.type, o + 2);
head.writeUInt32LE(e.count, o + 4);
if (e.data.length <= 4) {
e.data.copy(head, o + 8);
} else {
head.writeUInt32LE(dataAt, o + 8);
const padded = e.data.length % 2 ? Buffer.concat([e.data, Buffer.alloc(1)]) : e.data;
tail.push(padded);
dataAt += padded.length;
}
});
head.writeUInt32LE(0, head.length - 4);
return Buffer.concat([head, ...tail]);
}
export function jpegWithExif(tags: ExifJpegTags): Buffer {
const exifEntries: Entry[] = [];
if (tags.DateTimeOriginal !== undefined) exifEntries.push(ascii(0x9003, tags.DateTimeOriginal));
if (tags.CreateDate !== undefined) exifEntries.push(ascii(0x9004, tags.CreateDate));
if (tags.OffsetTime !== undefined) exifEntries.push(ascii(0x9010, tags.OffsetTime));
if (tags.OffsetTimeOriginal !== undefined) exifEntries.push(ascii(0x9011, tags.OffsetTimeOriginal));
if (tags.OffsetTimeDigitized !== undefined) exifEntries.push(ascii(0x9012, tags.OffsetTimeDigitized));
const gpsEntries: Entry[] = [];
if (tags.gps) {
gpsEntries.push({ tag: 0x0000, type: BYTE, count: 4, data: Buffer.from([2, 2, 0, 0]) });
gpsEntries.push(ascii(0x0001, tags.gps.lat < 0 ? 'S' : 'N'));
gpsEntries.push(rationals(0x0002, dms(tags.gps.lat)));
gpsEntries.push(ascii(0x0003, tags.gps.lng < 0 ? 'W' : 'E'));
gpsEntries.push(rationals(0x0004, dms(tags.gps.lng)));
}
// IFD0 holds only the two pointers, so its size is known before the offsets are.
const ifd0Count = (exifEntries.length ? 1 : 0) + (gpsEntries.length ? 1 : 0);
const ifd0At = 8;
const exifAt = ifd0At + 2 + ifd0Count * 12 + 4;
const gpsAt = exifAt + (exifEntries.length ? ifdSize(exifEntries) : 0);
const ifd0Entries: Entry[] = [];
if (exifEntries.length) ifd0Entries.push(long(0x8769, exifAt));
if (gpsEntries.length) ifd0Entries.push(long(0x8825, gpsAt));
const tiffHeader = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00]);
const tiff = Buffer.concat([
tiffHeader,
writeIfd(ifd0Entries, ifd0At),
exifEntries.length ? writeIfd(exifEntries, exifAt) : Buffer.alloc(0),
gpsEntries.length ? writeIfd(gpsEntries, gpsAt) : Buffer.alloc(0),
]);
const payload = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]);
const app1 = Buffer.alloc(4);
app1.writeUInt16BE(0xffe1, 0);
app1.writeUInt16BE(payload.length + 2, 2);
const base = fs.readFileSync(BASE_JPEG);
// SOI, then skip the JFIF APP0 (marker + its big-endian length).
const app0Length = base.readUInt16BE(4);
const rest = base.subarray(4 + app0Length);
return Buffer.concat([base.subarray(0, 2), app1, payload, rest]);
}