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 →