Skip to content

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 →