exif_strip_jpeg($in, $out), 'png' => exif_strip_png($in, $out), 'webp' => exif_strip_webp($in, $out), }; } catch (Throwable $e) { $removed = false; } fclose($in); fclose($out); // A rewrite that removed nothing is a byte-for-byte copy: drop it and let // the original go up, saving a second read of the whole file. if (!$removed || !exif_same_image($src, $dest)) { @unlink($dest); return null; } return $dest; } /** 'jpeg' | 'png' | 'webp' from the first bytes of a file, or null. */ function exif_detect_format(string $head): ?string { if (str_starts_with($head, "\xFF\xD8\xFF")) { return 'jpeg'; } if (str_starts_with($head, "\x89PNG\r\n\x1A\n")) { return 'png'; } if (str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP') { return 'webp'; } return null; } /** * The safety net: the stripped file must still be the same image. Anything the * parser got wrong — a truncated copy, a segment length misread, a container we * only thought we understood — shows up here as a failed or differing * getimagesize(), and the stripped copy is thrown away. */ function exif_same_image(string $src, string $dest): bool { $a = @getimagesize($src); $b = @getimagesize($dest); return is_array($a) && is_array($b) && $a[0] === $b[0] && $a[1] === $b[1] && $a[2] === $b[2]; } // --------------------------------------------------------------------------- // TIFF (the block inside an EXIF segment) // --------------------------------------------------------------------------- /** * The Orientation tag (0x0112) of a TIFF/EXIF block, or 1 ("upright") when it * is absent or unreadable. Only IFD0 is walked: orientation lives there, and a * block this code cannot follow simply reads as upright — the same thing a * viewer does with a missing tag. */ function exif_tiff_orientation(string $tiff): int { if (strlen($tiff) < 8) { return 1; } // Byte order is declared by the block itself: 'II' little-endian, 'MM' big. $order = substr($tiff, 0, 2); if ($order === 'II') { [$short, $long] = ['v', 'V']; } elseif ($order === 'MM') { [$short, $long] = ['n', 'N']; } else { return 1; } if (unpack($short, substr($tiff, 2, 2))[1] !== 42) { return 1; } $ifd = unpack($long, substr($tiff, 4, 4))[1]; if ($ifd < 8 || $ifd + 2 > strlen($tiff)) { return 1; } $count = unpack($short, substr($tiff, $ifd, 2))[1]; for ($i = 0; $i < $count; $i++) { $entry = $ifd + 2 + $i * 12; if ($entry + 12 > strlen($tiff)) { break; } if (unpack($short, substr($tiff, $entry, 2))[1] !== 0x0112) { continue; } // Type 3 = SHORT, and a single one fits in the entry's value field. if (unpack($short, substr($tiff, $entry + 2, 2))[1] !== 3) { break; } $value = unpack($short, substr($tiff, $entry + 8, 2))[1]; return $value >= 1 && $value <= 8 ? $value : 1; } return 1; } /** * A complete TIFF block holding one tag: Orientation. 26 bytes, big-endian, * one IFD, no thumbnail, no maker note — the whole point being that this is * everything we are willing to keep. */ function exif_minimal_tiff(int $orientation): string { return "MM\x00\x2A" . pack('N', 8) // header, IFD0 starts at byte 8 . pack('n', 1) // one entry . pack('n', 0x0112) . pack('n', 3) . pack('N', 1) . pack('n', $orientation) . "\x00\x00" // SHORT, left-aligned in 4 bytes . pack('N', 0); // no IFD1 } // --------------------------------------------------------------------------- // Stream helpers // --------------------------------------------------------------------------- /** Exactly $len bytes, or null if the stream ended early. */ function exif_read_exact($fh, int $len): ?string { $buf = ''; while (strlen($buf) < $len) { $chunk = fread($fh, $len - strlen($buf)); if ($chunk === false || $chunk === '') { return null; } $buf .= $chunk; } return $buf; } /** Copy $len bytes across without holding them in memory. */ function exif_copy_bytes($in, $out, int $len): bool { return $len === 0 || stream_copy_to_stream($in, $out, $len) === $len; } // --------------------------------------------------------------------------- // JPEG // --------------------------------------------------------------------------- /** * JPEG is a chain of marker segments (0xFF, marker, 2-byte length, payload) * ending at the start-of-scan, after which the entropy-coded image data runs to * the end of the file. Metadata lives entirely in the segments, so stripping is * a copy that skips some of them and never looks at the scan. */ function exif_strip_jpeg($in, $out): bool { if (fread($in, 2) !== "\xFF\xD8") { return false; } fwrite($out, "\xFF\xD8"); $removed = false; while (true) { $head = exif_read_exact($in, 2); if ($head === null || $head[0] !== "\xFF") { return false; } $marker = ord($head[1]); // Start of scan: the rest of the file is image data, copied verbatim. if ($marker === 0xDA) { fwrite($out, $head); return stream_copy_to_stream($in, $out) !== false && $removed; } // Markers that carry no payload (only 0x01 and the restart markers can // legally appear out here, but passing any of them through keeps a file // with padding between segments intact). if ($marker === 0x01 || ($marker >= 0xD0 && $marker <= 0xD9)) { fwrite($out, $head); continue; } $lenBytes = exif_read_exact($in, 2); if ($lenBytes === null) { return false; } $len = unpack('n', $lenBytes)[1]; if ($len < 2) { return false; } $payloadLen = $len - 2; // Only APPn and COM can hold metadata; everything else (quantisation // tables, Huffman tables, frame headers) is structure and streams past. $isApp = $marker >= 0xE0 && $marker <= 0xEF; if (!$isApp && $marker !== 0xFE) { fwrite($out, $head . $lenBytes); if (!exif_copy_bytes($in, $out, $payloadLen)) { return false; } continue; } // An APP segment is at most 64 KB, so reading it whole is cheap — and // the decision needs its first bytes anyway. $payload = exif_read_exact($in, $payloadLen); if ($payload === null) { return false; } if (exif_jpeg_segment_is_structural($marker, $payload)) { fwrite($out, $head . $lenBytes . $payload); continue; } $removed = true; // The one thing worth rescuing: how the camera was held. Re-emitted as // a segment holding that tag and nothing else, in place of the original. if ($marker === 0xE1 && str_starts_with($payload, "Exif\x00\x00")) { $orientation = exif_tiff_orientation(substr($payload, 6)); if ($orientation > 1) { $slim = "Exif\x00\x00" . exif_minimal_tiff($orientation); fwrite($out, "\xFF\xE1" . pack('n', strlen($slim) + 2) . $slim); } } } } /** * True for the few APP segments that describe how to render the image rather * than where it came from. Everything else — EXIF and XMP (APP1), IPTC and the * Photoshop resource block (APP13), FlashPix, vendor blocks, comments — goes. */ function exif_jpeg_segment_is_structural(int $marker, string $payload): bool { return match ($marker) { 0xE0 => true, // JFIF: pixel density 0xE2 => str_starts_with($payload, "ICC_PROFILE\x00"), // colour profile 0xEE => str_starts_with($payload, 'Adobe'), // colour transform default => false, }; } // --------------------------------------------------------------------------- // PNG // --------------------------------------------------------------------------- /** Chunks that hold metadata rather than image data. */ const EXIF_PNG_DROP_CHUNKS = ['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME']; /** * PNG is a signature followed by length/type/data/CRC chunks. Dropping one is * simply not copying it; because every chunk carries its own CRC, nothing has * to be recomputed for the chunks that stay. */ function exif_strip_png($in, $out): bool { $sig = exif_read_exact($in, 8); if ($sig === null) { return false; } fwrite($out, $sig); $removed = false; while (true) { $head = exif_read_exact($in, 8); if ($head === null) { return false; // ran out before IEND } $len = unpack('N', substr($head, 0, 4))[1]; $type = substr($head, 4, 4); if (in_array($type, EXIF_PNG_DROP_CHUNKS, true)) { // Only eXIf is worth reading (for the orientation); the rest is // skipped without ever being held in memory. $data = null; if ($type === 'eXIf' && $len <= EXIF_MAX_PARSE_BYTES) { $data = exif_read_exact($in, $len); if ($data === null) { return false; } } elseif (fseek($in, $len, SEEK_CUR) !== 0) { return false; } fseek($in, 4, SEEK_CUR); // the chunk's CRC $removed = true; if ($data !== null && ($orientation = exif_tiff_orientation($data)) > 1) { fwrite($out, exif_png_chunk('eXIf', exif_minimal_tiff($orientation))); } continue; } fwrite($out, $head); if (!exif_copy_bytes($in, $out, $len)) { return false; } $crc = exif_read_exact($in, 4); if ($crc === null) { return false; } fwrite($out, $crc); // IEND closes the image; anything appended after it is not part of the // PNG and is deliberately not carried over. if ($type === 'IEND') { return $removed; } } } /** One PNG chunk, CRC included (PHP's crc32 is the one PNG specifies). */ function exif_png_chunk(string $type, string $data): string { return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data)); } // --------------------------------------------------------------------------- // WebP // --------------------------------------------------------------------------- /** * WebP is RIFF: a 12-byte header whose size field covers everything after it, * then FourCC/size/payload chunks padded to an even length. Metadata sits in * the 'EXIF' and 'XMP ' chunks, and an extended file announces their presence * in the VP8X flag byte — so dropping them means clearing those bits too, or * decoders go looking for chunks that are no longer there. * * The orientation is read in a first pass, because VP8X (start of file) has to * be written before the EXIF chunk (end of file) is reached. */ function exif_strip_webp($in, $out): bool { $header = exif_read_exact($in, 12); if ($header === null) { return false; } $orientation = exif_webp_orientation($in); fwrite($out, $header); // RIFF size is patched in at the end $payloadBytes = 4; // the 'WEBP' FourCC already written $removed = false; while (!feof($in)) { $head = exif_read_exact($in, 8); if ($head === null) { break; // clean end of file } $type = substr($head, 0, 4); $len = unpack('V', substr($head, 4, 4))[1]; $padded = $len + ($len % 2); if ($type === 'EXIF' || $type === 'XMP ') { if (fseek($in, $padded, SEEK_CUR) !== 0) { return false; } $removed = true; if ($type === 'EXIF' && $orientation > 1) { $slim = exif_minimal_tiff($orientation); fwrite($out, 'EXIF' . pack('V', strlen($slim)) . $slim); $payloadBytes += 8 + strlen($slim); } continue; } if ($type === 'VP8X' && $len >= 10) { $data = exif_read_exact($in, $padded); if ($data === null) { return false; } // Bit 3 = EXIF present, bit 2 = XMP present. The EXIF bit survives // only when an orientation-only chunk is being written back. $flags = ord($data[0]) & ~0x0C; if ($orientation > 1) { $flags |= 0x08; } $data[0] = chr($flags); fwrite($out, $head . $data); $payloadBytes += 8 + $padded; continue; } fwrite($out, $head); if (!exif_copy_bytes($in, $out, $padded)) { return false; } $payloadBytes += 8 + $padded; } // RIFF states its own length, which just changed. if (fseek($out, 4) !== 0) { return false; } fwrite($out, pack('V', $payloadBytes)); return $removed; } /** Orientation from a WebP's EXIF chunk, leaving $in rewound for the real pass. */ function exif_webp_orientation($in): int { $start = ftell($in); $orientation = 1; while (true) { $head = exif_read_exact($in, 8); if ($head === null) { break; } $len = unpack('V', substr($head, 4, 4))[1]; $padded = $len + ($len % 2); if (substr($head, 0, 4) === 'EXIF' && $len <= EXIF_MAX_PARSE_BYTES) { $data = exif_read_exact($in, $len); $orientation = $data === null ? 1 : exif_tiff_orientation($data); break; } if (fseek($in, $padded, SEEK_CUR) !== 0) { break; } } fseek($in, $start); return $orientation; }