mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
148 lines
5.4 KiB
TypeScript
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]);
|
|
}
|