EXIF & Metadata Stripper — C# source
View and strip GPS, camera, date, and software metadata from photos entirely in your browser. Download a clean copy.
This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// EXIF Stripper — EXIF / XMP / IPTC metadata reader + stripper for JPEG/PNG.
// C# 12 / .NET 8 — ported from src/lib/exif-stripper.ts (the canonical
// TypeScript implementation). Display source for CosmoDev's polyglot pages.
//
// Pure byte[] logic - no imaging libraries, no external dependencies. 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.
using System.Text;
/// <summary>Metadata read from a JPEG or PNG buffer, plus the stripped copy.</summary>
public sealed class ExifMetadata
{
public string? CameraMake { get; set; }
public string? CameraModel { get; set; }
public string? Software { get; set; }
public string? DateTime { get; set; }
/// <summary>Decimal degrees; negative for southern / western hemispheres.</summary>
public double? GpsLatitude { get; set; }
public double? GpsLongitude { get; set; }
public int? ImageWidth { get; set; }
public int? ImageHeight { get; set; }
/// <summary>EXIF orientation value 1-8.</summary>
public int? Orientation { get; set; }
/// <summary>Seconds (e.g. 0.004 = 1/250 s).</summary>
public double? ExposureTime { get; set; }
/// <summary>F-number (e.g. 2.8).</summary>
public double? FNumber { get; set; }
public int? Iso { get; set; }
/// <summary>Millimetres.</summary>
public double? FocalLength { get; set; }
/// <summary>A rebuilt copy of the input with every metadata segment removed.</summary>
public required byte[] Stripped { get; init; }
}
public static class ExifStripper
{
/// <summary>Degrees/minutes/seconds + hemisphere reference to signed decimal degrees.</summary>
public static double FormatGpsCoordinate(double[] degrees, string reference)
{
var deg = degrees.Length > 0 ? degrees[0] : 0;
var min = degrees.Length > 1 ? degrees[1] : 0;
var sec = degrees.Length > 2 ? degrees[2] : 0;
var @decimal = deg + min / 60 + sec / 3600;
return reference is "S" or "W" ? -@decimal : @decimal;
}
// --- format detection -----------------------------------------------------
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
private const string ExifSignature = "Exif\0\0";
private static readonly string[] XmpSignatures =
[
"http://ns.adobe.com/xap/1.0/",
"http://ns.adobe.com/xmp/extension/",
];
private const string IptcSignature = "Photoshop 3.0\0";
private static bool IsJpeg(byte[] bytes) =>
bytes.Length >= 2 && bytes[0] == 0xff && bytes[1] == 0xd8;
private static bool IsPng(byte[] bytes)
{
if (bytes.Length < 8) return false;
for (var i = 0; i < 8; i++)
{
if (bytes[i] != PngSignature[i]) return false;
}
return true;
}
private static bool HasSignature(byte[] bytes, int off, string signature)
{
if (off < 0 || off + signature.Length > bytes.Length) return false;
for (var i = 0; i < signature.Length; i++)
{
if (bytes[off + i] != signature[i]) return false;
}
return true;
}
private static ushort ReadU16(byte[] b, int off, bool little) =>
little ? (ushort)(b[off] | (b[off + 1] << 8)) : (ushort)((b[off] << 8) | b[off + 1]);
private static uint ReadU32(byte[] b, int off, bool little) =>
little
? (uint)(b[off] | (b[off + 1] << 8) | (b[off + 2] << 16) | (b[off + 3] << 24))
: (uint)((b[off] << 24) | (b[off + 1] << 16) | (b[off + 2] << 8) | b[off + 3]);
// --- JPEG segment walk ----------------------------------------------------
/// <summary>One length-prefixed JPEG marker: marker id, start/end (end exclusive)
/// and the first payload byte (right after the 2-byte length field).</summary>
private sealed class JpegSegment(int marker, int start, int end, int payloadStart)
{
public int Marker { get; } = marker;
public int Start { get; } = start;
public int End { get; } = end;
public int PayloadStart { get; } = payloadStart;
}
/// <summary>
/// Walk 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.
/// </summary>
private static List<JpegSegment>? CollectJpegSegments(byte[] bytes)
{
var segments = new List<JpegSegment>();
var 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;
}
var marker = bytes[off + 1];
// Standalone markers carry no length field (TEM, RSTn, SOI, EOI).
if (marker == 0x01 || (marker >= 0xd0 && marker <= 0xd9))
{
segments.Add(new JpegSegment(marker, off, off + 2, off + 2));
if (marker == 0xd9) break;
off += 2;
continue;
}
if (off + 4 > bytes.Length) return null;
var size = ReadU16(bytes, off + 2, little: false); // includes the 2 length bytes itself
if (size < 2) return null;
var end = off + 2 + size;
if (end > bytes.Length) return null;
segments.Add(new JpegSegment(marker, off, end, off + 4));
if (marker == 0xda) break; // SOS - entropy data follows, no more segments
off = end;
}
return segments;
}
private static bool IsJpegMetadataSegment(byte[] bytes, JpegSegment segment)
{
if (segment.Marker == 0xe1)
{
return HasSignature(bytes, segment.PayloadStart, ExifSignature)
|| XmpSignatures.Any(sig => HasSignature(bytes, segment.PayloadStart, sig));
}
// APP13 is IPTC ("Photoshop 3.0\0" + 8BIM records) - always metadata.
return segment.Marker == 0xed && HasSignature(bytes, segment.PayloadStart, IptcSignature);
}
// --- TIFF / EXIF IFD parsing ----------------------------------------------
/// <summary>EXIF field types -> byte size per component.</summary>
private static readonly IReadOnlyDictionary<int, int> TypeSize = new Dictionary<int, int>
{
[1] = 1, [2] = 1, [3] = 2, [4] = 4, [5] = 8, [7] = 1, [9] = 4, [10] = 8,
};
// IFD0 tags.
private const int TagImageWidth = 0x0100;
private const int TagImageHeight = 0x0101;
private const int TagMake = 0x010f;
private const int TagModel = 0x0110;
private const int TagOrientation = 0x0112;
private const int TagSoftware = 0x0131;
private const int TagDateTime = 0x0132;
private const int TagExifIfdPointer = 0x8769;
private const int TagGpsIfdPointer = 0x8825;
// Exif SubIFD tags.
private const int TagExposureTime = 0x829a;
private const int TagFNumber = 0x829d;
private const int TagIso = 0x8827;
private const int TagDateTimeOriginal = 0x9003;
private const int TagFocalLength = 0x920a;
// GPS IFD tags.
private const int TagGpsLatRef = 0x0001;
private const int TagGpsLat = 0x0002;
private const int TagGpsLonRef = 0x0003;
private const int TagGpsLon = 0x0004;
private const int TypeAscii = 2;
private const int TypeShort = 3;
private const int TypeLong = 4;
private const int TypeRational = 5;
/// <summary>A TIFF blob plus its byte order ("II" little / "MM" big endian).</summary>
private sealed class Tiff(byte[] bytes, bool little)
{
public byte[] Bytes { get; } = bytes;
public bool Little { get; } = little;
public ushort U16(int off) => ReadU16(Bytes, off, Little);
public uint U32(int off) => ReadU32(Bytes, off, Little);
}
/// <summary>
/// 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.
/// </summary>
private static int ValueOffset(Tiff t, int entry, int type, int count)
{
var size = (TypeSize.GetValueOrDefault(type, 1)) * count;
var off = size <= 4 ? entry + 8 : (int)t.U32(entry + 8);
if (off < 0 || off + size > t.Bytes.Length) return -1;
return off;
}
private static string? ReadAscii(Tiff t, int entry, int type, int count)
{
if (type != TypeAscii || count < 1) return null;
var off = ValueOffset(t, entry, type, count);
if (off < 0) return null;
var str = Encoding.UTF8.GetString(t.Bytes, off, count).TrimEnd('\0').Trim();
return str.Length == 0 ? null : str;
}
private static int? ReadNumber(Tiff t, int entry, int type, int count)
{
if (count < 1) return null;
var off = ValueOffset(t, entry, type, count);
if (off < 0) return null;
if (type == TypeShort) return t.U16(off);
if (type == TypeLong) return (int)t.U32(off);
return null;
}
private static double[]? ReadRationals(Tiff t, int entry, int type, int count)
{
if (type != TypeRational || count < 1) return null;
var off = ValueOffset(t, entry, type, count);
if (off < 0) return null;
var values = new double[count];
for (var i = 0; i < count; i++)
{
var num = t.U32(off + i * 8);
var den = t.U32(off + i * 8 + 4);
values[i] = den == 0 ? 0 : num / (double)den;
}
return values;
}
private static void ParseTiff(byte[] tiffBytes, ExifMetadata meta)
{
if (tiffBytes.Length < 8) return;
bool little;
if (tiffBytes[0] == 0x49 && tiffBytes[1] == 0x49) little = true; // "II"
else if (tiffBytes[0] == 0x4d && tiffBytes[1] == 0x4d) little = false; // "MM"
else return;
var t = new Tiff(tiffBytes, little);
if (t.U16(2) != 42) return;
ReadIfd(t, (int)t.U32(4), meta);
}
private static void ReadIfd(Tiff t, int offset, ExifMetadata meta)
{
if (offset < 8 || offset + 2 > t.Bytes.Length) return;
var count = t.U16(offset);
var end = offset + 2 + count * 12;
if (end > t.Bytes.Length) return;
for (var i = 0; i < count; i++)
{
var entry = offset + 2 + i * 12;
var tag = t.U16(entry);
var type = t.U16(entry + 2);
var n = (int)t.U32(entry + 4);
switch (tag)
{
case TagImageWidth:
meta.ImageWidth ??= ReadNumber(t, entry, type, n);
break;
case TagImageHeight:
meta.ImageHeight ??= ReadNumber(t, entry, type, n);
break;
case TagMake:
meta.CameraMake ??= ReadAscii(t, entry, type, n);
break;
case TagModel:
meta.CameraModel ??= ReadAscii(t, entry, type, n);
break;
case TagOrientation:
meta.Orientation ??= ReadNumber(t, entry, type, n);
break;
case TagSoftware:
meta.Software ??= ReadAscii(t, entry, type, n);
break;
case TagDateTime:
meta.DateTime ??= ReadAscii(t, entry, type, n);
break;
case TagExifIfdPointer:
{
var sub = ReadNumber(t, entry, TypeLong, 1);
if (sub != null) ReadExifIfd(t, sub.Value, meta);
break;
}
case TagGpsIfdPointer:
{
var gps = ReadNumber(t, entry, TypeLong, 1);
if (gps != null) ReadGpsIfd(t, gps.Value, meta);
break;
}
}
}
}
private static void ReadExifIfd(Tiff t, int offset, ExifMetadata meta)
{
if (offset < 8 || offset + 2 > t.Bytes.Length) return;
var count = t.U16(offset);
var end = offset + 2 + count * 12;
if (end > t.Bytes.Length) return;
for (var i = 0; i < count; i++)
{
var entry = offset + 2 + i * 12;
var tag = t.U16(entry);
var type = t.U16(entry + 2);
var n = (int)t.U32(entry + 4);
switch (tag)
{
case TagExposureTime:
meta.ExposureTime ??= ReadRationals(t, entry, type, n)?[0];
break;
case TagFNumber:
meta.FNumber ??= ReadRationals(t, entry, type, n)?[0];
break;
case TagIso:
meta.Iso ??= ReadNumber(t, entry, type, n);
break;
case TagDateTimeOriginal:
// Phone photos usually carry the real capture time here only.
meta.DateTime ??= ReadAscii(t, entry, type, n);
break;
case TagFocalLength:
meta.FocalLength ??= ReadRationals(t, entry, type, n)?[0];
break;
}
}
}
private static void ReadGpsIfd(Tiff t, int offset, ExifMetadata meta)
{
if (offset < 8 || offset + 2 > t.Bytes.Length) return;
var count = t.U16(offset);
var end = offset + 2 + count * 12;
if (end > t.Bytes.Length) return;
var latRef = "N";
var lonRef = "E";
double[]? lat = null;
double[]? lon = null;
for (var i = 0; i < count; i++)
{
var entry = offset + 2 + i * 12;
var tag = t.U16(entry);
var type = t.U16(entry + 2);
var n = (int)t.U32(entry + 4);
switch (tag)
{
case TagGpsLatRef:
{
var @ref = ReadAscii(t, entry, type, n);
if (@ref != null) latRef = char.ToUpperInvariant(@ref[0]).ToString();
break;
}
case TagGpsLat:
lat = ReadRationals(t, entry, type, n);
break;
case TagGpsLonRef:
{
var @ref = ReadAscii(t, entry, type, n);
if (@ref != null) lonRef = char.ToUpperInvariant(@ref[0]).ToString();
break;
}
case TagGpsLon:
lon = ReadRationals(t, entry, type, n);
break;
}
}
if (lat != null) meta.GpsLatitude ??= FormatGpsCoordinate(lat, latRef);
if (lon != null) 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</...>.
private static void ParseXmp(byte[] payload, ExifMetadata meta)
{
var text = Encoding.UTF8.GetString(payload);
var match = System.Text.RegularExpressions.Regex.Match(text, @":(?:CreatorTool|Software)>([^<]+)<");
if (match.Success) meta.Software ??= match.Groups[1].Value.Trim();
}
// --- JPEG parse -----------------------------------------------------------
private static void ParseJpeg(byte[] bytes, ExifMetadata meta)
{
foreach (var seg in CollectJpegSegments(bytes) ?? [])
{
if (seg.Marker == 0xe1)
{
var payloadLength = seg.End - seg.PayloadStart;
if (HasSignature(bytes, seg.PayloadStart, ExifSignature))
{
var tiffOffset = seg.PayloadStart + ExifSignature.Length;
ParseTiff(bytes[tiffOffset..seg.End], meta);
}
else if (XmpSignatures.Any(sig => HasSignature(bytes, seg.PayloadStart, sig)))
{
ParseXmp(bytes[seg.PayloadStart..seg.End], 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 ??= ReadU16(bytes, seg.PayloadStart + 1, little: false);
meta.ImageWidth ??= ReadU16(bytes, seg.PayloadStart + 3, little: false);
}
}
}
// --- PNG parse ------------------------------------------------------------
private static void ParsePng(byte[] bytes, ExifMetadata meta)
{
var off = 8;
while (off + 8 <= bytes.Length)
{
var length = ReadU32(bytes, off, little: false);
if (length > bytes.Length - off - 12) break; // corrupted chunk
var type = Encoding.ASCII.GetString(bytes, off + 4, 4);
var data = bytes[(off + 8)..(off + 8 + (int)length)];
if (type == "IHDR")
{
meta.ImageWidth ??= (int)ReadU32(bytes, off + 8, little: false);
meta.ImageHeight ??= (int)ReadU32(bytes, off + 12, little: false);
}
else if (type == "tEXt" || type == "iTXt")
{
ParsePngTextChunk(type, data, meta);
}
else if (type == "eXIf")
{
ParseTiff(data, meta);
}
if (type == "IEND") break;
off += 12 + (int)length;
}
}
private static void ParsePngTextChunk(string kind, byte[] data, ExifMetadata meta)
{
var nul = Array.IndexOf(data, (byte)0);
if (nul < 1) return;
var keyword = Encoding.UTF8.GetString(data, 0, nul);
if (keyword != "Software") return;
byte[] text;
if (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 (data[nul + 1] != 0) return;
var p = nul + 3;
// Skip the language tag and the translated keyword (both NUL-terminated).
for (var skipped = 0; skipped < 2; skipped++)
{
var next = Array.IndexOf(data, (byte)0, p);
if (next < 0) return;
p = next + 1;
}
text = data[p..];
}
var value = Encoding.UTF8.GetString(text).Trim();
if (value.Length > 0) meta.Software ??= value;
}
// --- strip ----------------------------------------------------------------
private static byte[] Concat(List<byte[]> parts)
{
var total = parts.Sum(p => p.Length);
var output = new byte[total];
var off = 0;
foreach (var part in parts)
{
part.CopyTo(output, off);
off += part.Length;
}
return output;
}
private static byte[] StripJpeg(byte[] bytes)
{
var segments = CollectJpegSegments(bytes);
if (segments == null) return (byte[])bytes.Clone(); // corrupted walk - copy verbatim
var parts = new List<byte[]> { bytes[..2] };
foreach (var seg in segments)
{
if (seg.Marker == 0xda)
{
// SOS header + entropy data + EOI are copied verbatim to the end.
parts.Add(bytes[seg.Start..]);
break;
}
if (IsJpegMetadataSegment(bytes, seg)) continue;
parts.Add(bytes[seg.Start..seg.End]);
}
return Concat(parts);
}
private static byte[] StripPng(byte[] bytes)
{
var parts = new List<byte[]> { bytes[..8] };
var off = 8;
while (off + 8 <= bytes.Length)
{
var length = ReadU32(bytes, off, little: false);
if (length > bytes.Length - off - 12) break;
var type = Encoding.ASCII.GetString(bytes, off + 4, 4);
// 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.Add(bytes[off..(off + 12 + (int)length)]);
}
off += 12 + (int)length;
}
if (off < bytes.Length) parts.Add(bytes[off..]);
return Concat(parts);
}
// --- public API -----------------------------------------------------------
public static byte[] StripMetadata(byte[] buffer)
{
if (buffer.Length < 8) throw new InvalidDataException("File is too small to be a valid image.");
if (IsJpeg(buffer)) return StripJpeg(buffer);
if (IsPng(buffer)) return StripPng(buffer);
throw new InvalidDataException("Unsupported format: only JPEG and PNG images are supported.");
}
public static ExifMetadata ParseMetadata(byte[] buffer)
{
if (buffer.Length < 8) throw new InvalidDataException("File is too small to be a valid image.");
if (!IsJpeg(buffer) && !IsPng(buffer))
{
throw new InvalidDataException("Unsupported format: only JPEG and PNG images are supported.");
}
var meta = new ExifMetadata { Stripped = StripMetadata(buffer) };
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 →