feat(exif): stamp RC_<ts> filenames + EXIF on capture and export
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
// Pure JPEG/EXIF writer — no imports, so the codec can be exercised directly.
|
||||
//
|
||||
// Skia re-encodes the photo, which drops the original EXIF block (see
|
||||
// jpegDpi.ts). This rebuilds the APP1/EXIF segment: it reads the SOURCE
|
||||
// photo's TIFF, keeps every tag it does not own, and stamps the fields this app
|
||||
// is responsible for — software + version, capture date, GPS, pixel dimensions.
|
||||
// Same in-place byte approach as jpegDpi.ts: no dependency, no native module.
|
||||
//
|
||||
// ponytail: IFD1 (the embedded thumbnail) and MakerNote are dropped. A carried
|
||||
// thumbnail would show the PRE-export pixels, and MakerNote holds absolute
|
||||
// offsets that a relocated TIFF invalidates. Read them back when a use case
|
||||
// needs them.
|
||||
|
||||
// Stamped into every file this app writes (kept in step with app.json).
|
||||
export const SOFTWARE = 'RecipesCam 1.2';
|
||||
|
||||
export interface ExifStamp {
|
||||
software?: string;
|
||||
// Shutter instant. null/undefined = keep the loaded photo's own
|
||||
// DateTimeOriginal and only fill it in when the source has none.
|
||||
dateTime?: Date | null;
|
||||
gps?: { latitude: number; longitude: number } | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
dpi?: number | null;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
tag: number;
|
||||
type: number;
|
||||
count: number;
|
||||
data: Uint8Array;
|
||||
ref?: 'exif' | 'gps'; // IFD pointer entry — value patched during layout
|
||||
}
|
||||
|
||||
const BYTE = 1, ASCII = 2, SHORT = 3, LONG = 4, RATIONAL = 5, UNDEFINED = 7, SLONG = 9, SRATIONAL = 10;
|
||||
|
||||
const TYPE_SIZE: Record<number, number> = {
|
||||
[BYTE]: 1, [ASCII]: 1, [SHORT]: 2, [LONG]: 4, [RATIONAL]: 8,
|
||||
[UNDEFINED]: 1, [SLONG]: 4, [SRATIONAL]: 8,
|
||||
};
|
||||
|
||||
// Tags this module owns, or that would dangle once the TIFF is rebuilt.
|
||||
const DROP = new Set<number>([
|
||||
0x0112, // Orientation — the exported pixels are already upright (stamped back as 1)
|
||||
0x0100, 0x0101, // ImageWidth / ImageLength (stamped from the real export size)
|
||||
0x011a, 0x011b, 0x0128, // XResolution / YResolution / ResolutionUnit (stamped when dpi is set)
|
||||
0x0131, 0x0132, // Software / DateTime
|
||||
0x8769, 0x8825, 0xa005, 0x014a, // Exif / GPS / Interop / SubIFD pointers (rebuilt)
|
||||
0x0200, 0x0201, 0x0202, // thumbnail offset/length
|
||||
0x927c, // MakerNote — internal offsets do not survive relocation
|
||||
]);
|
||||
|
||||
// --- little-endian writers -------------------------------------------------
|
||||
|
||||
function u16w(b: Uint8Array, p: number, v: number): void {
|
||||
b[p] = v & 0xff;
|
||||
b[p + 1] = (v >> 8) & 0xff;
|
||||
}
|
||||
|
||||
function u32w(b: Uint8Array, p: number, v: number): void {
|
||||
b[p] = v & 0xff;
|
||||
b[p + 1] = (v >> 8) & 0xff;
|
||||
b[p + 2] = (v >> 16) & 0xff;
|
||||
b[p + 3] = (v >> 24) & 0xff;
|
||||
}
|
||||
|
||||
// --- entry builders --------------------------------------------------------
|
||||
|
||||
function asciiEntry(tag: number, text: string): Entry {
|
||||
const data = new Uint8Array(text.length + 1); // NUL-terminated, as EXIF requires
|
||||
for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i) & 0xff;
|
||||
return { tag, type: ASCII, count: data.length, data };
|
||||
}
|
||||
|
||||
function shortEntry(tag: number, v: number): Entry {
|
||||
const data = new Uint8Array(2);
|
||||
u16w(data, 0, v);
|
||||
return { tag, type: SHORT, count: 1, data };
|
||||
}
|
||||
|
||||
function longEntry(tag: number, v: number): Entry {
|
||||
const data = new Uint8Array(4);
|
||||
u32w(data, 0, v);
|
||||
return { tag, type: LONG, count: 1, data };
|
||||
}
|
||||
|
||||
function rationalEntry(tag: number, num: number, den: number, type = RATIONAL): Entry {
|
||||
const data = new Uint8Array(8);
|
||||
u32w(data, 0, num);
|
||||
u32w(data, 4, den);
|
||||
return { tag, type, count: 1, data };
|
||||
}
|
||||
|
||||
// GPS coordinate: 3 RATIONALs (degrees, minutes, seconds), denominator 10000
|
||||
// for the seconds so ~0.1 m survives.
|
||||
function gpsCoordEntry(tag: number, value: number): Entry {
|
||||
const abs = Math.abs(value);
|
||||
let deg = Math.floor(abs);
|
||||
let min = Math.floor((abs - deg) * 60);
|
||||
let sec = Math.round(((abs - deg) * 60 - min) * 60 * 10000);
|
||||
if (sec >= 600000) { sec = 0; min += 1; }
|
||||
if (min >= 60) { min = 0; deg += 1; }
|
||||
const data = new Uint8Array(24);
|
||||
u32w(data, 0, deg); u32w(data, 4, 1);
|
||||
u32w(data, 8, min); u32w(data, 12, 1);
|
||||
u32w(data, 16, sec); u32w(data, 20, 10000);
|
||||
return { tag, type: RATIONAL, count: 3, data };
|
||||
}
|
||||
|
||||
// A big-endian source TIFF hands us big-endian value bytes; they are re-emitted
|
||||
// into a little-endian TIFF, so every numeric value must be swapped first
|
||||
// (ASCII/BYTE/UNDEFINED are byte strings and stay as they are).
|
||||
function swapToLittleEndian(data: Uint8Array, type: number): Uint8Array {
|
||||
const unit = type === SHORT ? 2 : type === LONG || type === SLONG ? 4 : type === RATIONAL || type === SRATIONAL ? 4 : 0;
|
||||
if (!unit) return data;
|
||||
const out = data.slice();
|
||||
for (let p = 0; p + unit <= out.length; p += unit) {
|
||||
for (let i = 0; i < unit >> 1; i++) {
|
||||
const a = out[p + i];
|
||||
out[p + i] = out[p + unit - 1 - i];
|
||||
out[p + unit - 1 - i] = a;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asciiValue(e: Entry | undefined): string | null {
|
||||
if (!e || e.type !== ASCII || e.data.length === 0) return null;
|
||||
let s = '';
|
||||
for (let i = 0; i < e.data.length && e.data[i] !== 0; i++) s += String.fromCharCode(e.data[i]);
|
||||
return s || null;
|
||||
}
|
||||
|
||||
// "YYYY:MM:DD HH:MM:SS" — the EXIF date format (colons, not dashes).
|
||||
function exifDate(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}:${p(d.getMonth() + 1)}:${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
// --- parse -----------------------------------------------------------------
|
||||
|
||||
interface ParsedTiff {
|
||||
ifd0: Entry[];
|
||||
exif: Entry[];
|
||||
gps: Entry[];
|
||||
}
|
||||
|
||||
function parseTiff(tiff: Uint8Array): ParsedTiff {
|
||||
const none: ParsedTiff = { ifd0: [], exif: [], gps: [] };
|
||||
if (tiff.length < 8) return none;
|
||||
const le = tiff[0] === 0x49 && tiff[1] === 0x49;
|
||||
const be = tiff[0] === 0x4d && tiff[1] === 0x4d;
|
||||
if (!le && !be) return none;
|
||||
|
||||
const u16 = (p: number): number => (le ? tiff[p] | (tiff[p + 1] << 8) : (tiff[p] << 8) | tiff[p + 1]);
|
||||
const u32 = (p: number): number =>
|
||||
le
|
||||
? (tiff[p] | (tiff[p + 1] << 8) | (tiff[p + 2] << 16) | (tiff[p + 3] << 24)) >>> 0
|
||||
: ((tiff[p] << 24) | (tiff[p + 1] << 16) | (tiff[p + 2] << 8) | tiff[p + 3]) >>> 0;
|
||||
|
||||
const read = (off: number): { list: Entry[]; ptr: Record<number, number> } => {
|
||||
const out: { list: Entry[]; ptr: Record<number, number> } = { list: [], ptr: {} };
|
||||
if (off <= 0 || off + 2 > tiff.length) return out;
|
||||
const n = u16(off);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = off + 2 + i * 12;
|
||||
if (p + 12 > tiff.length) break;
|
||||
const tag = u16(p);
|
||||
const type = u16(p + 2);
|
||||
const count = u32(p + 4);
|
||||
if (tag === 0x8769 || tag === 0x8825) {
|
||||
out.ptr[tag] = u32(p + 8);
|
||||
continue;
|
||||
}
|
||||
const size = TYPE_SIZE[type];
|
||||
if (!size || DROP.has(tag)) continue;
|
||||
const len = size * count;
|
||||
if (len <= 0 || len > 4 * 1024 * 1024) continue; // ignore absurd/overflowing counts
|
||||
if (len <= 4) {
|
||||
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(tiff.slice(p + 8, p + 8 + len), type) : tiff.slice(p + 8, p + 8 + len) });
|
||||
} else {
|
||||
const o = u32(p + 8);
|
||||
if (o + len > tiff.length) continue;
|
||||
const raw = tiff.slice(o, o + len);
|
||||
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(raw, type) : raw });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const a = read(u32(4));
|
||||
return {
|
||||
ifd0: a.list,
|
||||
exif: a.ptr[0x8769] ? read(a.ptr[0x8769]).list : [],
|
||||
gps: a.ptr[0x8825] ? read(a.ptr[0x8825]).list : [],
|
||||
};
|
||||
}
|
||||
|
||||
// Locate the APP1/EXIF segment: start/end bound the whole segment, tiff the
|
||||
// payload after the "Exif\0\0" signature.
|
||||
function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff: Uint8Array } | null {
|
||||
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return null;
|
||||
let i = 2;
|
||||
while (i + 1 < jpeg.length && jpeg[i] === 0xff) {
|
||||
const start = i;
|
||||
while (jpeg[i] === 0xff) i++;
|
||||
const marker = jpeg[i];
|
||||
i++;
|
||||
if (marker === 0xd9 || marker === 0xda) return null; // EOI / SOS — headers only
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
||||
if (i + 2 > jpeg.length) return null;
|
||||
const len = (jpeg[i] << 8) | jpeg[i + 1];
|
||||
if (len < 2 || i + len > jpeg.length) return null;
|
||||
const dataStart = i + 2;
|
||||
if (
|
||||
marker === 0xe1 && len >= 8 &&
|
||||
jpeg[dataStart] === 0x45 && jpeg[dataStart + 1] === 0x78 && jpeg[dataStart + 2] === 0x69 &&
|
||||
jpeg[dataStart + 3] === 0x66 && jpeg[dataStart + 4] === 0x00 && jpeg[dataStart + 5] === 0x00
|
||||
) {
|
||||
return { start, end: i + len, tiff: jpeg.subarray(dataStart + 6, i + len) };
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- serialize -------------------------------------------------------------
|
||||
|
||||
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[]): Uint8Array {
|
||||
const ifdLen = (n: number) => 2 + n * 12 + 4;
|
||||
const outLen = (es: Entry[]) => {
|
||||
let s = 0;
|
||||
for (const e of es) if (e.data.length > 4) s += (e.data.length + 1) & ~1;
|
||||
return s;
|
||||
};
|
||||
|
||||
const o0 = 8;
|
||||
const d0 = o0 + ifdLen(ifd0.length);
|
||||
const oE = d0 + outLen(ifd0);
|
||||
const dE = oE + ifdLen(exif.length);
|
||||
const oG = dE + outLen(exif);
|
||||
const dG = oG + ifdLen(gps.length);
|
||||
const t = new Uint8Array(dG + outLen(gps));
|
||||
|
||||
t[0] = 0x49; t[1] = 0x49; t[2] = 0x2a; t[3] = 0x00; // "II", 42
|
||||
u32w(t, 4, o0);
|
||||
|
||||
const writeIfd = (off: number, es: Entry[], dataOff: number): void => {
|
||||
u16w(t, off, es.length);
|
||||
let c = dataOff;
|
||||
es.forEach((e, i) => {
|
||||
const p = off + 2 + i * 12;
|
||||
u16w(t, p, e.tag);
|
||||
u16w(t, p + 2, e.type);
|
||||
u32w(t, p + 4, e.count);
|
||||
if (e.ref === 'exif') { u32w(t, p + 8, oE); return; }
|
||||
if (e.ref === 'gps') { u32w(t, p + 8, oG); return; }
|
||||
if (e.data.length <= 4) { t.set(e.data, p + 8); return; }
|
||||
u32w(t, p + 8, c);
|
||||
t.set(e.data, c);
|
||||
c += (e.data.length + 1) & ~1; // values start on a word boundary
|
||||
});
|
||||
u32w(t, off + 2 + es.length * 12, 0); // no IFD1
|
||||
};
|
||||
|
||||
writeIfd(o0, ifd0, d0);
|
||||
writeIfd(oE, exif, dE);
|
||||
writeIfd(oG, gps, dG);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the JPEG's APP1/EXIF block: `sourceJpeg` (the loaded photo) donates
|
||||
* every tag this module does not own; `stamp` sets the ones the app owns.
|
||||
* Returns a new array; input is never modified. A JPEG that cannot take the
|
||||
* segment (not a JPEG, EXIF larger than 64 KB) comes back untouched.
|
||||
*/
|
||||
export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: Uint8Array | null): Uint8Array {
|
||||
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return jpeg;
|
||||
|
||||
// An unreadable source (content:// uri, IO error) must not wipe the tags the
|
||||
// jpeg already carries, so fall back to the jpeg's own EXIF block.
|
||||
const src = findExifSegment(sourceJpeg && sourceJpeg.length > 4 ? sourceJpeg : jpeg);
|
||||
const parsed = src ? parseTiff(src.tiff) : { ifd0: [], exif: [], gps: [] };
|
||||
const ifd0 = new Map(parsed.ifd0.map((e) => [e.tag, e]));
|
||||
const exif = new Map(parsed.exif.map((e) => [e.tag, e]));
|
||||
const gps = new Map(parsed.gps.map((e) => [e.tag, e]));
|
||||
|
||||
const now = exifDate(stamp.dateTime ?? new Date());
|
||||
const keepSourceDate = !stamp.dateTime;
|
||||
const sourceDate = asciiValue(ifd0.get(0x0132)) ?? asciiValue(exif.get(0x9003));
|
||||
const dateStr = keepSourceDate ? sourceDate ?? now : now;
|
||||
|
||||
if (!keepSourceDate || !ifd0.has(0x0132)) ifd0.set(0x0132, asciiEntry(0x0132, dateStr));
|
||||
if (!keepSourceDate || !exif.has(0x9003)) exif.set(0x9003, asciiEntry(0x9003, dateStr));
|
||||
if (!keepSourceDate || !exif.has(0x9004)) exif.set(0x9004, asciiEntry(0x9004, dateStr));
|
||||
ifd0.set(0x0131, asciiEntry(0x0131, stamp.software ?? SOFTWARE));
|
||||
ifd0.set(0x0112, shortEntry(0x0112, 1)); // upright — the pixels are already straight
|
||||
|
||||
if (stamp.width && stamp.height) {
|
||||
ifd0.set(0x0100, longEntry(0x0100, stamp.width));
|
||||
ifd0.set(0x0101, longEntry(0x0101, stamp.height));
|
||||
exif.set(0xa002, longEntry(0xa002, stamp.width)); // PixelXDimension
|
||||
exif.set(0xa003, longEntry(0xa003, stamp.height)); // PixelYDimension
|
||||
}
|
||||
|
||||
if (stamp.dpi && stamp.dpi > 0) {
|
||||
ifd0.set(0x011a, rationalEntry(0x011a, stamp.dpi, 1));
|
||||
ifd0.set(0x011b, rationalEntry(0x011b, stamp.dpi, 1));
|
||||
ifd0.set(0x0128, shortEntry(0x0128, 2)); // resolution unit = inch
|
||||
}
|
||||
|
||||
if (stamp.gps) {
|
||||
const { latitude: lat, longitude: lon } = stamp.gps;
|
||||
gps.set(0x0001, asciiEntry(0x0001, lat >= 0 ? 'N' : 'S'));
|
||||
gps.set(0x0002, gpsCoordEntry(0x0002, lat));
|
||||
gps.set(0x0003, asciiEntry(0x0003, lon >= 0 ? 'E' : 'W'));
|
||||
gps.set(0x0004, gpsCoordEntry(0x0004, lon));
|
||||
}
|
||||
|
||||
const list0 = [...ifd0.values()];
|
||||
if (exif.size > 0) list0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4), ref: 'exif' });
|
||||
if (gps.size > 0) list0.push({ tag: 0x8825, type: LONG, count: 1, data: new Uint8Array(4), ref: 'gps' });
|
||||
list0.sort((a, b) => a.tag - b.tag);
|
||||
const listE = [...exif.values()].sort((a, b) => a.tag - b.tag);
|
||||
const listG = [...gps.values()].sort((a, b) => a.tag - b.tag);
|
||||
|
||||
const tiff = serializeTiff(list0, listE, listG);
|
||||
const payload = 2 + 6 + tiff.length;
|
||||
if (payload > 0xffff) return jpeg; // APP1 length is a 16-bit field
|
||||
|
||||
const app1 = new Uint8Array(2 + payload);
|
||||
app1[0] = 0xff; app1[1] = 0xe1;
|
||||
// APP1 length is a big-endian 16-bit field (the TIFF block after it is little-endian)
|
||||
app1[2] = (payload >> 8) & 0xff;
|
||||
app1[3] = payload & 0xff;
|
||||
app1.set([0x45, 0x78, 0x69, 0x66, 0x00, 0x00], 4); // "Exif\0\0"
|
||||
app1.set(tiff, 10);
|
||||
|
||||
const old = findExifSegment(jpeg);
|
||||
const head = old ? jpeg.subarray(0, old.start) : jpeg.subarray(0, 2);
|
||||
const tail = old ? jpeg.subarray(old.end) : jpeg.subarray(2);
|
||||
const out = new Uint8Array(head.length + app1.length + tail.length);
|
||||
out.set(head, 0);
|
||||
out.set(app1, head.length);
|
||||
out.set(tail, head.length + app1.length);
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user