Skip to content

EXIF & Metadata Stripper — Zig source

View and strip GPS, camera, date, and software metadata from photos entirely in your browser. Download a clean copy.

This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! exif-stripper — EXIF / XMP / IPTC metadata reader + stripper (JPEG & PNG).
//!
//! Language: Zig 0.14 (standard library only)
//! Ported from: src/lib/exif-stripper.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! Pure buffer 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.

const std = @import("std");

pub const Error = error{
    FileTooSmall,
    UnsupportedFormat,
    OutOfMemory,
};

/// Extracted metadata. String fields are slices into the input buffer (valid
/// as long as the buffer is); `stripped` is caller-owned.
pub const ExifMetadata = struct {
    camera_make: ?[]const u8 = null,
    camera_model: ?[]const u8 = null,
    software: ?[]const u8 = null,
    date_time: ?[]const u8 = null,
    /// Decimal degrees; negative for southern / western hemispheres.
    gps_latitude: ?f64 = null,
    gps_longitude: ?f64 = null,
    image_width: ?u32 = null,
    image_height: ?u32 = null,
    /// EXIF orientation value 1-8.
    orientation: ?u32 = null,
    /// Seconds (e.g. 0.004 = 1/250 s).
    exposure_time: ?f64 = null,
    /// F-number (e.g. 2.8).
    f_number: ?f64 = null,
    iso: ?u32 = null,
    /// Millimetres.
    focal_length: ?f64 = null,
    /// A rebuilt copy of the input with every metadata segment removed.
    stripped: []u8,
};

/// Degrees/minutes/seconds + hemisphere reference to signed decimal degrees.
pub fn formatGpsCoordinate(degrees: [3]f64, ref: []const u8) f64 {
    const decimal = degrees[0] + degrees[1] / 60 + degrees[2] / 3600;
    const r = if (ref.len > 0) ref[0] else 'N';
    return if (r == 'S' or r == 'W' or r == 's' or r == 'w') -decimal else decimal;
}

// --- format detection ---------------------------------------------------------

const png_signature = [_]u8{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a };
const exif_signature = "Exif\x00\x00";
const xmp_signatures = [_][]const u8{
    "http://ns.adobe.com/xap/1.0/",
    "http://ns.adobe.com/xmp/extension/",
};
const iptc_signature = "Photoshop 3.0\x00";

fn isJpeg(bytes: []const u8) bool {
    return bytes.len >= 2 and bytes[0] == 0xff and bytes[1] == 0xd8;
}

fn isPng(bytes: []const u8) bool {
    if (bytes.len < 8) return false;
    return std.mem.eql(u8, bytes[0..8], &png_signature);
}

fn hasSignature(bytes: []const u8, off: usize, sig: []const u8) bool {
    if (off + sig.len > bytes.len) return false;
    return std.mem.eql(u8, bytes[off .. off + sig.len], sig);
}

// --- JPEG segment walk --------------------------------------------------------

pub const JpegSegment = struct {
    marker: u8,
    /// Segment start (at the 0xFF byte), end exclusive.
    start: usize,
    end: usize,
    /// First payload byte (right after the 2-byte length field).
    payload_start: usize,
};

/// 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
/// (0) when the structure is corrupted mid-walk.
fn collectJpegSegments(bytes: []const u8, out: []JpegSegment) ?usize {
    var n: usize = 0;
    var off: usize = 2;
    while (off + 2 <= bytes.len) {
        if (bytes[off] != 0xff) return null;
        if (bytes[off + 1] == 0xff) {
            off += 1; // 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 or (marker >= 0xd0 and marker <= 0xd9)) {
            if (n == out.len) return null;
            out[n] = .{ .marker = marker, .start = off, .end = off + 2, .payload_start = off + 2 };
            n += 1;
            if (marker == 0xd9) break;
            off += 2;
            continue;
        }
        if (off + 4 > bytes.len) return null;
        const size = std.mem.readInt(u16, bytes[off + 2 ..][0..2], .big); // includes the 2 length bytes itself
        if (size < 2) return null;
        const end = off + 2 + size;
        if (end > bytes.len) return null;
        if (n == out.len) return null;
        out[n] = .{ .marker = marker, .start = off, .end = end, .payload_start = off + 4 };
        n += 1;
        if (marker == 0xda) break; // SOS - entropy data follows, no more segments
        off = end;
    }
    return n;
}

fn isJpegMetadataSegment(bytes: []const u8, seg: JpegSegment) bool {
    if (seg.marker == 0xe1) {
        if (hasSignature(bytes, seg.payload_start, exif_signature)) return true;
        for (xmp_signatures) |sig| {
            if (hasSignature(bytes, seg.payload_start, sig)) return true;
        }
        return false;
    }
    // APP13 is IPTC ("Photoshop 3.0\0" + 8BIM records) - always metadata.
    return seg.marker == 0xed and hasSignature(bytes, seg.payload_start, iptc_signature);
}

// --- TIFF / EXIF IFD parsing --------------------------------------------------

// EXIF field types -> byte size per component.
fn typeSize(t: u16) usize {
    return switch (t) {
        1, 2, 7 => 1,
        3 => 2,
        4, 9 => 4,
        5, 10 => 8,
        else => 1,
    };
}

// 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;

const Tiff = struct {
    bytes: []const u8,
    little: bool,

    fn u16At(t: *const Tiff, off: usize) ?u16 {
        if (off + 2 > t.bytes.len) return null;
        return std.mem.readInt(u16, t.bytes[off..][0..2], @enumFromInt(@intFromBool(t.little)));
    }

    fn u32At(t: *const Tiff, off: usize) ?u32 {
        if (off + 4 > t.bytes.len) return null;
        return std.mem.readInt(u32, t.bytes[off..][0..4], @enumFromInt(@intFromBool(t.little)));
    }
};

/// Where the entry's value lives: inline in the 4-byte value field when it
/// fits, else at the recorded byte offset. Returns null when out of bounds.
fn valueOffset(t: *const Tiff, entry: usize, ty: u16, count: u32) ?usize {
    const size = typeSize(ty) * count;
    const off: usize = if (size <= 4)
        entry + 8
    else
        t.u32At(entry + 8) orelse return null;
    if (off + size > t.bytes.len) return null;
    return off;
}

fn readAscii(t: *const Tiff, entry: usize, ty: u16, count: u32) ?[]const u8 {
    if (ty != type_ascii or count < 1) return null;
    const off = valueOffset(t, entry, ty, count) orelse return null;
    var str = t.bytes[off .. off + count];
    str = std.mem.trimRight(u8, str, "\x00"); // strip trailing NUL padding
    str = std.mem.trim(u8, str, " \t\r\n");
    return if (str.len > 0) str else null;
}

fn readNumber(t: *const Tiff, entry: usize, ty: u16, count: u32) ?u32 {
    if (count < 1) return null;
    const off = valueOffset(t, entry, ty, count) orelse return null;
    if (ty == type_short) return t.u16At(off);
    if (ty == type_long) return t.u32At(off);
    return null;
}

fn readRationals(t: *const Tiff, entry: usize, ty: u16, count: u32, out: []f64) ?[]f64 {
    if (ty != type_rational or count < 1) return null;
    const off = valueOffset(t, entry, ty, count) orelse return null;
    const n = @min(count, out.len);
    for (0..n) |i| {
        const num = t.u32At(off + i * 8) orelse return null;
        const den = t.u32At(off + i * 8 + 4) orelse return null;
        out[i] = if (den == 0) 0 else @as(f64, @floatFromInt(num)) / @as(f64, @floatFromInt(den));
    }
    return out[0..n];
}

fn parseTiff(tiff_bytes: []const u8, meta: *ExifMetadata) void {
    if (tiff_bytes.len < 8) return;
    var little: bool = undefined;
    if (tiff_bytes[0] == 0x49 and tiff_bytes[1] == 0x49) {
        little = true; // "II"
    } else if (tiff_bytes[0] == 0x4d and tiff_bytes[1] == 0x4d) {
        little = false; // "MM"
    } else return;
    const t = Tiff{ .bytes = tiff_bytes, .little = little };
    if (t.u16At(2) != 42) return;
    const ifd_offset = t.u32At(4) orelse return;
    readIfd(&t, ifd_offset, meta);
}

fn readIfd(t: *const Tiff, offset: usize, meta: *ExifMetadata) void {
    if (offset < 8 or offset + 2 > t.bytes.len) return;
    const count = t.u16At(offset) orelse return;
    const end = offset + 2 + @as(usize, count) * 12;
    if (end > t.bytes.len) return;
    var i: usize = 0;
    while (i < count) : (i += 1) {
        const entry = offset + 2 + i * 12;
        const tag = t.u16At(entry) orelse return;
        const ty = t.u16At(entry + 2) orelse return;
        const n = t.u32At(entry + 4) orelse return;
        switch (tag) {
            tag_image_width => if (meta.image_width == null) meta.image_width = readNumber(t, entry, ty, n),
            tag_image_height => if (meta.image_height == null) meta.image_height = readNumber(t, entry, ty, n),
            tag_make => if (meta.camera_make == null) meta.camera_make = readAscii(t, entry, ty, n),
            tag_model => if (meta.camera_model == null) meta.camera_model = readAscii(t, entry, ty, n),
            tag_orientation => if (meta.orientation == null) meta.orientation = readNumber(t, entry, ty, n),
            tag_software => if (meta.software == null) meta.software = readAscii(t, entry, ty, n),
            tag_date_time => if (meta.date_time == null) meta.date_time = readAscii(t, entry, ty, n),
            tag_exif_ifd_pointer => {
                if (readNumber(t, entry, type_long, 1)) |sub| readExifIfd(t, sub, meta);
            },
            tag_gps_ifd_pointer => {
                if (readNumber(t, entry, type_long, 1)) |gps| readGpsIfd(t, gps, meta);
            },
            else => {},
        }
    }
}

fn readExifIfd(t: *const Tiff, offset: usize, meta: *ExifMetadata) void {
    if (offset < 8 or offset + 2 > t.bytes.len) return;
    const count = t.u16At(offset) orelse return;
    const end = offset + 2 + @as(usize, count) * 12;
    if (end > t.bytes.len) return;
    var rat_buf: [3]f64 = undefined;
    var i: usize = 0;
    while (i < count) : (i += 1) {
        const entry = offset + 2 + i * 12;
        const tag = t.u16At(entry) orelse return;
        const ty = t.u16At(entry + 2) orelse return;
        const n = t.u32At(entry + 4) orelse return;
        switch (tag) {
            tag_exposure_time => if (meta.exposure_time == null) {
                if (readRationals(t, entry, ty, n, &rat_buf)) |r| meta.exposure_time = r[0];
            },
            tag_f_number => if (meta.f_number == null) {
                if (readRationals(t, entry, ty, n, &rat_buf)) |r| meta.f_number = r[0];
            },
            tag_iso => if (meta.iso == null) meta.iso = readNumber(t, entry, ty, n),
            tag_date_time_original => if (meta.date_time == null) {
                // Phone photos usually carry the real capture time here only.
                meta.date_time = readAscii(t, entry, ty, n);
            },
            tag_focal_length => if (meta.focal_length == null) {
                if (readRationals(t, entry, ty, n, &rat_buf)) |r| meta.focal_length = r[0];
            },
            else => {},
        }
    }
}

fn readGpsIfd(t: *const Tiff, offset: usize, meta: *ExifMetadata) void {
    if (offset < 8 or offset + 2 > t.bytes.len) return;
    const count = t.u16At(offset) orelse return;
    const end = offset + 2 + @as(usize, count) * 12;
    if (end > t.bytes.len) return;
    var lat_ref: u8 = 'N';
    var lon_ref: u8 = 'E';
    var lat: ?[3]f64 = null;
    var lon: ?[3]f64 = null;
    var rat_buf: [3]f64 = undefined;
    var i: usize = 0;
    while (i < count) : (i += 1) {
        const entry = offset + 2 + i * 12;
        const tag = t.u16At(entry) orelse return;
        const ty = t.u16At(entry + 2) orelse return;
        const n = t.u32At(entry + 4) orelse return;
        switch (tag) {
            tag_gps_lat_ref => {
                if (readAscii(t, entry, ty, n)) |ref| lat_ref = std.ascii.toUpper(ref[0]);
            },
            tag_gps_lat => {
                if (readRationals(t, entry, ty, n, &rat_buf)) |r| {
                    var v: [3]f64 = .{ 0, 0, 0 };
                    for (r, 0..) |x, j| v[j] = x;
                    lat = v;
                }
            },
            tag_gps_lon_ref => {
                if (readAscii(t, entry, ty, n)) |ref| lon_ref = std.ascii.toUpper(ref[0]);
            },
            tag_gps_lon => {
                if (readRationals(t, entry, ty, n, &rat_buf)) |r| {
                    var v: [3]f64 = .{ 0, 0, 0 };
                    for (r, 0..) |x, j| v[j] = x;
                    lon = v;
                }
            },
            else => {},
        }
    }
    if (lat != null and meta.gps_latitude == null)
        meta.gps_latitude = formatGpsCoordinate(lat.?, &[1]u8{lat_ref});
    if (lon != null and meta.gps_longitude == null)
        meta.gps_longitude = formatGpsCoordinate(lon.?, &[1]u8{lon_ref});
}

/// XMP is XML; the only field we surface is the editing software / creator
/// tool, which appears as e.g. <xmp:CreatorTool>Pixelmator Pro</...>.
fn parseXmp(payload: []const u8, meta: *ExifMetadata) void {
    const names = [_][]const u8{ "CreatorTool>", "Software>" };
    for (names) |name| {
        var search_from: usize = 0;
        while (std.mem.indexOfPos(u8, payload, search_from, ":")) |colon| {
            search_from = colon + 1;
            if (colon + 1 + name.len > payload.len) break;
            if (!std.mem.eql(u8, payload[colon + 1 .. colon + 1 + name.len], name)) continue;
            const value_start = colon + 1 + name.len;
            const value_end = std.mem.indexOfScalarPos(u8, payload, value_start, '<') orelse break;
            const value = std.mem.trim(u8, payload[value_start..value_end], " \t\r\n");
            if (value.len > 0 and meta.software == null) meta.software = value;
            return;
        }
    }
}

// --- JPEG parse ---------------------------------------------------------------

fn parseJpeg(bytes: []const u8, meta: *ExifMetadata) void {
    var segments: [128]JpegSegment = undefined;
    const n = collectJpegSegments(bytes, &segments) orelse return;
    for (segments[0..n]) |seg| {
        if (seg.marker == 0xe1) {
            const payload = bytes[seg.payload_start..seg.end];
            if (hasSignature(bytes, seg.payload_start, exif_signature)) {
                parseTiff(payload[exif_signature.len..], meta);
            } else {
                for (xmp_signatures) |sig| {
                    if (hasSignature(bytes, seg.payload_start, sig)) {
                        parseXmp(payload, meta);
                        break;
                    }
                }
            }
        } else if (seg.marker >= 0xc0 and seg.marker <= 0xcf and
            seg.marker != 0xc4 and seg.marker != 0xc8 and seg.marker != 0xcc and
            seg.end - seg.payload_start >= 5)
        {
            // Start-of-frame header: precision(1), height(2), width(2), big-endian.
            if (meta.image_height == null)
                meta.image_height = std.mem.readInt(u16, bytes[seg.payload_start + 1 ..][0..2], .big);
            if (meta.image_width == null)
                meta.image_width = std.mem.readInt(u16, bytes[seg.payload_start + 3 ..][0..2], .big);
        }
    }
}

// --- PNG parse ----------------------------------------------------------------

fn chunkType(bytes: []const u8, off: usize) [4]u8 {
    return .{ bytes[off + 4], bytes[off + 5], bytes[off + 6], bytes[off + 7] };
}

fn parsePng(bytes: []const u8, meta: *ExifMetadata) void {
    var off: usize = 8;
    while (off + 8 <= bytes.len) {
        const length = std.mem.readInt(u32, bytes[off..][0..4], .big);
        if (length > bytes.len - off - 12) break; // corrupted chunk
        const ty = chunkType(bytes, off);
        const data = bytes[off + 8 .. off + 8 + length];
        if (std.mem.eql(u8, &ty, "IHDR")) {
            if (meta.image_width == null)
                meta.image_width = std.mem.readInt(u32, bytes[off + 8 ..][0..4], .big);
            if (meta.image_height == null)
                meta.image_height = std.mem.readInt(u32, bytes[off + 12 ..][0..4], .big);
        } else if (std.mem.eql(u8, &ty, "tEXt") or std.mem.eql(u8, &ty, "iTXt")) {
            parsePngTextChunk(&ty, data, meta);
        } else if (std.mem.eql(u8, &ty, "eXIf")) {
            parseTiff(data, meta);
        }
        if (std.mem.eql(u8, &ty, "IEND")) break;
        off += 12 + length;
    }
}

fn parsePngTextChunk(kind: *const [4]u8, data: []const u8, meta: *ExifMetadata) void {
    const nul = std.mem.indexOfScalar(u8, data, 0) orelse return;
    if (nul < 1) return;
    if (!std.mem.eql(u8, data[0..nul], "Software")) return;
    var text: []const u8 = undefined;
    if (std.mem.eql(u8, kind, "tEXt")) {
        text = data[nul + 1 ..];
    } else {
        // iTXt: keyword\0 compressionFlag(1) compressionMethod(1) languageTag\0
        // translatedKeyword\0 text(utf-8). Only uncompressed text is read.
        if (nul + 2 > data.len or data[nul + 1] != 0) return;
        var p = nul + 3;
        // Skip the language tag and the translated keyword (both NUL-terminated).
        var skipped: usize = 0;
        while (skipped < 2) : (skipped += 1) {
            const next = std.mem.indexOfScalarPos(u8, data, p, 0) orelse return;
            p = next + 1;
        }
        text = data[p..];
    }
    const value = std.mem.trim(u8, text, " \t\r\n");
    if (value.len > 0 and meta.software == null) meta.software = value;
}

// --- strip --------------------------------------------------------------------

fn concat(allocator: std.mem.Allocator, parts: []const []const u8) Error![]u8 {
    var total: usize = 0;
    for (parts) |p| total += p.len;
    const out = allocator.alloc(u8, total) catch return Error.OutOfMemory;
    var off: usize = 0;
    for (parts) |p| {
        @memcpy(out[off .. off + p.len], p);
        off += p.len;
    }
    return out;
}

fn stripJpeg(allocator: std.mem.Allocator, bytes: []const u8) Error![]u8 {
    var segments: [128]JpegSegment = undefined;
    const n = collectJpegSegments(bytes, &segments) orelse
        return allocator.dupe(u8, bytes) catch Error.OutOfMemory; // corrupted walk - copy verbatim

    var parts = std.ArrayList([]const u8).init(allocator);
    defer parts.deinit();
    parts.append(bytes[0..2]) catch return Error.OutOfMemory;
    for (segments[0..n]) |seg| {
        if (seg.marker == 0xda) {
            // SOS header + entropy data + EOI are copied verbatim to the end.
            parts.append(bytes[seg.start..]) catch return Error.OutOfMemory;
            break;
        }
        if (isJpegMetadataSegment(bytes, seg)) continue;
        parts.append(bytes[seg.start..seg.end]) catch return Error.OutOfMemory;
    }
    return concat(allocator, parts.items);
}

fn stripPng(allocator: std.mem.Allocator, bytes: []const u8) Error![]u8 {
    var parts = std.ArrayList([]const u8).init(allocator);
    defer parts.deinit();
    parts.append(bytes[0..8]) catch return Error.OutOfMemory;
    var off: usize = 8;
    while (off + 8 <= bytes.len) {
        const length = std.mem.readInt(u32, bytes[off..][0..4], .big);
        if (length > bytes.len - off - 12) break;
        const ty = chunkType(bytes, off);
        // tEXt / iTXt / eXIf are the metadata carriers; everything else (IHDR,
        // PLTE, IDAT, ...) is copied byte-for-byte, CRC included.
        if (!std.mem.eql(u8, &ty, "tEXt") and !std.mem.eql(u8, &ty, "iTXt") and
            !std.mem.eql(u8, &ty, "eXIf"))
        {
            parts.append(bytes[off .. off + 12 + length]) catch return Error.OutOfMemory;
        }
        off += 12 + length;
    }
    if (off < bytes.len) parts.append(bytes[off..]) catch return Error.OutOfMemory;
    return concat(allocator, parts.items);
}

// --- public API ---------------------------------------------------------------

/// Rebuild `buffer` without its metadata segments. Caller owns the result.
pub fn stripMetadata(allocator: std.mem.Allocator, buffer: []const u8) Error![]u8 {
    if (buffer.len < 8) return Error.FileTooSmall;
    if (isJpeg(buffer)) return stripJpeg(allocator, buffer);
    if (isPng(buffer)) return stripPng(allocator, buffer);
    return Error.UnsupportedFormat;
}

/// Parse the metadata out of `buffer` and return it together with a stripped
/// copy. The caller owns `.stripped`; string fields point into `buffer`.
pub fn parseMetadata(allocator: std.mem.Allocator, buffer: []const u8) Error!ExifMetadata {
    if (buffer.len < 8) return Error.FileTooSmall;
    if (!isJpeg(buffer) and !isPng(buffer)) return Error.UnsupportedFormat;

    var meta = ExifMetadata{ .stripped = try stripMetadata(allocator, buffer) };
    errdefer allocator.free(meta.stripped);
    if (isJpeg(buffer)) {
        parseJpeg(buffer, &meta);
    } else {
        parsePng(buffer, &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 →