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 →