EXIF & Metadata Stripper — Java source
View and strip GPS, camera, date, and software metadata from photos entirely in your browser. Download a clean copy.
This is the Java implementation — the same logic the interactive tool runs, in a shareable, citable form.
// EXIF / XMP / IPTC metadata reader + stripper for JPEG and PNG buffers.
//
// Language: Java (17+, standard library only)
// Ported from src/lib/exif-stripper.ts
// display source — part of CosmoDev's polyglot tool pages.
//
// Pure byte[] logic - no imaging 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 java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class ExifStripper {
/** Parsed metadata; nullable fields are absent in the image. */
public static final class ExifMetadata {
public String cameraMake;
public String cameraModel;
public String software;
public String dateTime;
/** Decimal degrees; negative for southern / western hemispheres. */
public Double gpsLatitude;
public Double gpsLongitude;
public Integer imageWidth;
public Integer imageHeight;
/** EXIF orientation value 1-8. */
public Integer orientation;
/** Seconds (e.g. 0.004 = 1/250 s). */
public Double exposureTime;
/** F-number (e.g. 2.8). */
public Double fNumber;
public Integer iso;
/** Millimetres. */
public Double focalLength;
/** A rebuilt copy of the input with every metadata segment removed. */
public byte[] stripped;
}
/** Degrees/minutes/seconds + hemisphere reference to signed decimal degrees. */
public static double formatGpsCoordinate(double[] degrees, String ref) {
double deg = degrees.length > 0 ? degrees[0] : 0;
double min = degrees.length > 1 ? degrees[1] : 0;
double sec = degrees.length > 2 ? degrees[2] : 0;
double decimal = deg + min / 60 + sec / 3600;
return "S".equals(ref) || "W".equals(ref) ? -decimal : decimal;
}
// --- format detection ---------------------------------------------------------
private static final byte[] PNG_SIGNATURE = {
(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a};
private static final String EXIF_SIGNATURE = "Exif\0\0";
private static final String[] XMP_SIGNATURES = {
"http://ns.adobe.com/xap/1.0/", "http://ns.adobe.com/xmp/extension/"};
private static final String IPTC_SIGNATURE = "Photoshop 3.0\0";
private static boolean isJpeg(byte[] bytes) {
return bytes.length >= 2 && (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8;
}
private static boolean isPng(byte[] bytes) {
if (bytes.length < 8) return false;
for (int i = 0; i < 8; i++) {
if (bytes[i] != PNG_SIGNATURE[i]) return false;
}
return true;
}
private static boolean hasSignature(byte[] bytes, int off, String sig) {
if (off < 0 || off + sig.length() > bytes.length) return false;
for (int i = 0; i < sig.length(); i++) {
if (bytes[off + i] != (byte) sig.charAt(i)) return false;
}
return true;
}
private static String utf8(byte[] bytes, int off, int len) {
return new String(bytes, off, len, StandardCharsets.UTF_8);
}
// --- little helpers -----------------------------------------------------------
private static int u16(byte[] b, int off, boolean little) {
if (little) return ((b[off + 1] & 0xff) << 8) | (b[off] & 0xff);
return ((b[off] & 0xff) << 8) | (b[off + 1] & 0xff);
}
private static int u32(byte[] b, int off, boolean little) {
if (little) {
return ((b[off + 3] & 0xff) << 24) | ((b[off + 2] & 0xff) << 16)
| ((b[off + 1] & 0xff) << 8) | (b[off] & 0xff);
}
return ((b[off] & 0xff) << 24) | ((b[off + 1] & 0xff) << 16)
| ((b[off + 2] & 0xff) << 8) | (b[off + 3] & 0xff);
}
private static byte[] slice(byte[] bytes, int from, int toExclusive) {
return Arrays.copyOfRange(bytes, from, Math.min(toExclusive, bytes.length));
}
private static byte[] concat(List<byte[]> parts) {
int total = 0;
for (byte[] p : parts) total += p.length;
byte[] out = new byte[total];
int off = 0;
for (byte[] p : parts) {
System.arraycopy(p, 0, out, off, p.length);
off += p.length;
}
return out;
}
// --- JPEG segment walk --------------------------------------------------------
private static final class JpegSegment {
final int marker;
/** Segment start (at the 0xFF byte), end exclusive. */
final int start;
final int end;
/** First payload byte (right after the 2-byte length field). */
final int payloadStart;
JpegSegment(int marker, int start, int end, int payloadStart) {
this.marker = marker;
this.start = start;
this.end = end;
this.payloadStart = payloadStart;
}
}
// 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 static List<JpegSegment> collectJpegSegments(byte[] bytes) {
List<JpegSegment> segments = new ArrayList<>();
int off = 2;
while (off + 2 <= bytes.length) {
if ((bytes[off] & 0xff) != 0xff) return null;
if ((bytes[off + 1] & 0xff) == 0xff) {
off++; // padding fill byte before the real marker
continue;
}
int marker = bytes[off + 1] & 0xff;
// 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;
int size = u16(bytes, off + 2, false); // includes the 2 length bytes itself
if (size < 2) return null;
int 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 boolean isJpegMetadataSegment(byte[] bytes, JpegSegment seg) {
if (seg.marker == 0xe1) {
if (hasSignature(bytes, seg.payloadStart, EXIF_SIGNATURE)) return true;
for (String sig : XMP_SIGNATURES) {
if (hasSignature(bytes, seg.payloadStart, sig)) return true;
}
return false;
}
// 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 static int typeSize(int type) {
return switch (type) {
case 1, 2, 7 -> 1;
case 3 -> 2;
case 4, 9 -> 4;
case 5, 10 -> 8;
default -> 1;
};
}
// IFD0 tags.
private static final int TAG_IMAGE_WIDTH = 0x0100;
private static final int TAG_IMAGE_HEIGHT = 0x0101;
private static final int TAG_MAKE = 0x010f;
private static final int TAG_MODEL = 0x0110;
private static final int TAG_ORIENTATION = 0x0112;
private static final int TAG_SOFTWARE = 0x0131;
private static final int TAG_DATE_TIME = 0x0132;
private static final int TAG_EXIF_IFD_POINTER = 0x8769;
private static final int TAG_GPS_IFD_POINTER = 0x8825;
// Exif SubIFD tags.
private static final int TAG_EXPOSURE_TIME = 0x829a;
private static final int TAG_F_NUMBER = 0x829d;
private static final int TAG_ISO = 0x8827;
private static final int TAG_DATE_TIME_ORIGINAL = 0x9003;
private static final int TAG_FOCAL_LENGTH = 0x920a;
// GPS IFD tags.
private static final int TAG_GPS_LAT_REF = 0x0001;
private static final int TAG_GPS_LAT = 0x0002;
private static final int TAG_GPS_LON_REF = 0x0003;
private static final int TAG_GPS_LON = 0x0004;
private static final int TYPE_ASCII = 2;
private static final int TYPE_SHORT = 3;
private static final int TYPE_LONG = 4;
private static final int TYPE_RATIONAL = 5;
private static final class Tiff {
final byte[] bytes;
final boolean little;
Tiff(byte[] bytes, boolean little) {
this.bytes = bytes;
this.little = little;
}
}
// 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 static int valueOffset(Tiff t, int entry, int type, int count) {
int size = typeSize(type) * count;
int off = size <= 4 ? entry + 8 : u32(t.bytes, entry + 8, t.little);
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 != TYPE_ASCII || count < 1) return null;
int off = valueOffset(t, entry, type, count);
if (off < 0) return null;
String str = utf8(t.bytes, off, count).replaceAll("\0+$", "").trim();
return str.isEmpty() ? null : str;
}
private static Integer readNumber(Tiff t, int entry, int type, int count) {
if (count < 1) return null;
int off = valueOffset(t, entry, type, count);
if (off < 0) return null;
if (type == TYPE_SHORT) return u16(t.bytes, off, t.little);
if (type == TYPE_LONG) return u32(t.bytes, off, t.little);
return null;
}
private static double[] readRationals(Tiff t, int entry, int type, int count) {
if (type != TYPE_RATIONAL || count < 1) return null;
int off = valueOffset(t, entry, type, count);
if (off < 0) return null;
double[] values = new double[count];
for (int i = 0; i < count; i++) {
long num = u32(t.bytes, off + i * 8, t.little) & 0xffffffffL;
long den = u32(t.bytes, off + i * 8 + 4, t.little) & 0xffffffffL;
values[i] = den == 0 ? 0 : num / (double) den;
}
return values;
}
private static void parseTiff(byte[] tiffBytes, ExifMetadata meta) {
if (tiffBytes.length < 8) return;
boolean little;
if (tiffBytes[0] == 0x49 && tiffBytes[1] == 0x49) little = true; // "II"
else if (tiffBytes[0] == 0x4d && tiffBytes[1] == 0x4d) little = false; // "MM"
else return;
Tiff t = new Tiff(tiffBytes, little);
if (u16(tiffBytes, 2, little) != 42) return;
readIfd(t, u32(tiffBytes, 4, little), meta);
}
private static void readIfd(Tiff t, int offset, ExifMetadata meta) {
int count = ifdEntryCount(t, offset);
if (count < 0) return;
for (int i = 0; i < count; i++) {
int entry = offset + 2 + i * 12;
int tag = u16(t.bytes, entry, t.little);
int type = u16(t.bytes, entry + 2, t.little);
int n = u32(t.bytes, entry + 4, t.little);
switch (tag) {
case TAG_IMAGE_WIDTH -> {
if (meta.imageWidth == null) meta.imageWidth = readNumber(t, entry, type, n);
}
case TAG_IMAGE_HEIGHT -> {
if (meta.imageHeight == null) meta.imageHeight = readNumber(t, entry, type, n);
}
case TAG_MAKE -> {
if (meta.cameraMake == null) meta.cameraMake = readAscii(t, entry, type, n);
}
case TAG_MODEL -> {
if (meta.cameraModel == null) meta.cameraModel = readAscii(t, entry, type, n);
}
case TAG_ORIENTATION -> {
if (meta.orientation == null) meta.orientation = readNumber(t, entry, type, n);
}
case TAG_SOFTWARE -> {
if (meta.software == null) meta.software = readAscii(t, entry, type, n);
}
case TAG_DATE_TIME -> {
if (meta.dateTime == null) meta.dateTime = readAscii(t, entry, type, n);
}
case TAG_EXIF_IFD_POINTER -> {
Integer sub = readNumber(t, entry, TYPE_LONG, 1);
if (sub != null) readExifIfd(t, sub, meta);
}
case TAG_GPS_IFD_POINTER -> {
Integer gps = readNumber(t, entry, TYPE_LONG, 1);
if (gps != null) readGpsIfd(t, gps, meta);
}
default -> { /* tag not surfaced */ }
}
}
}
private static void readExifIfd(Tiff t, int offset, ExifMetadata meta) {
int count = ifdEntryCount(t, offset);
if (count < 0) return;
for (int i = 0; i < count; i++) {
int entry = offset + 2 + i * 12;
int tag = u16(t.bytes, entry, t.little);
int type = u16(t.bytes, entry + 2, t.little);
int n = u32(t.bytes, entry + 4, t.little);
switch (tag) {
case TAG_EXPOSURE_TIME -> {
if (meta.exposureTime == null) meta.exposureTime = first(readRationals(t, entry, type, n));
}
case TAG_F_NUMBER -> {
if (meta.fNumber == null) meta.fNumber = first(readRationals(t, entry, type, n));
}
case TAG_ISO -> {
if (meta.iso == null) meta.iso = readNumber(t, entry, type, n);
}
case TAG_DATE_TIME_ORIGINAL ->
// Phone photos usually carry the real capture time here only.
meta.dateTime = meta.dateTime != null ? meta.dateTime : readAscii(t, entry, type, n);
case TAG_FOCAL_LENGTH -> {
if (meta.focalLength == null) meta.focalLength = first(readRationals(t, entry, type, n));
}
default -> { /* tag not surfaced */ }
}
}
}
private static void readGpsIfd(Tiff t, int offset, ExifMetadata meta) {
int count = ifdEntryCount(t, offset);
if (count < 0) return;
String latRef = "N";
String lonRef = "E";
double[] lat = null;
double[] lon = null;
for (int i = 0; i < count; i++) {
int entry = offset + 2 + i * 12;
int tag = u16(t.bytes, entry, t.little);
int type = u16(t.bytes, entry + 2, t.little);
int n = u32(t.bytes, entry + 4, t.little);
switch (tag) {
case TAG_GPS_LAT_REF -> {
String ref = readAscii(t, entry, type, n);
if (ref != null && !ref.isEmpty()) {
latRef = ref.substring(0, 1).toUpperCase();
}
}
case TAG_GPS_LAT -> lat = readRationals(t, entry, type, n);
case TAG_GPS_LON_REF -> {
String ref = readAscii(t, entry, type, n);
if (ref != null && !ref.isEmpty()) {
lonRef = ref.substring(0, 1).toUpperCase();
}
}
case TAG_GPS_LON -> lon = readRationals(t, entry, type, n);
default -> { /* tag not surfaced */ }
}
}
if (lat != null && meta.gpsLatitude == null) meta.gpsLatitude = formatGpsCoordinate(lat, latRef);
if (lon != null && meta.gpsLongitude == null) meta.gpsLongitude = formatGpsCoordinate(lon, lonRef);
}
/** Entry count of a well-formed IFD at `offset`, or -1 when out of bounds. */
private static int ifdEntryCount(Tiff t, int offset) {
if (offset < 8 || offset + 2 > t.bytes.length) return -1;
int count = u16(t.bytes, offset, t.little);
if (offset + 2 + count * 12 > t.bytes.length) return -1;
return count;
}
private static Double first(double[] values) {
return values != null && values.length > 0 ? values[0] : null;
}
// 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 final Pattern XMP_TOOL = Pattern.compile(":(?:CreatorTool|Software)>([^<]+)<");
private static void parseXmp(byte[] payload, ExifMetadata meta) {
String text = utf8(payload, 0, payload.length);
Matcher match = XMP_TOOL.matcher(text);
if (match.find() && meta.software == null) {
String value = match.group(1).trim();
if (!value.isEmpty()) meta.software = value;
}
}
// --- JPEG parse ---------------------------------------------------------------
private static void parseJpeg(byte[] bytes, ExifMetadata meta) {
List<JpegSegment> segments = collectJpegSegments(bytes);
if (segments == null) return;
for (JpegSegment seg : segments) {
if (seg.marker == 0xe1) {
boolean xmp = false;
for (String sig : XMP_SIGNATURES) {
if (hasSignature(bytes, seg.payloadStart, sig)) xmp = true;
}
if (hasSignature(bytes, seg.payloadStart, EXIF_SIGNATURE)) {
parseTiff(slice(bytes, seg.payloadStart + EXIF_SIGNATURE.length(), seg.end), meta);
} else if (xmp) {
parseXmp(slice(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.
if (meta.imageHeight == null) meta.imageHeight = u16(bytes, seg.payloadStart + 1, false);
if (meta.imageWidth == null) meta.imageWidth = u16(bytes, seg.payloadStart + 3, false);
}
}
}
// --- PNG parse ----------------------------------------------------------------
private static int indexOfByte(byte[] data, int from, int value) {
for (int i = Math.max(from, 0); i < data.length; i++) {
if ((data[i] & 0xff) == value) return i;
}
return -1;
}
private static void parsePng(byte[] bytes, ExifMetadata meta) {
int off = 8;
while (off + 8 <= bytes.length) {
long length = u32(bytes, off, false) & 0xffffffffL;
if (length > bytes.length - off - 12L) break; // corrupted chunk
String type = utf8(bytes, off + 4, 4);
byte[] data = slice(bytes, off + 8, off + 8 + (int) length);
switch (type) {
case "IHDR" -> {
if (meta.imageWidth == null) meta.imageWidth = u32(bytes, off + 8, false);
if (meta.imageHeight == null) meta.imageHeight = u32(bytes, off + 12, false);
}
case "tEXt", "iTXt" -> parsePngTextChunk(type, data, meta);
case "eXIf" -> parseTiff(data, meta);
default -> { /* not metadata-bearing for our purposes */ }
}
if ("IEND".equals(type)) break;
off += 12 + (int) length;
}
}
private static void parsePngTextChunk(String kind, byte[] data, ExifMetadata meta) {
int nul = indexOfByte(data, 0, 0);
if (nul < 1) return;
String keyword = utf8(data, 0, nul);
if (!"Software".equals(keyword)) return;
byte[] text;
if ("tEXt".equals(kind)) {
text = slice(data, nul + 1, data.length);
} else {
// iTXt: keyword\0 compressionFlag(1) compressionMethod(1) languageTag\0
// translatedKeyword\0 text(utf-8). Only uncompressed text is read.
if ((data[nul + 1] & 0xff) != 0) return;
int p = nul + 3;
// Skip the language tag and the translated keyword (both NUL-terminated).
for (int skipped = 0; skipped < 2; skipped++) {
int next = indexOfByte(data, p, 0);
if (next < 0) return;
p = next + 1;
}
text = slice(data, p, data.length);
}
String value = utf8(text, 0, text.length).trim();
if (!value.isEmpty() && meta.software == null) meta.software = value;
}
// --- strip --------------------------------------------------------------------
private static byte[] stripJpeg(byte[] bytes) {
List<JpegSegment> segments = collectJpegSegments(bytes);
if (segments == null) return bytes.clone(); // corrupted walk - copy verbatim
List<byte[]> parts = new ArrayList<>();
parts.add(slice(bytes, 0, 2));
for (JpegSegment seg : segments) {
if (seg.marker == 0xda) {
// SOS header + entropy data + EOI are copied verbatim to the end.
parts.add(slice(bytes, seg.start, bytes.length));
break;
}
if (isJpegMetadataSegment(bytes, seg)) continue;
parts.add(slice(bytes, seg.start, seg.end));
}
return concat(parts);
}
private static byte[] stripPng(byte[] bytes) {
List<byte[]> parts = new ArrayList<>();
parts.add(slice(bytes, 0, 8));
int off = 8;
while (off + 8 <= bytes.length) {
long length = u32(bytes, off, false) & 0xffffffffL;
if (length > bytes.length - off - 12L) break;
String type = utf8(bytes, off + 4, 4);
// tEXt / iTXt / eXIf are the metadata carriers; everything else (IHDR,
// PLTE, IDAT, ...) is copied byte-for-byte, CRC included.
if (!"tEXt".equals(type) && !"iTXt".equals(type) && !"eXIf".equals(type)) {
parts.add(slice(bytes, off, off + 12 + (int) length));
}
off += 12 + (int) length;
}
if (off < bytes.length) parts.add(slice(bytes, off, bytes.length));
return concat(parts);
}
// --- public API ---------------------------------------------------------------
public static byte[] stripMetadata(byte[] buffer) {
if (buffer.length < 8) {
throw new IllegalArgumentException("File is too small to be a valid image.");
}
if (isJpeg(buffer)) return stripJpeg(buffer);
if (isPng(buffer)) return stripPng(buffer);
throw new IllegalArgumentException("Unsupported format: only JPEG and PNG images are supported.");
}
public static ExifMetadata parseMetadata(byte[] buffer) {
byte[] bytes = buffer;
if (bytes.length < 8) {
throw new IllegalArgumentException("File is too small to be a valid image.");
}
if (!isJpeg(bytes) && !isPng(bytes)) {
throw new IllegalArgumentException("Unsupported format: only JPEG and PNG images are supported.");
}
ExifMetadata meta = new ExifMetadata();
meta.stripped = stripMetadata(bytes);
if (isJpeg(bytes)) parseJpeg(bytes, meta);
else parsePng(bytes, meta);
return meta;
}
private ExifStripper() {
}
}
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 →