|
@@ -0,0 +1,481 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+/**
|
|
|
|
|
+ * Metadata stripping for uploaded images — the per-gallery "strip EXIF" option.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Why the server and not the browser
|
|
|
|
|
+ * ----------------------------------
|
|
|
|
|
+ * Thumbnails and the resolution cap are browser work, because both need the
|
|
|
|
|
+ * decoded pixels anyway. Stripping needs none: it is a container rewrite, the
|
|
|
|
|
+ * uploaded file already sits in a temp file on the webhost, and doing it here
|
|
|
|
|
+ * means the promise "this gallery carries no EXIF" holds for every upload —
|
|
|
|
|
+ * including one from a stale cached admin.js or a hand-crafted POST. A gallery
|
|
|
|
|
+ * with a resolution cap gets stripping for free from the browser's re-encode;
|
|
|
|
|
+ * this is what makes it available to galleries that keep their originals.
|
|
|
|
|
+ *
|
|
|
|
|
+ * What is removed
|
|
|
|
|
+ * ---------------
|
|
|
|
|
+ * Everything a camera, phone or editor writes about the photo — EXIF (camera,
|
|
|
|
|
+ * lens, exposure, timestamps, GPS), XMP, IPTC/Photoshop blocks, comments — but
|
|
|
|
|
+ * nothing the picture needs to render:
|
|
|
|
|
+ *
|
|
|
|
|
+ * - the pixels are never touched: no decode, no re-encode, no quality loss
|
|
|
|
|
+ * - the ICC colour profile stays, or colours would shift
|
|
|
|
|
+ * - the JFIF (density) and Adobe (colour transform) blocks stay
|
|
|
|
|
+ * - the orientation flag is re-written on its own, so a photo shot in
|
|
|
|
|
+ * portrait still shows upright. It says which way up, not who or where.
|
|
|
|
|
+ *
|
|
|
|
|
+ * JPEG, PNG and WebP are understood. Anything else (RAW, AVIF, video, a file
|
|
|
|
|
+ * the parser does not recognise) is left alone and uploaded as it arrived —
|
|
|
|
|
+ * best-effort by nature, exactly like the resolution cap. Every entry point
|
|
|
|
|
+ * verifies the result with getimagesize() before it is used, so a parse that
|
|
|
|
|
+ * goes wrong costs the strip, never the photo.
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+/** Metadata segments/chunks bigger than this are skipped rather than examined. */
|
|
|
|
|
+const EXIF_MAX_PARSE_BYTES = 1024 * 1024;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Write a metadata-free copy of $src to a temp file and return its path — the
|
|
|
|
|
+ * caller owns that file and must unlink it. Returns null when the format is not
|
|
|
|
|
+ * understood, when there was nothing to strip, or when the rewrite produced
|
|
|
|
|
+ * anything other than the same image at the same size; in every one of those
|
|
|
|
|
+ * cases the caller should simply use the original.
|
|
|
|
|
+ */
|
|
|
|
|
+function exif_strip_copy(string $src): ?string
|
|
|
|
|
+{
|
|
|
|
|
+ $in = @fopen($src, 'rb');
|
|
|
|
|
+ if ($in === false) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ $format = exif_detect_format((string)fread($in, 12));
|
|
|
|
|
+ if ($format === null) {
|
|
|
|
|
+ fclose($in);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ rewind($in);
|
|
|
|
|
+
|
|
|
|
|
+ $dest = @tempnam(sys_get_temp_dir(), 'fpexif');
|
|
|
|
|
+ $out = $dest !== false ? @fopen($dest, 'wb') : false;
|
|
|
|
|
+ if ($dest === false || $out === false) {
|
|
|
|
|
+ fclose($in);
|
|
|
|
|
+ if ($dest !== false) {
|
|
|
|
|
+ @unlink($dest);
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ $removed = match ($format) {
|
|
|
|
|
+ 'jpeg' => 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;
|
|
|
|
|
+}
|