Files
CLProject/public/js/exif.js
T
2026-04-20 21:04:54 +02:00

284 lines
10 KiB
JavaScript

/*
* exif.js — Lightweight EXIF parser for JPEG images in the browser.
*
* Extracts basic EXIF metadata (camera make/model, date taken, GPS coords,
* orientation, dimensions) from a base64 dataUrl or ArrayBuffer.
*
* This is a minimal parser — it handles the most common IFD0 and GPS tags
* without pulling in a full library.
*/
/* ── EXIF Tag IDs ───────────────────────────────────────────────────────── */
const TAGS = {
0x010F: 'make',
0x0110: 'model',
0x0112: 'orientation',
0x011A: 'xResolution',
0x011B: 'yResolution',
0x0132: 'dateTime',
0x8769: 'exifOffset',
0x8825: 'gpsOffset',
0xA002: 'pixelXDimension',
0xA003: 'pixelYDimension',
0x9003: 'dateTimeOriginal',
0x9004: 'dateTimeDigitized',
0x920A: 'focalLength',
0x829A: 'exposureTime',
0x829D: 'fNumber',
0x8827: 'isoSpeed'
};
const GPS_TAGS = {
0x0001: 'latRef',
0x0002: 'lat',
0x0003: 'lonRef',
0x0004: 'lon',
0x0005: 'altRef',
0x0006: 'alt'
};
/* ── Public API ─────────────────────────────────────────────────────────── */
/**
* Parse EXIF from a base64 dataUrl string.
* Returns an object with parsed tags, or null if no EXIF found.
*/
export function parseExifFromDataUrl(dataUrl) {
if (!dataUrl || !dataUrl.startsWith('data:image/jpeg')) return null;
try {
const base64 = dataUrl.split(',')[1];
const binary = atob(base64);
const buffer = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buffer[i] = binary.charCodeAt(i);
return parseExif(buffer.buffer);
} catch {
return null;
}
}
/**
* Parse EXIF from an ArrayBuffer.
* Returns an object with parsed tags, or null if no EXIF found.
*/
export function parseExif(arrayBuffer) {
const view = new DataView(arrayBuffer);
/* Check JPEG SOI marker */
if (view.getUint16(0) !== 0xFFD8) return null;
/* Find APP1 marker (EXIF) */
let offset = 2;
while (offset < view.byteLength - 4) {
const marker = view.getUint16(offset);
if (marker === 0xFFE1) {
/* Found APP1 */
const length = view.getUint16(offset + 2);
return parseExifData(view, offset + 4, length - 2);
}
/* Skip to next marker */
if ((marker & 0xFF00) !== 0xFF00) break;
const segLen = view.getUint16(offset + 2);
offset += 2 + segLen;
}
return null;
}
/* ── Internal parsing ───────────────────────────────────────────────────── */
function parseExifData(view, start, length) {
/* Check "Exif\0\0" header */
const exifStr = String.fromCharCode(
view.getUint8(start), view.getUint8(start + 1),
view.getUint8(start + 2), view.getUint8(start + 3)
);
if (exifStr !== 'Exif') return null;
const tiffStart = start + 6;
const byteOrder = view.getUint16(tiffStart);
const littleEndian = byteOrder === 0x4949; /* 'II' = Intel = little-endian */
/* Verify TIFF magic number */
if (view.getUint16(tiffStart + 2, littleEndian) !== 0x002A) return null;
const ifd0Offset = view.getUint32(tiffStart + 4, littleEndian);
const result = {};
/* Parse IFD0 */
const ifd0 = parseIFD(view, tiffStart, tiffStart + ifd0Offset, littleEndian, TAGS);
Object.assign(result, ifd0);
/* Parse EXIF sub-IFD if present */
if (ifd0.exifOffset) {
const exifIfd = parseIFD(view, tiffStart, tiffStart + ifd0.exifOffset, littleEndian, TAGS);
delete result.exifOffset;
Object.assign(result, exifIfd);
}
/* Parse GPS IFD if present */
if (ifd0.gpsOffset) {
const gpsIfd = parseIFD(view, tiffStart, tiffStart + ifd0.gpsOffset, littleEndian, GPS_TAGS);
delete result.gpsOffset;
if (gpsIfd.lat && gpsIfd.latRef) {
result.latitude = convertDMSToDD(gpsIfd.lat, gpsIfd.latRef);
}
if (gpsIfd.lon && gpsIfd.lonRef) {
result.longitude = convertDMSToDD(gpsIfd.lon, gpsIfd.lonRef);
}
if (gpsIfd.alt != null) {
result.altitude = gpsIfd.altRef === 1 ? -gpsIfd.alt : gpsIfd.alt;
}
}
/* Clean up internal offsets */
delete result.exifOffset;
delete result.gpsOffset;
return Object.keys(result).length ? result : null;
}
function parseIFD(view, tiffStart, ifdStart, littleEndian, tagMap) {
const result = {};
try {
const entries = view.getUint16(ifdStart, littleEndian);
for (let i = 0; i < entries; i++) {
const entryOffset = ifdStart + 2 + (i * 12);
const tag = view.getUint16(entryOffset, littleEndian);
const tagName = tagMap[tag];
if (!tagName) continue;
const type = view.getUint16(entryOffset + 2, littleEndian);
const count = view.getUint32(entryOffset + 4, littleEndian);
const valueOffset = entryOffset + 8;
result[tagName] = readTagValue(view, tiffStart, type, count, valueOffset, littleEndian);
}
} catch {
/* Gracefully handle malformed EXIF */
}
return result;
}
function readTagValue(view, tiffStart, type, count, valueOffset, littleEndian) {
/* Type: 1=BYTE, 2=ASCII, 3=SHORT, 4=LONG, 5=RATIONAL, 7=UNDEFINED, 10=SRATIONAL */
const typeSize = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
const totalBytes = (typeSize[type] || 1) * count;
const dataOffset = totalBytes > 4
? tiffStart + view.getUint32(valueOffset, littleEndian)
: valueOffset;
switch (type) {
case 2: { /* ASCII string */
let str = '';
for (let i = 0; i < count - 1; i++) str += String.fromCharCode(view.getUint8(dataOffset + i));
return str.trim();
}
case 3: /* SHORT */
return count === 1 ? view.getUint16(dataOffset, littleEndian) : readArray(view, dataOffset, count, 2, littleEndian);
case 4: /* LONG */
return count === 1 ? view.getUint32(dataOffset, littleEndian) : readArray(view, dataOffset, count, 4, littleEndian);
case 5: /* RATIONAL (unsigned) */
if (count === 1) {
const num = view.getUint32(dataOffset, littleEndian);
const den = view.getUint32(dataOffset + 4, littleEndian);
return den ? num / den : 0;
}
return readRationalArray(view, dataOffset, count, littleEndian);
case 10: /* SRATIONAL (signed) */
if (count === 1) {
const num = view.getInt32(dataOffset, littleEndian);
const den = view.getInt32(dataOffset + 4, littleEndian);
return den ? num / den : 0;
}
return readRationalArray(view, dataOffset, count, littleEndian);
default:
return count === 1 ? view.getUint8(dataOffset) : null;
}
}
function readArray(view, offset, count, size, littleEndian) {
const arr = [];
for (let i = 0; i < count; i++) {
arr.push(size === 2 ? view.getUint16(offset + i * size, littleEndian) : view.getUint32(offset + i * size, littleEndian));
}
return arr;
}
function readRationalArray(view, offset, count, littleEndian) {
const arr = [];
for (let i = 0; i < count; i++) {
const num = view.getUint32(offset + i * 8, littleEndian);
const den = view.getUint32(offset + i * 8 + 4, littleEndian);
arr.push(den ? num / den : 0);
}
return arr;
}
function convertDMSToDD(dms, ref) {
if (!Array.isArray(dms) || dms.length < 3) return null;
let dd = dms[0] + dms[1] / 60 + dms[2] / 3600;
if (ref === 'S' || ref === 'W') dd = -dd;
return Math.round(dd * 1000000) / 1000000;
}
/* ═══════════════════════════════════════════════════════════════════════════
* EXIF preservation: extract raw APP1 segment and re-inject into JPEG
* ═══════════════════════════════════════════════════════════════════════════ */
/**
* Extract the raw EXIF APP1 segment (including FF E1 marker + length) from a
* JPEG ArrayBuffer. Returns a Uint8Array of the full segment, or null if no
* EXIF is found. This raw segment can be re-injected into a new JPEG to
* preserve metadata after canvas operations.
*/
export function extractExifSegment(arrayBuffer) {
const view = new DataView(arrayBuffer);
if (view.byteLength < 4) return null;
/* Check JPEG SOI marker */
if (view.getUint16(0) !== 0xFFD8) return null;
let offset = 2;
while (offset < view.byteLength - 4) {
const marker = view.getUint16(offset);
if (marker === 0xFFE1) {
/* APP1 found — extract the entire segment (marker + length + data) */
const segmentLength = view.getUint16(offset + 2);
const totalLength = 2 + segmentLength; /* marker (2) + length field included in segmentLength */
if (offset + totalLength > view.byteLength) return null;
/* Return a standalone copy so it survives GC of the original buffer */
return new Uint8Array(arrayBuffer.slice(offset, offset + totalLength));
}
/* Not APP1 — skip to next marker */
if ((marker & 0xFF00) !== 0xFF00) break;
const segLen = view.getUint16(offset + 2);
offset += 2 + segLen;
}
return null;
}
/**
* Insert a raw APP1 EXIF segment into a JPEG Blob that lacks one.
* Places the APP1 segment immediately after the SOI marker (FF D8).
* Returns a new Blob with EXIF restored, or the original blob if it's not JPEG
* or if exifSegment is null.
*/
export async function insertExifIntoJpeg(jpegBlob, exifSegment) {
if (!exifSegment || !jpegBlob || jpegBlob.type !== 'image/jpeg') return jpegBlob;
const buffer = await jpegBlob.arrayBuffer();
const view = new DataView(buffer);
if (view.getUint16(0) !== 0xFFD8) return jpegBlob;
/* Build new JPEG: SOI (2 bytes) + EXIF segment + rest of original after SOI */
const soi = new Uint8Array(buffer, 0, 2);
const rest = new Uint8Array(buffer, 2);
const merged = new Uint8Array(soi.length + exifSegment.length + rest.length);
merged.set(soi, 0);
merged.set(exifSegment, 2);
merged.set(rest, 2 + exifSegment.length);
return new Blob([merged], { type: 'image/jpeg' });
}