Skip to content

EXIF & Metadata Stripper — Swift source

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

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

// exif-stripper — EXIF / XMP / IPTC metadata reader + stripper for JPEG and PNG.
//
// Language: Swift 5.9+ (Foundation only)
// Ported from src/lib/exif-stripper.ts
// display source — part of CosmoDev's polyglot tool pages
//
// Pure byte-buffer logic — no UIKit, no ImageIO, 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.

import Foundation

// MARK: - Errors

enum ExifError: Error, CustomStringConvertible {
    case tooSmall
    case unsupportedFormat

    var description: String {
        switch self {
        case .tooSmall: return "File is too small to be a valid image."
        case .unsupportedFormat: return "Unsupported format: only JPEG and PNG images are supported."
        }
    }
}

// MARK: - Metadata model

struct ExifMetadata {
    var cameraMake: String?
    var cameraModel: String?
    var software: String?
    var dateTime: String?
    /// Decimal degrees; negative for southern / western hemispheres.
    var gpsLatitude: Double?
    var gpsLongitude: Double?
    var imageWidth: Int?
    var imageHeight: Int?
    /// EXIF orientation value 1-8.
    var orientation: Int?
    /// Seconds (e.g. 0.004 = 1/250 s).
    var exposureTime: Double?
    /// F-number (e.g. 2.8).
    var fNumber: Double?
    var iso: Int?
    /// Millimetres.
    var focalLength: Double?
    /// A rebuilt copy of the input with every metadata segment removed.
    var stripped: [UInt8]
}

/// Degrees/minutes/seconds + hemisphere reference to signed decimal degrees.
func formatGpsCoordinate(_ degrees: [Double], _ ref: String) -> Double {
    let deg = degrees.count > 0 ? degrees[0] : 0
    let min = degrees.count > 1 ? degrees[1] : 0
    let sec = degrees.count > 2 ? degrees[2] : 0
    let decimal = deg + min / 60 + sec / 3600
    return ref == "S" || ref == "W" ? -decimal : decimal
}

// MARK: - Format detection

let pngSignature: [UInt8] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
let exifSignature = "Exif\u{0}\u{0}"
let xmpSignatures = ["http://ns.adobe.com/xap/1.0/", "http://ns.adobe.com/xmp/extension/"]
let iptcSignature = "Photoshop 3.0\u{0}"

func isJpeg(_ bytes: [UInt8]) -> Bool {
    bytes.count >= 2 && bytes[0] == 0xff && bytes[1] == 0xd8
}

func isPng(_ bytes: [UInt8]) -> Bool {
    bytes.count >= 8 && zip(pngSignature, bytes).allSatisfy { $0.0 == $0.1 }
}

func hasSignature(_ bytes: [UInt8], _ off: Int, _ sig: String) -> Bool {
    let chars = Array(sig.utf8)
    if off < 0 || off + chars.count > bytes.count { return false }
    for i in 0..<chars.count where bytes[off + i] != chars[i] { return false }
    return true
}

// MARK: - Byte readers

func u16(_ bytes: [UInt8], _ off: Int, little: Bool) -> Int {
    let hi = Int(bytes[off]), lo = Int(bytes[off + 1])
    return little ? lo << 8 | hi : hi << 8 | lo
}

func u32(_ bytes: [UInt8], _ off: Int, little: Bool) -> Int {
    let b0 = Int(bytes[off]), b1 = Int(bytes[off + 1])
    let b2 = Int(bytes[off + 2]), b3 = Int(bytes[off + 3])
    return little ? b3 << 24 | b2 << 16 | b1 << 8 | b0
                  : b0 << 24 | b1 << 16 | b2 << 8 | b3
}

// MARK: - JPEG segment walk

struct JpegSegment {
    let marker: Int
    /// Segment start (at the 0xFF byte), end exclusive.
    let start: Int
    let end: Int
    /// First payload byte (right after the 2-byte length field).
    let payloadStart: Int
}

/// 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 nil when
/// the structure is corrupted mid-walk.
func collectJpegSegments(_ bytes: [UInt8]) -> [JpegSegment]? {
    var segments: [JpegSegment] = []
    var off = 2
    while off + 2 <= bytes.count {
        if bytes[off] != 0xff { return nil }
        if bytes[off + 1] == 0xff {
            off += 1 // padding fill byte before the real marker
            continue
        }
        let marker = Int(bytes[off + 1])
        // Standalone markers carry no length field (TEM, RSTn, SOI, EOI).
        if marker == 0x01 || (marker >= 0xd0 && marker <= 0xd9) {
            segments.append(JpegSegment(marker: marker, start: off, end: off + 2, payloadStart: off + 2))
            if marker == 0xd9 { break }
            off += 2
            continue
        }
        if off + 4 > bytes.count { return nil }
        let size = u16(bytes, off + 2, little: false) // includes the 2 length bytes itself
        if size < 2 { return nil }
        let end = off + 2 + size
        if end > bytes.count { return nil }
        segments.append(JpegSegment(marker: marker, start: off, end: end, payloadStart: off + 4))
        if marker == 0xda { break } // SOS - entropy data follows, no more segments
        off = end
    }
    return segments
}

func isJpegMetadataSegment(_ bytes: [UInt8], _ seg: JpegSegment) -> Bool {
    if seg.marker == 0xe1 {
        return hasSignature(bytes, seg.payloadStart, exifSignature)
            || xmpSignatures.contains { hasSignature(bytes, seg.payloadStart, $0) }
    }
    // APP13 is IPTC ("Photoshop 3.0\0" + 8BIM records) — always metadata.
    return seg.marker == 0xed && hasSignature(bytes, seg.payloadStart, iptcSignature)
}

// MARK: - TIFF / EXIF IFD parsing

/// EXIF field types -> byte size per component.
let typeSize: [Int: Int] = [1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8]

// IFD0 tags.
let tagImageWidth = 0x0100
let tagImageHeight = 0x0101
let tagMake = 0x010f
let tagModel = 0x0110
let tagOrientation = 0x0112
let tagSoftware = 0x0131
let tagDateTime = 0x0132
let tagExifIfdPointer = 0x8769
let tagGpsIfdPointer = 0x8825
// Exif SubIFD tags.
let tagExposureTime = 0x829a
let tagFNumber = 0x829d
let tagIso = 0x8827
let tagDateTimeOriginal = 0x9003
let tagFocalLength = 0x920a
// GPS IFD tags.
let tagGpsLatRef = 0x0001
let tagGpsLat = 0x0002
let tagGpsLonRef = 0x0003
let tagGpsLon = 0x0004

let typeAscii = 2
let typeShort = 3
let typeLong = 4
let typeRational = 5

struct Tiff {
    let bytes: [UInt8]
    let little: Bool
}

/// 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.
func valueOffset(_ t: Tiff, _ entry: Int, _ type_: Int, _ count: Int) -> Int {
    let size = (typeSize[type_] ?? 1) * count
    let off = size <= 4 ? entry + 8 : u32(t.bytes, entry + 8, little: t.little)
    if off < 0 || off + size > t.bytes.count { return -1 }
    return off
}

func readAscii(_ t: Tiff, _ entry: Int, _ type_: Int, _ count: Int) -> String? {
    if type_ != typeAscii || count < 1 { return nil }
    let off = valueOffset(t, entry, type_, count)
    if off < 0 { return nil }
    let raw = String(decoding: t.bytes[off..<off + count], as: UTF8.self)
    let str = raw.trimmingCharacters(in: CharacterSet(charactersIn: "\u{0}")).trimmingCharacters(in: .whitespaces)
    return str.isEmpty ? nil : str
}

func readNumber(_ t: Tiff, _ entry: Int, _ type_: Int, _ count: Int) -> Int? {
    if count < 1 { return nil }
    let off = valueOffset(t, entry, type_, count)
    if off < 0 { return nil }
    if type_ == typeShort { return u16(t.bytes, off, little: t.little) }
    if type_ == typeLong { return u32(t.bytes, off, little: t.little) }
    return nil
}

func readRationals(_ t: Tiff, _ entry: Int, _ type_: Int, _ count: Int) -> [Double]? {
    if type_ != typeRational || count < 1 { return nil }
    let off = valueOffset(t, entry, type_, count)
    if off < 0 { return nil }
    var values: [Double] = []
    for i in 0..<count {
        let num = u32(t.bytes, off + i * 8, little: t.little)
        let den = u32(t.bytes, off + i * 8 + 4, little: t.little)
        values.append(den == 0 ? 0 : Double(num) / Double(den))
    }
    return values
}

func parseTiff(_ tiffBytes: [UInt8], _ meta: inout ExifMetadata) {
    if tiffBytes.count < 8 { return }
    let little: Bool
    if tiffBytes[0] == 0x49 && tiffBytes[1] == 0x49 { little = true } // "II"
    else if tiffBytes[0] == 0x4d && tiffBytes[1] == 0x4d { little = false } // "MM"
    else { return }
    let t = Tiff(bytes: tiffBytes, little: little)
    if u16(tiffBytes, 2, little: little) != 42 { return }
    readIfd(t, u32(tiffBytes, 4, little: little), &meta)
}

func readIfd(_ t: Tiff, _ offset: Int, _ meta: inout ExifMetadata) {
    if offset < 8 || offset + 2 > t.bytes.count { return }
    let count = u16(t.bytes, offset, little: t.little)
    let end = offset + 2 + count * 12
    if end > t.bytes.count { return }
    for i in 0..<count {
        let entry = offset + 2 + i * 12
        let tag = u16(t.bytes, entry, little: t.little)
        let type_ = u16(t.bytes, entry + 2, little: t.little)
        let n = u32(t.bytes, entry + 4, little: t.little)
        switch tag {
        case tagImageWidth where meta.imageWidth == nil:
            meta.imageWidth = readNumber(t, entry, type_, n)
        case tagImageHeight where meta.imageHeight == nil:
            meta.imageHeight = readNumber(t, entry, type_, n)
        case tagMake where meta.cameraMake == nil:
            meta.cameraMake = readAscii(t, entry, type_, n)
        case tagModel where meta.cameraModel == nil:
            meta.cameraModel = readAscii(t, entry, type_, n)
        case tagOrientation where meta.orientation == nil:
            meta.orientation = readNumber(t, entry, type_, n)
        case tagSoftware where meta.software == nil:
            meta.software = readAscii(t, entry, type_, n)
        case tagDateTime where meta.dateTime == nil:
            meta.dateTime = readAscii(t, entry, type_, n)
        case tagExifIfdPointer:
            if let sub = readNumber(t, entry, typeLong, 1) { readExifIfd(t, sub, &meta) }
        case tagGpsIfdPointer:
            if let gps = readNumber(t, entry, typeLong, 1) { readGpsIfd(t, gps, &meta) }
        default:
            break
        }
    }
}

func readExifIfd(_ t: Tiff, _ offset: Int, _ meta: inout ExifMetadata) {
    if offset < 8 || offset + 2 > t.bytes.count { return }
    let count = u16(t.bytes, offset, little: t.little)
    let end = offset + 2 + count * 12
    if end > t.bytes.count { return }
    for i in 0..<count {
        let entry = offset + 2 + i * 12
        let tag = u16(t.bytes, entry, little: t.little)
        let type_ = u16(t.bytes, entry + 2, little: t.little)
        let n = u32(t.bytes, entry + 4, little: t.little)
        switch tag {
        case tagExposureTime where meta.exposureTime == nil:
            meta.exposureTime = readRationals(t, entry, type_, n)?.first
        case tagFNumber where meta.fNumber == nil:
            meta.fNumber = readRationals(t, entry, type_, n)?.first
        case tagIso where meta.iso == nil:
            meta.iso = readNumber(t, entry, type_, n)
        case tagDateTimeOriginal:
            // Phone photos usually carry the real capture time here only.
            if meta.dateTime == nil { meta.dateTime = readAscii(t, entry, type_, n) }
        case tagFocalLength where meta.focalLength == nil:
            meta.focalLength = readRationals(t, entry, type_, n)?.first
        default:
            break
        }
    }
}

func readGpsIfd(_ t: Tiff, _ offset: Int, _ meta: inout ExifMetadata) {
    if offset < 8 || offset + 2 > t.bytes.count { return }
    let count = u16(t.bytes, offset, little: t.little)
    let end = offset + 2 + count * 12
    if end > t.bytes.count { return }
    var latRef = "N"
    var lonRef = "E"
    var lat: [Double]?
    var lon: [Double]?
    for i in 0..<count {
        let entry = offset + 2 + i * 12
        let tag = u16(t.bytes, entry, little: t.little)
        let type_ = u16(t.bytes, entry + 2, little: t.little)
        let n = u32(t.bytes, entry + 4, little: t.little)
        switch tag {
        case tagGpsLatRef:
            if let ref = readAscii(t, entry, type_, n) { latRef = String(ref.prefix(1)).uppercased() }
        case tagGpsLat:
            lat = readRationals(t, entry, type_, n)
        case tagGpsLonRef:
            if let ref = readAscii(t, entry, type_, n) { lonRef = String(ref.prefix(1)).uppercased() }
        case tagGpsLon:
            lon = readRationals(t, entry, type_, n)
        default:
            break
        }
    }
    if let lat = lat, meta.gpsLatitude == nil { meta.gpsLatitude = formatGpsCoordinate(lat, latRef) }
    if let lon = lon, meta.gpsLongitude == nil { 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</...>.
func parseXmp(_ payload: [UInt8], _ meta: inout ExifMetadata) {
    let text = String(decoding: payload, as: UTF8.self)
    if let range = text.range(of: #":(?:CreatorTool|Software)>([^<]+)<)"#, options: .regularExpression),
       let value = text[range].split(separator: ">", maxSplits: 1).last {
        let trimmed = value.dropLast().trimmingCharacters(in: .whitespaces) // drop the trailing "<"
        if meta.software == nil, !trimmed.isEmpty { meta.software = trimmed }
    }
}

// MARK: - JPEG parse

func parseJpeg(_ bytes: [UInt8], _ meta: inout ExifMetadata) {
    for seg in collectJpegSegments(bytes) ?? [] {
        if seg.marker == 0xe1 {
            let payload = Array(bytes[seg.payloadStart..<seg.end])
            if hasSignature(bytes, seg.payloadStart, exifSignature) {
                parseTiff(Array(payload[exifSignature.utf8.count...]), &meta)
            } else if xmpSignatures.contains(where: { hasSignature(bytes, seg.payloadStart, $0) }) {
                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.
            if meta.imageHeight == nil { meta.imageHeight = u16(bytes, seg.payloadStart + 1, little: false) }
            if meta.imageWidth == nil { meta.imageWidth = u16(bytes, seg.payloadStart + 3, little: false) }
        }
    }
}

// MARK: - PNG parse

func parsePng(_ bytes: [UInt8], _ meta: inout ExifMetadata) {
    var off = 8
    while off + 8 <= bytes.count {
        let length = u32(bytes, off, little: false)
        if length > bytes.count - off - 12 { break } // corrupted chunk
        let type = String(decoding: bytes[(off + 4)..<(off + 8)], as: UTF8.self)
        let data = Array(bytes[(off + 8)..<(off + 8 + length)])
        if type == "IHDR" {
            if meta.imageWidth == nil { meta.imageWidth = u32(bytes, off + 8, little: false) }
            if meta.imageHeight == nil { meta.imageHeight = u32(bytes, off + 12, little: false) }
        } else if type == "tEXt" || type == "iTXt" {
            parsePngTextChunk(kind: type, data: data, meta: &meta)
        } else if type == "eXIf" {
            parseTiff(data, &meta)
        }
        if type == "IEND" { break }
        off += 12 + length
    }
}

func parsePngTextChunk(kind: String, data: [UInt8], meta: inout ExifMetadata) {
    guard let nul = data.firstIndex(of: 0), nul > 0 else { return }
    let keyword = String(decoding: data[0..<nul], as: UTF8.self)
    if keyword != "Software" { return }
    let text: [UInt8]
    if kind == "tEXt" {
        text = Array(data[(nul + 1)...])
    } else {
        // iTXt: keyword\0 compressionFlag(1) compressionMethod(1) languageTag\0
        // translatedKeyword\0 text(utf-8). Only uncompressed text is read.
        guard data[nul + 1] == 0 else { return }
        var p = nul + 3
        // Skip the language tag and the translated keyword (both NUL-terminated).
        for _ in 0..<2 {
            guard let next = data[p...].firstIndex(of: 0) else { return }
            p = next + 1
        }
        text = Array(data[p...])
    }
    let value = String(decoding: text, as: UTF8.self).trimmingCharacters(in: .whitespaces)
    if !value.isEmpty, meta.software == nil { meta.software = value }
}

// MARK: - Strip

func concat(_ parts: [[UInt8]]) -> [UInt8] {
    parts.reduce(into: []) { out, part in out += part }
}

func stripJpeg(_ bytes: [UInt8]) -> [UInt8] {
    guard let segments = collectJpegSegments(bytes) else { return bytes } // corrupted walk — copy verbatim
    var parts: [[UInt8]] = [Array(bytes[0..<2])]
    for seg in segments {
        if seg.marker == 0xda {
            // SOS header + entropy data + EOI are copied verbatim to the end.
            parts.append(Array(bytes[seg.start...]))
            break
        }
        if isJpegMetadataSegment(bytes, seg) { continue }
        parts.append(Array(bytes[seg.start..<seg.end]))
    }
    return concat(parts)
}

func stripPng(_ bytes: [UInt8]) -> [UInt8] {
    var parts: [[UInt8]] = [Array(bytes[0..<8])]
    var off = 8
    while off + 8 <= bytes.count {
        let length = u32(bytes, off, little: false)
        if length > bytes.count - off - 12 { break }
        let type = String(decoding: bytes[(off + 4)..<(off + 8)], as: UTF8.self)
        // 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.append(Array(bytes[off..<(off + 12 + length)]))
        }
        off += 12 + length
    }
    if off < bytes.count { parts.append(Array(bytes[off...])) }
    return concat(parts)
}

// MARK: - Public API

func stripMetadata(_ buffer: [UInt8]) throws -> [UInt8] {
    if buffer.count < 8 { throw ExifError.tooSmall }
    if isJpeg(buffer) { return stripJpeg(buffer) }
    if isPng(buffer) { return stripPng(buffer) }
    throw ExifError.unsupportedFormat
}

func parseMetadata(_ buffer: [UInt8]) throws -> ExifMetadata {
    if buffer.count < 8 { throw ExifError.tooSmall }
    if !isJpeg(buffer) && !isPng(buffer) { throw ExifError.unsupportedFormat }
    var meta = ExifMetadata(stripped: try 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 →