Skip to content

EXIF & Metadata Stripper — C++ source

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

This is the C++ implementation — the same logic the interactive tool runs, in a shareable, citable form.

// exif-stripper — EXIF / XMP / IPTC metadata reader + stripper for JPEG/PNG.
//
// Language: C++ (C++17, standard library only)
// Ported from src/lib/exif-stripper.ts (the canonical TypeScript
// implementation). display source — part of CosmoDev's polyglot tool pages.
//
// Pure buffer logic - no DOM, 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.

#include <cstdint>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>

namespace exif_stripper {

using Bytes = std::vector<uint8_t>;

/** Everything the parser surfaced, plus the stripped rebuild of the input. */
struct ExifMetadata {
  std::optional<std::string> cameraMake;
  std::optional<std::string> cameraModel;
  std::optional<std::string> software;
  std::optional<std::string> dateTime;
  /** Decimal degrees; negative for southern / western hemispheres. */
  std::optional<double> gpsLatitude;
  std::optional<double> gpsLongitude;
  std::optional<uint32_t> imageWidth;
  std::optional<uint32_t> imageHeight;
  /** EXIF orientation value 1-8. */
  std::optional<uint16_t> orientation;
  /** Seconds (e.g. 0.004 = 1/250 s). */
  std::optional<double> exposureTime;
  /** F-number (e.g. 2.8). */
  std::optional<double> fNumber;
  std::optional<uint32_t> iso;
  /** Millimetres. */
  std::optional<double> focalLength;
  /** A rebuilt copy of the input with every metadata segment removed. */
  Bytes stripped;
};

/** Degrees/minutes/seconds + hemisphere reference to signed decimal degrees. */
double formatGpsCoordinate(const std::vector<double>& degrees, const std::string& ref) {
  const double deg = degrees.size() > 0 ? degrees[0] : 0;
  const double min = degrees.size() > 1 ? degrees[1] : 0;
  const double sec = degrees.size() > 2 ? degrees[2] : 0;
  const double decimal = deg + min / 60 + sec / 3600;
  return ref == "S" || ref == "W" ? -decimal : decimal;
}

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

static const char EXIF_SIGNATURE[] = {'E', 'x', 'i', 'f', 0, 0};
static const char* const XMP_SIGNATURES[] = {
  "http://ns.adobe.com/xap/1.0/", "http://ns.adobe.com/xmp/extension/"};
static const char IPTC_SIGNATURE[] = {'P', 'h', 'o', 't', 'o', 's', 'h', 'o',
                                      'p', ' ', '3', '.', '0', 0};

static bool isJpeg(const Bytes& bytes) {
  return bytes.size() >= 2 && bytes[0] == 0xff && bytes[1] == 0xd8;
}

static bool isPng(const Bytes& bytes) {
  static const uint8_t SIG[8] = {0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a};
  return bytes.size() >= 8 &&
         std::equal(SIG, SIG + 8, bytes.begin());
}

/** True when `bytes[off..]` starts with the NUL-terminated string `sig`. */
static bool hasSignature(const Bytes& bytes, std::size_t off, const char* sig) {
  for (std::size_t i = 0; sig[i] != '\0' || i == 0; i++) {
    if (off + i >= bytes.size() || bytes[off + i] != uint8_t(sig[i])) return false;
    if (sig[i] == '\0') return true;  // matched through the embedded NUL
  }
  return true;
}

/** Bytes [off, off+len) as a trimmed string (trailing NULs stripped). */
static std::string utf8At(const Bytes& bytes, std::size_t off, std::size_t len) {
  std::size_t end = off + len;
  if (end > bytes.size()) end = bytes.size();
  std::string s(bytes.begin() + off, bytes.begin() + end);
  while (!s.empty() && (s.back() == '\0' || s.back() == ' ' || s.back() == '\t' ||
                        s.back() == '\r' || s.back() == '\n')) {
    s.pop_back();
  }
  return s;
}

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

struct JpegSegment {
  uint8_t marker;
  /** Segment start (at the 0xFF byte), end exclusive. */
  std::size_t start;
  std::size_t end;
  /** First payload byte (right after the 2-byte length field). */
  std::size_t payloadStart;
};

static uint16_t u16be(const Bytes& b, std::size_t off) {
  return uint16_t(b[off]) << 8 | b[off + 1];
}
static uint32_t u32be(const Bytes& b, std::size_t off) {
  return uint32_t(b[off]) << 24 | uint32_t(b[off + 1]) << 16 |
         uint32_t(b[off + 2]) << 8 | uint32_t(b[off + 3]);
}

// 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 an empty
// optional when the structure is corrupted mid-walk.
static std::optional<std::vector<JpegSegment>> collectJpegSegments(const Bytes& bytes) {
  std::vector<JpegSegment> segments;
  std::size_t off = 2;
  while (off + 2 <= bytes.size()) {
    if (bytes[off] != 0xff) return std::nullopt;
    if (bytes[off + 1] == 0xff) {
      off++;  // padding fill byte before the real marker
      continue;
    }
    const uint8_t marker = bytes[off + 1];
    // Standalone markers carry no length field (TEM, RSTn, SOI, EOI).
    if (marker == 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
      segments.push_back({marker, off, off + 2, off + 2});
      if (marker == 0xd9) break;
      off += 2;
      continue;
    }
    if (off + 4 > bytes.size()) return std::nullopt;
    const uint16_t size = u16be(bytes, off + 2);  // includes the 2 length bytes
    if (size < 2) return std::nullopt;
    const std::size_t end = off + 2 + size;
    if (end > bytes.size()) return std::nullopt;
    segments.push_back({marker, off, end, off + 4});
    if (marker == 0xda) break;  // SOS - entropy data follows, no more segments
    off = end;
  }
  return segments;
}

static bool isJpegMetadataSegment(const Bytes& bytes, const JpegSegment& seg) {
  if (seg.marker == 0xe1) {
    if (hasSignature(bytes, seg.payloadStart, EXIF_SIGNATURE)) return true;
    for (const char* 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.
static std::size_t typeSize(uint16_t type) {
  switch (type) {
    case 1: case 2: case 7: return 1;
    case 3: return 2;
    case 4: case 9: return 4;
    case 5: case 10: return 8;
    default: return 1;
  }
}

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

constexpr uint16_t TYPE_ASCII = 2;
constexpr uint16_t TYPE_SHORT = 3;
constexpr uint16_t TYPE_LONG = 4;
constexpr uint16_t TYPE_RATIONAL = 5;

struct Tiff {
  const Bytes& bytes;
  bool little;
  uint16_t u16(std::size_t off) const {
    return little ? uint16_t(bytes[off]) | uint16_t(bytes[off + 1]) << 8
                  : uint16_t(bytes[off]) << 8 | bytes[off + 1];
  }
  uint32_t u32(std::size_t off) const {
    return little ? uint32_t(bytes[off]) | uint32_t(bytes[off + 1]) << 8 |
                        uint32_t(bytes[off + 2]) << 16 | uint32_t(bytes[off + 3]) << 24
                  : uint32_t(bytes[off]) << 24 | uint32_t(bytes[off + 1]) << 16 |
                        uint32_t(bytes[off + 2]) << 8 | uint32_t(bytes[off + 3]);
  }
};

// Where the entry's value lives: inline in the 4-byte value field when it
// fits, else at the recorded byte offset. Returns nullopt when out of bounds.
static std::optional<std::size_t> valueOffset(const Tiff& t, std::size_t entry,
                                              uint16_t type, uint32_t count) {
  const std::size_t size = typeSize(type) * count;
  const std::size_t off = size <= 4 ? entry + 8 : t.u32(entry + 8);
  if (off + size > t.bytes.size()) return std::nullopt;
  return off;
}

static std::optional<std::string> readAscii(const Tiff& t, std::size_t entry,
                                            uint16_t type, uint32_t count) {
  if (type != TYPE_ASCII || count < 1) return std::nullopt;
  const auto off = valueOffset(t, entry, type, count);
  if (!off) return std::nullopt;
  std::string str = utf8At(t.bytes, *off, count);
  // Trim trailing NULs, then surrounding whitespace; empty means absent.
  std::size_t b = 0, e = str.size();
  while (b < e && (str[b] == ' ' || str[b] == '\t')) b++;
  std::string trimmed = str.substr(b);
  while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\t')) {
    trimmed.pop_back();
  }
  if (trimmed.empty()) return std::nullopt;
  return trimmed;
}

static std::optional<uint32_t> readNumber(const Tiff& t, std::size_t entry,
                                          uint16_t type, uint32_t count) {
  if (count < 1) return std::nullopt;
  const auto off = valueOffset(t, entry, type, count);
  if (!off) return std::nullopt;
  if (type == TYPE_SHORT) return uint32_t(t.u16(*off));
  if (type == TYPE_LONG) return t.u32(*off);
  return std::nullopt;
}

static std::optional<std::vector<double>> readRationals(const Tiff& t, std::size_t entry,
                                                        uint16_t type, uint32_t count) {
  if (type != TYPE_RATIONAL || count < 1) return std::nullopt;
  const auto off = valueOffset(t, entry, type, count);
  if (!off) return std::nullopt;
  std::vector<double> values;
  for (uint32_t i = 0; i < count; i++) {
    const uint32_t num = t.u32(*off + i * 8);
    const uint32_t den = t.u32(*off + i * 8 + 4);
    values.push_back(den == 0 ? 0 : double(num) / double(den));
  }
  return values;
}

static void readIfd(const Tiff& t, std::size_t offset, ExifMetadata& meta);
static void readExifIfd(const Tiff& t, std::size_t offset, ExifMetadata& meta);
static void readGpsIfd(const Tiff& t, std::size_t offset, ExifMetadata& meta);

static void parseTiff(const Bytes& tiffBytes, ExifMetadata& meta) {
  if (tiffBytes.size() < 8) return;
  bool little;
  if (tiffBytes[0] == 0x49 && tiffBytes[1] == 0x49) little = true;    // "II"
  else if (tiffBytes[0] == 0x4d && tiffBytes[1] == 0x4d) little = false;  // "MM"
  else return;
  const Tiff t{tiffBytes, little};
  if (t.u16(2) != 42) return;
  readIfd(t, t.u32(4), meta);
}

static void readIfd(const Tiff& t, std::size_t offset, ExifMetadata& meta) {
  if (offset < 8 || offset + 2 > t.bytes.size()) return;
  const uint16_t count = t.u16(offset);
  const std::size_t end = offset + 2 + std::size_t(count) * 12;
  if (end > t.bytes.size()) return;
  for (uint16_t i = 0; i < count; i++) {
    const std::size_t entry = offset + 2 + std::size_t(i) * 12;
    const uint16_t tag = t.u16(entry);
    const uint16_t type = t.u16(entry + 2);
    const uint32_t n = t.u32(entry + 4);
    switch (tag) {
      case TAG_IMAGE_WIDTH:
        if (!meta.imageWidth) meta.imageWidth = readNumber(t, entry, type, n);
        break;
      case TAG_IMAGE_HEIGHT:
        if (!meta.imageHeight) meta.imageHeight = readNumber(t, entry, type, n);
        break;
      case TAG_MAKE:
        if (!meta.cameraMake) meta.cameraMake = readAscii(t, entry, type, n);
        break;
      case TAG_MODEL:
        if (!meta.cameraModel) meta.cameraModel = readAscii(t, entry, type, n);
        break;
      case TAG_ORIENTATION:
        if (!meta.orientation) meta.orientation = uint16_t(readNumber(t, entry, type, n).value_or(0));
        break;
      case TAG_SOFTWARE:
        if (!meta.software) meta.software = readAscii(t, entry, type, n);
        break;
      case TAG_DATE_TIME:
        if (!meta.dateTime) meta.dateTime = readAscii(t, entry, type, n);
        break;
      case TAG_EXIF_IFD_POINTER: {
        const auto sub = readNumber(t, entry, TYPE_LONG, 1);
        if (sub) readExifIfd(t, *sub, meta);
        break;
      }
      case TAG_GPS_IFD_POINTER: {
        const auto gps = readNumber(t, entry, TYPE_LONG, 1);
        if (gps) readGpsIfd(t, *gps, meta);
        break;
      }
      default: break;
    }
  }
}

static void readExifIfd(const Tiff& t, std::size_t offset, ExifMetadata& meta) {
  if (offset < 8 || offset + 2 > t.bytes.size()) return;
  const uint16_t count = t.u16(offset);
  const std::size_t end = offset + 2 + std::size_t(count) * 12;
  if (end > t.bytes.size()) return;
  for (uint16_t i = 0; i < count; i++) {
    const std::size_t entry = offset + 2 + std::size_t(i) * 12;
    const uint16_t tag = t.u16(entry);
    const uint16_t type = t.u16(entry + 2);
    const uint32_t n = t.u32(entry + 4);
    switch (tag) {
      case TAG_EXPOSURE_TIME: {
        const auto values = readRationals(t, entry, type, n);
        if (values && !values->empty() && !meta.exposureTime) meta.exposureTime = (*values)[0];
        break;
      }
      case TAG_F_NUMBER: {
        const auto values = readRationals(t, entry, type, n);
        if (values && !values->empty() && !meta.fNumber) meta.fNumber = (*values)[0];
        break;
      }
      case TAG_ISO:
        if (!meta.iso) meta.iso = readNumber(t, entry, type, n);
        break;
      case TAG_DATE_TIME_ORIGINAL:
        // Phone photos usually carry the real capture time here only.
        if (!meta.dateTime) meta.dateTime = readAscii(t, entry, type, n);
        break;
      case TAG_FOCAL_LENGTH: {
        const auto values = readRationals(t, entry, type, n);
        if (values && !values->empty() && !meta.focalLength) meta.focalLength = (*values)[0];
        break;
      }
      default: break;
    }
  }
}

static void readGpsIfd(const Tiff& t, std::size_t offset, ExifMetadata& meta) {
  if (offset < 8 || offset + 2 > t.bytes.size()) return;
  const uint16_t count = t.u16(offset);
  const std::size_t end = offset + 2 + std::size_t(count) * 12;
  if (end > t.bytes.size()) return;
  std::string latRef = "N";
  std::string lonRef = "E";
  std::optional<std::vector<double>> lat;
  std::optional<std::vector<double>> lon;
  for (uint16_t i = 0; i < count; i++) {
    const std::size_t entry = offset + 2 + std::size_t(i) * 12;
    const uint16_t tag = t.u16(entry);
    const uint16_t type = t.u16(entry + 2);
    const uint32_t n = t.u32(entry + 4);
    switch (tag) {
      case TAG_GPS_LAT_REF: {
        const auto ref = readAscii(t, entry, type, n);
        if (ref && !ref->empty()) latRef = std::string(1, char(std::toupper((*ref)[0])));
        break;
      }
      case TAG_GPS_LAT:
        lat = readRationals(t, entry, type, n);
        break;
      case TAG_GPS_LON_REF: {
        const auto ref = readAscii(t, entry, type, n);
        if (ref && !ref->empty()) lonRef = std::string(1, char(std::toupper((*ref)[0])));
        break;
      }
      case TAG_GPS_LON:
        lon = readRationals(t, entry, type, n);
        break;
      default: break;
    }
  }
  if (lat && !meta.gpsLatitude) meta.gpsLatitude = formatGpsCoordinate(*lat, latRef);
  if (lon && !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</...>.
static void parseXmp(const Bytes& payload, ExifMetadata& meta) {
  const std::string text(payload.begin(), payload.end());
  const std::size_t close = std::string::npos;
  for (const char* tag : {":CreatorTool>", ":Software>"}) {
    const std::string needle(tag);
    std::size_t at = text.find(needle);
    while (at != close) {
      const std::size_t valueStart = at + needle.size();
      const std::size_t valueEnd = text.find('<', valueStart);
      if (valueEnd == close) break;
      std::string value = text.substr(valueStart, valueEnd - valueStart);
      // Trim whitespace, as the TS `.trim()` does.
      std::size_t b = value.find_first_not_of(" \t\r\n");
      if (b != close) {
        std::size_t e = value.find_last_not_of(" \t\r\n");
        value = value.substr(b, e - b + 1);
        if (!value.empty()) {
          if (!meta.software) meta.software = value;
          return;
        }
      }
      at = text.find(needle, valueEnd);
    }
  }
}

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

static void parseJpeg(const Bytes& bytes, ExifMetadata& meta) {
  const auto segments = collectJpegSegments(bytes);
  if (!segments) return;
  for (const auto& seg : *segments) {
    if (seg.marker == 0xe1) {
      if (hasSignature(bytes, seg.payloadStart, EXIF_SIGNATURE)) {
        parseTiff(Bytes(bytes.begin() + seg.payloadStart + sizeof(EXIF_SIGNATURE),
                        bytes.begin() + seg.end),
                  meta);
      } else {
        for (const char* sig : XMP_SIGNATURES) {
          if (hasSignature(bytes, seg.payloadStart, sig)) {
            parseXmp(Bytes(bytes.begin() + seg.payloadStart, bytes.begin() + seg.end), meta);
            break;
          }
        }
      }
    } 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) meta.imageHeight = u16be(bytes, seg.payloadStart + 1);
      if (!meta.imageWidth) meta.imageWidth = u16be(bytes, seg.payloadStart + 3);
    }
  }
}

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

static void parsePngTextChunk(const std::string& kind, const Bytes& data, ExifMetadata& meta) {
  const std::size_t nul = std::distance(data.begin(), std::find(data.begin(), data.end(), 0));
  if (nul < 1 || nul >= data.size()) return;
  const std::string keyword(data.begin(), data.begin() + nul);
  if (keyword != "Software") return;
  std::size_t p;
  if (kind == "tEXt") {
    p = nul + 1;
  } else {
    // iTXt: keyword\0 compressionFlag(1) compressionMethod(1) languageTag\0
    // translatedKeyword\0 text(utf-8). Only uncompressed text is read.
    if (nul + 1 >= data.size() || data[nul + 1] != 0) return;
    p = nul + 3;
    // Skip the language tag and the translated keyword (both NUL-terminated).
    for (int skipped = 0; skipped < 2; skipped++) {
      const std::size_t next = std::distance(data.begin(), std::find(data.begin() + p, data.end(), 0));
      if (next >= data.size()) return;
      p = next + 1;
    }
  }
  std::string value(data.begin() + p, data.end());
  std::size_t b = value.find_first_not_of(" \t\r\n");
  if (b == std::string::npos) return;
  std::size_t e = value.find_last_not_of(" \t\r\n");
  value = value.substr(b, e - b + 1);
  if (!value.empty() && !meta.software) meta.software = value;
}

static void parsePng(const Bytes& bytes, ExifMetadata& meta) {
  std::size_t off = 8;
  while (off + 8 <= bytes.size()) {
    const uint32_t length = u32be(bytes, off);
    if (length > bytes.size() - off - 12) break;  // corrupted chunk
    const std::string type(bytes.begin() + off + 4, bytes.begin() + off + 8);
    const Bytes data(bytes.begin() + off + 8, bytes.begin() + off + 8 + length);
    if (type == "IHDR") {
      if (!meta.imageWidth) meta.imageWidth = u32be(bytes, off + 8);
      if (!meta.imageHeight) meta.imageHeight = u32be(bytes, off + 12);
    } else if (type == "tEXt" || type == "iTXt") {
      parsePngTextChunk(type, data, meta);
    } else if (type == "eXIf") {
      parseTiff(data, meta);
    }
    if (type == "IEND") break;
    off += 12 + length;
  }
}

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

static Bytes concat(const std::vector<Bytes>& parts) {
  std::size_t total = 0;
  for (const auto& p : parts) total += p.size();
  Bytes out;
  out.reserve(total);
  for (const auto& p : parts) out.insert(out.end(), p.begin(), p.end());
  return out;
}

static Bytes stripJpeg(const Bytes& bytes) {
  const auto segments = collectJpegSegments(bytes);
  if (!segments) return bytes;  // corrupted walk - copy verbatim
  std::vector<Bytes> parts{Bytes(bytes.begin(), bytes.begin() + 2)};
  for (const auto& seg : *segments) {
    if (seg.marker == 0xda) {
      // SOS header + entropy data + EOI are copied verbatim to the end.
      parts.push_back(Bytes(bytes.begin() + seg.start, bytes.end()));
      break;
    }
    if (isJpegMetadataSegment(bytes, seg)) continue;
    parts.push_back(Bytes(bytes.begin() + seg.start, bytes.begin() + seg.end));
  }
  return concat(parts);
}

static Bytes stripPng(const Bytes& bytes) {
  std::vector<Bytes> parts{Bytes(bytes.begin(), bytes.begin() + 8)};
  std::size_t off = 8;
  while (off + 8 <= bytes.size()) {
    const uint32_t length = u32be(bytes, off);
    if (length > bytes.size() - off - 12) break;
    const std::string type(bytes.begin() + off + 4, bytes.begin() + off + 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.push_back(Bytes(bytes.begin() + off, bytes.begin() + off + 12 + length));
    }
    off += 12 + length;
  }
  if (off < bytes.size()) parts.push_back(Bytes(bytes.begin() + off, bytes.end()));
  return concat(parts);
}

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

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

ExifMetadata parseMetadata(const Bytes& buffer) {
  if (buffer.size() < 8) throw std::invalid_argument("File is too small to be a valid image.");
  if (!isJpeg(buffer) && !isPng(buffer)) {
    throw std::invalid_argument("Unsupported format: only JPEG and PNG images are supported.");
  }
  ExifMetadata meta;
  meta.stripped = stripMetadata(buffer);
  if (isJpeg(buffer)) parseJpeg(buffer, meta);
  else parsePng(buffer, meta);
  return meta;
}

}  // namespace exif_stripper

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 →