EXIF & Metadata Stripper — TypeScript source
View and strip GPS, camera, date, and software metadata from photos entirely in your browser. Download a clean copy.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// EXIF / XMP / IPTC metadata reader + stripper for JPEG and PNG buffers.
//
// Pure ArrayBuffer logic - no DOM, no external libraries. The binary formats
// are parsed directly:
// - JPEG: APP1 (0xFFE1) segments carrying "Exif\0\0" (a TIFF IFD structure,
// big- or little-endian per the TIFF byte-order mark) or XMP, and APP13
// (0xFFED) segments carrying IPTC ("Photoshop 3.0\0").
// - PNG: tEXt / iTXt text chunks and the eXIf chunk (same TIFF structure).
//
// Stripping rebuilds the file without those metadata segments - the image
// data itself is copied byte-for-byte, so JPEG stripping is lossless.
export interface ExifMetadata {
cameraMake?: string;
cameraModel?: string;
software?: string;
dateTime?: string;
/** Decimal degrees; negative for southern / western hemispheres. */
gpsLatitude?: number;
gpsLongitude?: number;
imageWidth?: number;
imageHeight?: number;
/** EXIF orientation value 1-8. */
orientation?: number;
/** Seconds (e.g. 0.004 = 1/250 s). */
exposureTime?: number;
/** F-number (e.g. 2.8). */
fNumber?: number;
iso?: number;
/** Millimetres. */
focalLength?: number;
/** A rebuilt copy of the input with every metadata segment removed. */
stripped: ArrayBuffer;
}
/** Degrees/minutes/seconds + hemisphere reference to signed decimal degrees. */
export function formatGpsCoordinate(degrees: number[], ref: string): number {
const [deg = 0, min = 0, sec = 0] = degrees;
const decimal = deg + min / 60 + sec / 3600;
return ref === 'S' || ref === 'W' ? -decimal : decimal;
}
// --- format detection ---------------------------------------------------------
const NUL = String.fromCharCode(0);
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const;
const EXIF_SIGNATURE = 'Exif' + NUL + NUL;
const XMP_SIGNATURES = ['http://ns.adobe.com/xap/1.0/', 'http://ns.adobe.com/xmp/extension/'];
const IPTC_SIGNATURE = 'Photoshop 3.0' + NUL;
function isJpeg(bytes: Uint8Array): boolean {
return bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8;
}
function isPng(bytes: Uint8Array): boolean {
return bytes.length >= 8 && PNG_SIGNATURE.every((b, i) => bytes[i] === b);
}
function hasSignature(bytes: Uint8Array, off: number, sig: string): boolean {
if (off < 0 || off + sig.length > bytes.length) return false;
for (let i = 0; i < sig.length; i++) {
if (bytes[off + i] !== sig.charCodeAt(i)) return false;
}
return true;
}
const utf8 = new TextDecoder();
// --- JPEG segment walk --------------------------------------------------------
interface JpegSegment {
marker: number;
/** Segment start (at the 0xFF byte), end exclusive. */
start: number;
end: number;
/** First payload byte (right after the 2-byte length field). */
payloadStart: number;
}
// Walks length-prefixed markers from just after the SOI up to (and including)
// the SOS marker, whose entropy-coded data runs to the EOI. Returns null when
// the structure is corrupted mid-walk.
function collectJpegSegments(bytes: Uint8Array): JpegSegment[] | null {
const segments: JpegSegment[] = [];
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let off = 2;
while (off + 2 <= bytes.length) {
if (bytes[off] !== 0xff) return null;
if (bytes[off + 1] === 0xff) {
off++; // padding fill byte before the real marker
continue;
}
const marker = bytes[off + 1];
// Standalone markers carry no length field (TEM, RSTn, SOI, EOI).
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
segments.push({ marker, start: off, end: off + 2, payloadStart: off + 2 });
if (marker === 0xd9) break;
off += 2;
continue;
}
if (off + 4 > bytes.length) return null;
const size = view.getUint16(off + 2); // includes the 2 length bytes itself
if (size < 2) return null;
const end = off + 2 + size;
if (end > bytes.length) return null;
segments.push({ marker, start: off, end, payloadStart: off + 4 });
if (marker === 0xda) break; // SOS - entropy data follows, no more segments
off = end;
}
return segments;
}
function isJpegMetadataSegment(bytes: Uint8Array, seg: JpegSegment): boolean {
if (seg.marker === 0xe1) {
return (
hasSignature(bytes, seg.payloadStart, EXIF_SIGNATURE) ||
XMP_SIGNATURES.some((sig) => hasSignature(bytes, seg.payloadStart, sig))
);
}
// APP13 is IPTC ("Photoshop 3.0\0" + 8BIM records) - always metadata.
return seg.marker === 0xed && hasSignature(bytes, seg.payloadStart, IPTC_SIGNATURE);
}
// --- TIFF / EXIF IFD parsing --------------------------------------------------
// EXIF field types -> byte size per component.
const TYPE_SIZE: Record<number, number> = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
// IFD0 tags.
const TAG_IMAGE_WIDTH = 0x0100;
const TAG_IMAGE_HEIGHT = 0x0101;
const TAG_MAKE = 0x010f;
const TAG_MODEL = 0x0110;
const TAG_ORIENTATION = 0x0112;
const TAG_SOFTWARE = 0x0131;
const TAG_DATE_TIME = 0x0132;
const TAG_EXIF_IFD_POINTER = 0x8769;
const TAG_GPS_IFD_POINTER = 0x8825;
// Exif SubIFD tags.
const TAG_EXPOSURE_TIME = 0x829a;
const TAG_F_NUMBER = 0x829d;
const TAG_ISO = 0x8827;
const TAG_DATE_TIME_ORIGINAL = 0x9003;
const TAG_FOCAL_LENGTH = 0x920a;
// GPS IFD tags.
const TAG_GPS_LAT_REF = 0x0001;
const TAG_GPS_LAT = 0x0002;
const TAG_GPS_LON_REF = 0x0003;
const TAG_GPS_LON = 0x0004;
const TYPE_ASCII = 2;
const TYPE_SHORT = 3;
const TYPE_LONG = 4;
const TYPE_RATIONAL = 5;
interface Tiff {
bytes: Uint8Array;
view: DataView;
little: boolean;
}
// Where the entry's value lives: inline in the 4-byte value field when it
// fits, else at the recorded byte offset. Returns -1 when out of bounds.
function valueOffset(t: Tiff, entry: number, type: number, count: number): number {
const size = (TYPE_SIZE[type] ?? 1) * count;
const off = size <= 4 ? entry + 8 : t.view.getUint32(entry + 8, t.little);
if (off < 0 || off + size > t.bytes.length) return -1;
return off;
}
function readAscii(t: Tiff, entry: number, type: number, count: number): string | undefined {
if (type !== TYPE_ASCII || count < 1) return undefined;
const off = valueOffset(t, entry, type, count);
if (off < 0) return undefined;
const str = utf8.decode(t.bytes.subarray(off, off + count)).replace(/\0+$/, '').trim();
return str || undefined;
}
function readNumber(t: Tiff, entry: number, type: number, count: number): number | undefined {
if (count < 1) return undefined;
const off = valueOffset(t, entry, type, count);
if (off < 0) return undefined;
if (type === TYPE_SHORT) return t.view.getUint16(off, t.little);
if (type === TYPE_LONG) return t.view.getUint32(off, t.little);
return undefined;
}
function readRationals(t: Tiff, entry: number, type: number, count: number): number[] | undefined {
if (type !== TYPE_RATIONAL || count < 1) return undefined;
const off = valueOffset(t, entry, type, count);
if (off < 0) return undefined;
const values: number[] = [];
for (let i = 0; i < count; i++) {
const num = t.view.getUint32(off + i * 8, t.little);
const den = t.view.getUint32(off + i * 8 + 4, t.little);
values.push(den === 0 ? 0 : num / den);
}
return values;
}
function parseTiff(tiffBytes: Uint8Array, meta: ExifMetadata): void {
if (tiffBytes.length < 8) return;
let little: boolean;
if (tiffBytes[0] === 0x49 && tiffBytes[1] === 0x49) little = true; // "II"
else if (tiffBytes[0] === 0x4d && tiffBytes[1] === 0x4d) little = false; // "MM"
else return;
const t: Tiff = {
bytes: tiffBytes,
view: new DataView(tiffBytes.buffer, tiffBytes.byteOffset, tiffBytes.byteLength),
little,
};
if (t.view.getUint16(2, little) !== 42) return;
readIfd(t, t.view.getUint32(4, little), meta);
}
function readIfd(t: Tiff, offset: number, meta: ExifMetadata): void {
if (offset < 8 || offset + 2 > t.bytes.length) return;
const count = t.view.getUint16(offset, t.little);
const end = offset + 2 + count * 12;
if (end > t.bytes.length) return;
for (let i = 0; i < count; i++) {
const entry = offset + 2 + i * 12;
const tag = t.view.getUint16(entry, t.little);
const type = t.view.getUint16(entry + 2, t.little);
const n = t.view.getUint32(entry + 4, t.little);
switch (tag) {
case TAG_IMAGE_WIDTH:
meta.imageWidth ??= readNumber(t, entry, type, n);
break;
case TAG_IMAGE_HEIGHT:
meta.imageHeight ??= readNumber(t, entry, type, n);
break;
case TAG_MAKE:
meta.cameraMake ??= readAscii(t, entry, type, n);
break;
case TAG_MODEL:
meta.cameraModel ??= readAscii(t, entry, type, n);
break;
case TAG_ORIENTATION:
meta.orientation ??= readNumber(t, entry, type, n);
break;
case TAG_SOFTWARE:
meta.software ??= readAscii(t, entry, type, n);
break;
case TAG_DATE_TIME:
meta.dateTime ??= readAscii(t, entry, type, n);
break;
case TAG_EXIF_IFD_POINTER: {
const sub = readNumber(t, entry, TYPE_LONG, 1);
if (sub !== undefined) readExifIfd(t, sub, meta);
break;
}
case TAG_GPS_IFD_POINTER: {
const gps = readNumber(t, entry, TYPE_LONG, 1);
if (gps !== undefined) readGpsIfd(t, gps, meta);
break;
}
}
}
}
function readExifIfd(t: Tiff, offset: number, meta: ExifMetadata): void {
if (offset < 8 || offset + 2 > t.bytes.length) return;
const count = t.view.getUint16(offset, t.little);
const end = offset + 2 + count * 12;
if (end > t.bytes.length) return;
for (let i = 0; i < count; i++) {
const entry = offset + 2 + i * 12;
const tag = t.view.getUint16(entry, t.little);
const type = t.view.getUint16(entry + 2, t.little);
const n = t.view.getUint32(entry + 4, t.little);
switch (tag) {
case TAG_EXPOSURE_TIME: {
const [value] = readRationals(t, entry, type, n) ?? [];
meta.exposureTime ??= value;
break;
}
case TAG_F_NUMBER: {
const [value] = readRationals(t, entry, type, n) ?? [];
meta.fNumber ??= value;
break;
}
case TAG_ISO:
meta.iso ??= readNumber(t, entry, type, n);
break;
case TAG_DATE_TIME_ORIGINAL:
// Phone photos usually carry the real capture time here only.
meta.dateTime ??= readAscii(t, entry, type, n);
break;
case TAG_FOCAL_LENGTH: {
const [value] = readRationals(t, entry, type, n) ?? [];
meta.focalLength ??= value;
break;
}
}
}
}
function readGpsIfd(t: Tiff, offset: number, meta: ExifMetadata): void {
if (offset < 8 || offset + 2 > t.bytes.length) return;
const count = t.view.getUint16(offset, t.little);
const end = offset + 2 + count * 12;
if (end > t.bytes.length) return;
let latRef = 'N';
let lonRef = 'E';
let lat: number[] | undefined;
let lon: number[] | undefined;
for (let i = 0; i < count; i++) {
const entry = offset + 2 + i * 12;
const tag = t.view.getUint16(entry, t.little);
const type = t.view.getUint16(entry + 2, t.little);
const n = t.view.getUint32(entry + 4, t.little);
switch (tag) {
case TAG_GPS_LAT_REF: {
const ref = readAscii(t, entry, type, n);
if (ref) latRef = ref.charAt(0).toUpperCase();
break;
}
case TAG_GPS_LAT:
lat = readRationals(t, entry, type, n);
break;
case TAG_GPS_LON_REF: {
const ref = readAscii(t, entry, type, n);
if (ref) lonRef = ref.charAt(0).toUpperCase();
break;
}
case TAG_GPS_LON:
lon = readRationals(t, entry, type, n);
break;
}
}
if (lat) meta.gpsLatitude ??= formatGpsCoordinate(lat, latRef);
if (lon) meta.gpsLongitude ??= formatGpsCoordinate(lon, lonRef);
}
// XMP is XML; the only field we surface is the editing software / creator
// tool, which appears as e.g. <xmp:CreatorTool>Pixelmator Pro</...>.
function parseXmp(payload: Uint8Array, meta: ExifMetadata): void {
const text = utf8.decode(payload);
const match = text.match(/:(?:CreatorTool|Software)>([^<]+)</);
if (match) meta.software ??= match[1].trim();
}
// --- JPEG parse ---------------------------------------------------------------
function parseJpeg(bytes: Uint8Array, meta: ExifMetadata): void {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
for (const seg of collectJpegSegments(bytes) ?? []) {
if (seg.marker === 0xe1) {
const payload = bytes.subarray(seg.payloadStart, seg.end);
if (hasSignature(bytes, seg.payloadStart, EXIF_SIGNATURE)) {
parseTiff(payload.subarray(EXIF_SIGNATURE.length), meta);
} else if (XMP_SIGNATURES.some((sig) => hasSignature(bytes, seg.payloadStart, sig))) {
parseXmp(payload, meta);
}
} else if (
seg.marker >= 0xc0 &&
seg.marker <= 0xcf &&
seg.marker !== 0xc4 &&
seg.marker !== 0xc8 &&
seg.marker !== 0xcc &&
seg.end - seg.payloadStart >= 5
) {
// Start-of-frame header: precision(1), height(2), width(2), big-endian.
meta.imageHeight ??= view.getUint16(seg.payloadStart + 1);
meta.imageWidth ??= view.getUint16(seg.payloadStart + 3);
}
}
}
// --- PNG parse ----------------------------------------------------------------
function parsePng(bytes: Uint8Array, meta: ExifMetadata): void {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let off = 8;
while (off + 8 <= bytes.length) {
const length = view.getUint32(off);
if (length > bytes.length - off - 12) break; // corrupted chunk
const type = utf8.decode(bytes.subarray(off + 4, off + 8));
const data = bytes.subarray(off + 8, off + 8 + length);
if (type === 'IHDR') {
meta.imageWidth ??= view.getUint32(off + 8);
meta.imageHeight ??= view.getUint32(off + 12);
} else if (type === 'tEXt' || type === 'iTXt') {
parsePngTextChunk(type, data, meta);
} else if (type === 'eXIf') {
parseTiff(data, meta);
}
if (type === 'IEND') break;
off += 12 + length;
}
}
function parsePngTextChunk(kind: 'tEXt' | 'iTXt', data: Uint8Array, meta: ExifMetadata): void {
const nul = data.indexOf(0);
if (nul < 1) return;
const keyword = utf8.decode(data.subarray(0, nul));
if (keyword !== 'Software') return;
let text: Uint8Array;
if (kind === 'tEXt') {
text = data.subarray(nul + 1);
} else {
// iTXt: keyword\0 compressionFlag(1) compressionMethod(1) languageTag\0
// translatedKeyword\0 text(utf-8). Only uncompressed text is read.
if (data[nul + 1] !== 0) return;
let p = nul + 3;
// Skip the language tag and the translated keyword (both NUL-terminated).
for (let skipped = 0; skipped < 2; skipped++) {
const next = data.indexOf(0, p);
if (next < 0) return;
p = next + 1;
}
text = data.subarray(p);
}
const value = utf8.decode(text).trim();
if (value) meta.software ??= value;
}
// --- strip --------------------------------------------------------------------
function concat(parts: Uint8Array[]): ArrayBuffer {
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const part of parts) {
out.set(part, off);
off += part.length;
}
return out.buffer;
}
function stripJpeg(bytes: Uint8Array): ArrayBuffer {
const segments = collectJpegSegments(bytes);
if (!segments) return bytes.slice().buffer; // corrupted walk - copy verbatim
const parts: Uint8Array[] = [bytes.subarray(0, 2)];
for (const seg of segments) {
if (seg.marker === 0xda) {
// SOS header + entropy data + EOI are copied verbatim to the end.
parts.push(bytes.subarray(seg.start));
break;
}
if (isJpegMetadataSegment(bytes, seg)) continue;
parts.push(bytes.subarray(seg.start, seg.end));
}
return concat(parts);
}
function stripPng(bytes: Uint8Array): ArrayBuffer {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const parts: Uint8Array[] = [bytes.subarray(0, 8)];
let off = 8;
while (off + 8 <= bytes.length) {
const length = view.getUint32(off);
if (length > bytes.length - off - 12) break;
const type = utf8.decode(bytes.subarray(off + 4, off + 8));
// tEXt / iTXt / eXIf are the metadata carriers; everything else (IHDR,
// PLTE, IDAT, ...) is copied byte-for-byte, CRC included.
if (type !== 'tEXt' && type !== 'iTXt' && type !== 'eXIf') {
parts.push(bytes.subarray(off, off + 12 + length));
}
off += 12 + length;
}
if (off < bytes.length) parts.push(bytes.subarray(off));
return concat(parts);
}
// --- public API ---------------------------------------------------------------
export function stripMetadata(buffer: ArrayBuffer): ArrayBuffer {
const bytes = new Uint8Array(buffer);
if (bytes.length < 8) throw new Error('File is too small to be a valid image.');
if (isJpeg(bytes)) return stripJpeg(bytes);
if (isPng(bytes)) return stripPng(bytes);
throw new Error('Unsupported format: only JPEG and PNG images are supported.');
}
export function parseMetadata(buffer: ArrayBuffer): ExifMetadata {
const bytes = new Uint8Array(buffer);
if (bytes.length < 8) throw new Error('File is too small to be a valid image.');
if (!isJpeg(bytes) && !isPng(bytes)) {
throw new Error('Unsupported format: only JPEG and PNG images are supported.');
}
const meta: ExifMetadata = { stripped: stripMetadata(buffer) };
if (isJpeg(bytes)) parseJpeg(bytes, meta);
else parsePng(bytes, meta);
return meta;
}
Also available in 8 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →