Skip to content

EXIF & Metadata Stripper — Kotlin source

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

This is the Kotlin 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 buffers.
// Language: Kotlin (JVM 17+), standard library only.
// Ported from src/lib/exif-stripper.ts — display source, part of CosmoDev's
// polyglot tool pages. Functionally equivalent to the TS reference: same
// inputs -> same outputs.
//
// The binary formats are parsed directly, with no image library:
//   - 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.

private const val NUL = 0.toByte()

/** Parsed metadata. Fields absent from the file stay null; `stripped` is a rebuilt copy with every metadata segment removed. */
data class ExifMetadata(
    val cameraMake: String? = null,
    val cameraModel: String? = null,
    val software: String? = null,
    val dateTime: String? = null,
    /** Decimal degrees; negative for southern / western hemispheres. */
    val gpsLatitude: Double? = null,
    val gpsLongitude: Double? = null,
    val imageWidth: Int? = null,
    val imageHeight: Int? = null,
    /** EXIF orientation value 1-8. */
    val orientation: Int? = null,
    /** Seconds (e.g. 0.004 = 1/250 s). */
    val exposureTime: Double? = null,
    /** F-number (e.g. 2.8). */
    val fNumber: Double? = null,
    val iso: Int? = null,
    /** Millimetres. */
    val focalLength: Double? = null,
    val stripped: ByteArray,
)

/** Degrees/minutes/seconds + hemisphere reference to signed decimal degrees. */
fun formatGpsCoordinate(degrees: DoubleArray, ref: String): Double {
    val deg = degrees.getOrElse(0) { 0.0 }
    val min = degrees.getOrElse(1) { 0.0 }
    val sec = degrees.getOrElse(2) { 0.0 }
    val decimal = deg + min / 60 + sec / 3600
    return if (ref == "S" || ref == "W") -decimal else decimal
}

// --- format detection ---------------------------------------------------------

private val PNG_SIGNATURE = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
private const val EXIF_SIGNATURE = "Exif"
private val XMP_SIGNATURES = listOf("http://ns.adobe.com/xap/1.0/", "http://ns.adobe.com/xmp/extension/")
private const val IPTC_SIGNATURE = "Photoshop 3.0"

private fun isJpeg(bytes: ByteArray): Boolean =
    bytes.size >= 2 && bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte()

private fun isPng(bytes: ByteArray): Boolean =
    bytes.size >= 8 && PNG_SIGNATURE.indices.all { bytes[it] == PNG_SIGNATURE[it] }

private fun hasSignature(bytes: ByteArray, off: Int, sig: String): Boolean {
    if (off < 0 || off + sig.length > bytes.size) return false
    for (i in sig.indices) {
        if (bytes[off + i] != sig[i].code.toByte()) return false
    }
    return true
}

// --- little helpers over raw bytes --------------------------------------------

private fun ByteArray.u16(off: Int, little: Boolean): Int {
    val lo = this[off].toInt() and 0xff
    val hi = this[off + 1].toInt() and 0xff
    return if (little) lo or (hi shl 8) else (lo shl 8) or hi
}

private fun ByteArray.u32(off: Int, little: Boolean): Long {
    val b0 = this[off].toLong() and 0xff
    val b1 = this[off + 1].toLong() and 0xff
    val b2 = this[off + 2].toLong() and 0xff
    val b3 = this[off + 3].toLong() and 0xff
    return if (little) b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
    else (b0 shl 24) or (b1 shl 16) or (b2 shl 8) or b3
}

private fun ByteArray.u32Big(off: Int): Long =
    ((this[off].toLong() and 0xff) shl 24) or ((this[off + 1].toLong() and 0xff) shl 16) or
        ((this[off + 2].toLong() and 0xff) shl 8) or (this[off + 3].toLong() and 0xff)

// --- JPEG segment walk --------------------------------------------------------

/** One JPEG segment: marker id, span [start, end), and first payload byte (after the 2-byte length field). */
private class JpegSegment(val marker: Int, val start: Int, val end: Int, val 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 null when
 * the structure is corrupted mid-walk.
 */
private fun collectJpegSegments(bytes: ByteArray): List<JpegSegment>? {
    val segments = mutableListOf<JpegSegment>()
    var off = 2
    while (off + 2 <= bytes.size) {
        if (bytes[off] != 0xff.toByte()) return null
        if (bytes[off + 1] == 0xff.toByte()) {
            off++ // padding fill byte before the real marker
            continue
        }
        val marker = bytes[off + 1].toInt() and 0xff
        // Standalone markers carry no length field (TEM, RSTn, SOI, EOI).
        if (marker == 0x01 || marker in 0xd0..0xd9) {
            segments.add(JpegSegment(marker, off, off + 2, off + 2))
            if (marker == 0xd9) break
            off += 2
            continue
        }
        if (off + 4 > bytes.size) return null
        val size = bytes.u16(off + 2, little = false) // includes the 2 length bytes itself
        if (size < 2) return null
        val end = off + 2 + size
        if (end > bytes.size) return null
        segments.add(JpegSegment(marker, off, end, off + 4))
        if (marker == 0xda) break // SOS - entropy data follows, no more segments
        off = end
    }
    return segments
}

private fun isJpegMetadataSegment(bytes: ByteArray, seg: JpegSegment): Boolean {
    if (seg.marker == 0xe1) {
        return hasSignature(bytes, seg.payloadStart, EXIF_SIGNATURE) ||
            XMP_SIGNATURES.any { hasSignature(bytes, seg.payloadStart, it) }
    }
    // APP13 is IPTC ("Photoshop 3.0\0" + 8BIM records) - always metadata.
    return seg.marker == 0xed && hasSignature(bytes, seg.payloadStart, IPTC_SIGNATURE)
}

// --- TIFF / EXIF IFD parsing --------------------------------------------------

/** EXIF field types -> byte size per component. */
private val TYPE_SIZE = mapOf(1 to 1, 2 to 1, 3 to 2, 4 to 4, 5 to 8, 7 to 1, 9 to 4, 10 to 8)

// IFD0 tags.
private const val TAG_IMAGE_WIDTH = 0x0100
private const val TAG_IMAGE_HEIGHT = 0x0101
private const val TAG_MAKE = 0x010f
private const val TAG_MODEL = 0x0110
private const val TAG_ORIENTATION = 0x0112
private const val TAG_SOFTWARE = 0x0131
private const val TAG_DATE_TIME = 0x0132
private const val TAG_EXIF_IFD_POINTER = 0x8769
private const val TAG_GPS_IFD_POINTER = 0x8825
// Exif SubIFD tags.
private const val TAG_EXPOSURE_TIME = 0x829a
private const val TAG_F_NUMBER = 0x829d
private const val TAG_ISO = 0x8827
private const val TAG_DATE_TIME_ORIGINAL = 0x9003
private const val TAG_FOCAL_LENGTH = 0x920a
// GPS IFD tags.
private const val TAG_GPS_LAT_REF = 0x0001
private const val TAG_GPS_LAT = 0x0002
private const val TAG_GPS_LON_REF = 0x0003
private const val TAG_GPS_LON = 0x0004

private const val TYPE_ASCII = 2
private const val TYPE_SHORT = 3
private const val TYPE_LONG = 4
private const val TYPE_RATIONAL = 5

/** Mutable metadata accumulator; `parseMetadata` freezes it into an [ExifMetadata]. */
private class MetadataBuilder {
    var cameraMake: String? = null
    var cameraModel: String? = null
    var software: String? = null
    var dateTime: String? = null
    var gpsLatitude: Double? = null
    var gpsLongitude: Double? = null
    var imageWidth: Int? = null
    var imageHeight: Int? = null
    var orientation: Int? = null
    var exposureTime: Double? = null
    var fNumber: Double? = null
    var iso: Int? = null
    var focalLength: Double? = null
}

/** A TIFF structure: its bytes and its byte order. */
private class Tiff(val bytes: ByteArray, val little: Boolean)

/**
 * 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.
 */
private fun valueOffset(t: Tiff, entry: Int, type: Int, count: Int): Int {
    val size = (TYPE_SIZE[type] ?: 1) * count
    val off = if (size <= 4) entry + 8 else t.bytes.u32(entry + 8, t.little).toInt()
    if (off < 0 || off + size > t.bytes.size) return -1
    return off
}

private fun readAscii(t: Tiff, entry: Int, type: Int, count: Int): String? {
    if (type != TYPE_ASCII || count < 1) return null
    val off = valueOffset(t, entry, type, count)
    if (off < 0) return null
    val str = t.bytes.copyOfRange(off, off + count).toString(Charsets.UTF_8).trimEnd('').trim()
    return str.ifEmpty { null }
}

private fun readNumber(t: Tiff, entry: Int, type: Int, count: Int): Int? {
    if (count < 1) return null
    val off = valueOffset(t, entry, type, count)
    if (off < 0) return null
    if (type == TYPE_SHORT) return t.bytes.u16(off, t.little)
    if (type == TYPE_LONG) return t.bytes.u32(off, t.little).toInt()
    return null
}

private fun readRationals(t: Tiff, entry: Int, type: Int, count: Int): DoubleArray? {
    if (type != TYPE_RATIONAL || count < 1) return null
    val off = valueOffset(t, entry, type, count)
    if (off < 0) return null
    val values = DoubleArray(count)
    for (i in 0 until count) {
        val num = t.bytes.u32(off + i * 8, t.little)
        val den = t.bytes.u32(off + i * 8 + 4, t.little)
        values[i] = if (den == 0L) 0.0 else num.toDouble() / den.toDouble()
    }
    return values
}

private fun parseTiff(tiffBytes: ByteArray, meta: MetadataBuilder) {
    if (tiffBytes.size < 8) return
    val little = when {
        tiffBytes[0] == 0x49.toByte() && tiffBytes[1] == 0x49.toByte() -> true  // "II"
        tiffBytes[0] == 0x4d.toByte() && tiffBytes[1] == 0x4d.toByte() -> false // "MM"
        else -> return
    }
    val t = Tiff(tiffBytes, little)
    if (tiffBytes.u16(2, little) != 42) return
    readIfd(t, tiffBytes.u32(4, little).toInt(), meta)
}

private fun readIfd(t: Tiff, offset: Int, meta: MetadataBuilder) {
    if (offset < 8 || offset + 2 > t.bytes.size) return
    val count = t.bytes.u16(offset, t.little)
    val end = offset + 2 + count * 12
    if (end > t.bytes.size) return
    for (i in 0 until count) {
        val entry = offset + 2 + i * 12
        val tag = t.bytes.u16(entry, t.little)
        val type = t.bytes.u16(entry + 2, t.little)
        val n = t.bytes.u32(entry + 4, t.little).toInt()
        when (tag) {
            TAG_IMAGE_WIDTH -> meta.imageWidth = meta.imageWidth ?: readNumber(t, entry, type, n)
            TAG_IMAGE_HEIGHT -> meta.imageHeight = meta.imageHeight ?: readNumber(t, entry, type, n)
            TAG_MAKE -> meta.cameraMake = meta.cameraMake ?: readAscii(t, entry, type, n)
            TAG_MODEL -> meta.cameraModel = meta.cameraModel ?: readAscii(t, entry, type, n)
            TAG_ORIENTATION -> meta.orientation = meta.orientation ?: readNumber(t, entry, type, n)
            TAG_SOFTWARE -> meta.software = meta.software ?: readAscii(t, entry, type, n)
            TAG_DATE_TIME -> meta.dateTime = meta.dateTime ?: readAscii(t, entry, type, n)
            TAG_EXIF_IFD_POINTER -> readNumber(t, entry, TYPE_LONG, 1)?.let { readExifIfd(t, it, meta) }
            TAG_GPS_IFD_POINTER -> readNumber(t, entry, TYPE_LONG, 1)?.let { readGpsIfd(t, it, meta) }
        }
    }
}

private fun readExifIfd(t: Tiff, offset: Int, meta: MetadataBuilder) {
    if (offset < 8 || offset + 2 > t.bytes.size) return
    val count = t.bytes.u16(offset, t.little)
    val end = offset + 2 + count * 12
    if (end > t.bytes.size) return
    for (i in 0 until count) {
        val entry = offset + 2 + i * 12
        val tag = t.bytes.u16(entry, t.little)
        val type = t.bytes.u16(entry + 2, t.little)
        val n = t.bytes.u32(entry + 4, t.little).toInt()
        when (tag) {
            TAG_EXPOSURE_TIME -> meta.exposureTime = meta.exposureTime ?: readRationals(t, entry, type, n)?.firstOrNull()
            TAG_F_NUMBER -> meta.fNumber = meta.fNumber ?: readRationals(t, entry, type, n)?.firstOrNull()
            TAG_ISO -> meta.iso = meta.iso ?: readNumber(t, entry, type, n)
            TAG_DATE_TIME_ORIGINAL ->
                // Phone photos usually carry the real capture time here only.
                meta.dateTime = meta.dateTime ?: readAscii(t, entry, type, n)
            TAG_FOCAL_LENGTH -> meta.focalLength = meta.focalLength ?: readRationals(t, entry, type, n)?.firstOrNull()
        }
    }
}

private fun readGpsIfd(t: Tiff, offset: Int, meta: MetadataBuilder) {
    if (offset < 8 || offset + 2 > t.bytes.size) return
    val count = t.bytes.u16(offset, t.little)
    val end = offset + 2 + count * 12
    if (end > t.bytes.size) return
    var latRef = "N"
    var lonRef = "E"
    var lat: DoubleArray? = null
    var lon: DoubleArray? = null
    for (i in 0 until count) {
        val entry = offset + 2 + i * 12
        val tag = t.bytes.u16(entry, t.little)
        val type = t.bytes.u16(entry + 2, t.little)
        val n = t.bytes.u32(entry + 4, t.little).toInt()
        when (tag) {
            TAG_GPS_LAT_REF -> readAscii(t, entry, type, n)?.let { latRef = it.take(1).uppercase() }
            TAG_GPS_LAT -> lat = readRationals(t, entry, type, n)
            TAG_GPS_LON_REF -> readAscii(t, entry, type, n)?.let { lonRef = it.take(1).uppercase() }
            TAG_GPS_LON -> lon = readRationals(t, entry, type, n)
        }
    }
    if (lat != null) meta.gpsLatitude = meta.gpsLatitude ?: formatGpsCoordinate(lat, latRef)
    if (lon != null) meta.gpsLongitude = 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 fun parseXmp(payload: ByteArray, meta: MetadataBuilder) {
    val text = payload.toString(Charsets.UTF_8)
    val match = Regex(":(?:CreatorTool|Software)>([^<]+)<").find(text) ?: return
    meta.software = meta.software ?: match.groupValues[1].trim()
}

// --- JPEG parse ---------------------------------------------------------------

private fun parseJpeg(bytes: ByteArray, meta: MetadataBuilder) {
    for (seg in collectJpegSegments(bytes) ?: return) {
        val payload = bytes.copyOfRange(seg.payloadStart, seg.end)
        if (seg.marker == 0xe1) {
            when {
                hasSignature(bytes, seg.payloadStart, EXIF_SIGNATURE) ->
                    parseTiff(payload.copyOfRange(EXIF_SIGNATURE.length, payload.size), meta)
                XMP_SIGNATURES.any { hasSignature(bytes, seg.payloadStart, it) } ->
                    parseXmp(payload, meta)
            }
        } else if (seg.marker in 0xc0..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 = meta.imageHeight ?: bytes.u16(seg.payloadStart + 1, little = false)
            meta.imageWidth = meta.imageWidth ?: bytes.u16(seg.payloadStart + 3, little = false)
        }
    }
}

// --- PNG parse ----------------------------------------------------------------

private fun parsePng(bytes: ByteArray, meta: MetadataBuilder) {
    var off = 8
    while (off + 8 <= bytes.size) {
        val length = bytes.u32Big(off).toInt()
        if (length > bytes.size - off - 12) break // corrupted chunk
        val type = bytes.copyOfRange(off + 4, off + 8).toString(Charsets.UTF_8)
        val data = bytes.copyOfRange(off + 8, off + 8 + length)
        when (type) {
            "IHDR" -> {
                meta.imageWidth = meta.imageWidth ?: bytes.u32Big(off + 8).toInt()
                meta.imageHeight = meta.imageHeight ?: bytes.u32Big(off + 12).toInt()
            }
            "tEXt", "iTXt" -> parsePngTextChunk(type, data, meta)
            "eXIf" -> parseTiff(data, meta)
        }
        if (type == "IEND") break
        off += 12 + length
    }
}

private fun parsePngTextChunk(kind: String, data: ByteArray, meta: MetadataBuilder) {
    val nul = data.indexOfFirst { it == NUL }
    if (nul < 1) return
    val keyword = data.copyOfRange(0, nul).toString(Charsets.UTF_8)
    if (keyword != "Software") return
    val text: ByteArray = if (kind == "tEXt") {
        data.copyOfRange(nul + 1, data.size)
    } else {
        // iTXt: keyword\0 compressionFlag(1) compressionMethod(1) languageTag\0
        // translatedKeyword\0 text(utf-8). Only uncompressed text is read.
        if (data.getOrNull(nul + 1) != NUL) return
        var p = nul + 3
        // Skip the language tag and the translated keyword (both NUL-terminated).
        repeat(2) {
            val next = data.drop(p).indexOfFirst { it == NUL }
            if (next < 0) return
            p += next + 1
        }
        data.copyOfRange(p, data.size)
    }
    val value = text.toString(Charsets.UTF_8).trim()
    if (value.isNotEmpty()) meta.software = meta.software ?: value
}

// --- strip --------------------------------------------------------------------

private fun concat(parts: List<ByteArray>): ByteArray {
    val out = ByteArray(parts.sumOf { it.size })
    var off = 0
    for (part in parts) {
        part.copyInto(out, off)
        off += part.size
    }
    return out
}

private fun stripJpeg(bytes: ByteArray): ByteArray {
    val segments = collectJpegSegments(bytes) ?: return bytes.copyOf() // corrupted walk - copy verbatim
    val parts = mutableListOf(bytes.copyOfRange(0, 2))
    for (seg in segments) {
        if (seg.marker == 0xda) {
            // SOS header + entropy data + EOI are copied verbatim to the end.
            parts.add(bytes.copyOfRange(seg.start, bytes.size))
            break
        }
        if (isJpegMetadataSegment(bytes, seg)) continue
        parts.add(bytes.copyOfRange(seg.start, seg.end))
    }
    return concat(parts)
}

private fun stripPng(bytes: ByteArray): ByteArray {
    val parts = mutableListOf(bytes.copyOfRange(0, 8))
    var off = 8
    while (off + 8 <= bytes.size) {
        val length = bytes.u32Big(off).toInt()
        if (length > bytes.size - off - 12) break
        val type = bytes.copyOfRange(off + 4, off + 8).toString(Charsets.UTF_8)
        // 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.copyOfRange(off, off + 12 + length))
        }
        off += 12 + length
    }
    if (off < bytes.size) parts.add(bytes.copyOfRange(off, bytes.size))
    return concat(parts)
}

// --- public API ---------------------------------------------------------------

fun stripMetadata(buffer: ByteArray): ByteArray {
    if (buffer.size < 8) throw IllegalArgumentException("File is too small to be a valid image.")
    if (isJpeg(buffer)) return stripJpeg(buffer)
    if (isPng(buffer)) return stripPng(buffer)
    throw IllegalArgumentException("Unsupported format: only JPEG and PNG images are supported.")
}

fun parseMetadata(buffer: ByteArray): ExifMetadata {
    if (buffer.size < 8) throw IllegalArgumentException("File is too small to be a valid image.")
    if (!isJpeg(buffer) && !isPng(buffer)) {
        throw IllegalArgumentException("Unsupported format: only JPEG and PNG images are supported.")
    }
    val meta = MetadataBuilder()
    val stripped = stripMetadata(buffer)
    if (isJpeg(buffer)) parseJpeg(buffer, meta) else parsePng(buffer, meta)
    return ExifMetadata(
        cameraMake = meta.cameraMake,
        cameraModel = meta.cameraModel,
        software = meta.software,
        dateTime = meta.dateTime,
        gpsLatitude = meta.gpsLatitude,
        gpsLongitude = meta.gpsLongitude,
        imageWidth = meta.imageWidth,
        imageHeight = meta.imageHeight,
        orientation = meta.orientation,
        exposureTime = meta.exposureTime,
        fNumber = meta.fNumber,
        iso = meta.iso,
        focalLength = meta.focalLength,
        stripped = stripped,
    )
}

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 →