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 →