Skip to content

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 →