mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
cd06f0f7f4
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).
The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.
Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.
StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.
The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.
The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
139 lines
5.3 KiB
TypeScript
139 lines
5.3 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]);
|
|
}
|