/* * 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' }); }