Skip to content

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 →