$s['max_bytes'] ) { throw new \RuntimeException( 'source_byte_limit' ); } $i = wp_getimagesize( $path ); if ( ! $i || empty( $i[0] ) || empty( $i[1] ) ) { throw new \RuntimeException( 'invalid_image' ); } if ( $i[0] * $i[1] > $s['max_pixels'] || max( $i[0], $i[1] ) > 8192 ) { throw new \RuntimeException( 'pixel_limit' ); } // Covers two decoded frames, quality checks and bounded compressed buffers on Q16 Imagick/GD. if ( $i[0] * $i[1] * ( class_exists( '\Imagick' ) ? 80 : 40 ) + $size * 5 > Settings::memory_budget() ) { throw new \RuntimeException( 'memory_limit' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Read bounded validated local image bytes for format and pixel inspection; URL input is not accepted here. $bytes = file_get_contents( $path ); if ( ! is_string( $bytes ) || strlen( $bytes ) !== $size ) { throw new \RuntimeException( 'source_read_failed' ); } $out = self::analyze( $bytes, $i, $size, $animation ); if ( ! empty( $out['animated'] ) ) { // Every image is composed to the full canvas and decoded twice (the GIF's and the copy's), as Imagick does it. $pixels = $i[0] * $i[1] * $out['frames']; if ( $out['frames'] > self::ANIMATION_FRAMES || $pixels > 2 * $s['max_pixels'] ) { throw new \RuntimeException( 'animated_frames_limit' ); } if ( $pixels * 40 + $size * 5 > Settings::memory_budget() ) { throw new \RuntimeException( 'memory_limit' ); } } return $out; } /** * 1.9.0 (D-500 gap 4): the same reading of bytes already in memory (a source with its photo details removed), so the * engine judges what each encoder will really see. The limits of read() apply to the original file. * * @param string $bytes The picture. * @param bool $animation As for read(). * @return array * @throws \RuntimeException When the bytes are no picture this reader accepts. */ public static function from_bytes( string $bytes, bool $animation = false ): array { $i = getimagesizefromstring( $bytes ); if ( ! $i || empty( $i[0] ) || empty( $i[1] ) ) { throw new \RuntimeException( 'invalid_image' ); } return self::analyze( $bytes, $i, strlen( $bytes ), $animation ); } /** * Format, alpha and metadata of a picture's bytes. * * @param string $bytes The picture. * @param array $i Its getimagesize() answer. * @param int $size Its length in bytes. * @param bool $animation As for read(). * @return array * @throws \RuntimeException When the format or its structure is not accepted. */ private static function analyze( string $bytes, array $i, int $size, bool $animation = false ): array { $out = array( 'width' => (int) $i[0], 'height' => (int) $i[1], 'mime' => $i['mime'] ?? '', 'bytes' => $size, 'data' => $bytes, 'alpha' => false, 'metadata' => false, 'modern_safe' => true, 'imagick_safe' => true, 'display_notes' => false, ); if ( 'image/png' === $out['mime'] ) { $chunks = self::png_chunks( $bytes ); $h = $chunks[0]['data']; $color = ord( $h[9] ); if ( ! in_array( ord( $h[8] ), array( 1, 2, 4, 8 ), true ) || ! in_array( $color, array( 0, 2, 3, 4, 6 ), true ) || ( in_array( $color, array( 2, 4, 6 ), true ) && 8 !== ord( $h[8] ) ) ) { throw new \RuntimeException( 'unsupported_precision' ); } if ( ord( $h[10] ) !== 0 || ord( $h[11] ) !== 0 || ! in_array( ord( $h[12] ), array( 0, 1 ), true ) ) { throw new \RuntimeException( 'invalid_png' ); } $out['alpha'] = in_array( $color, array( 4, 6 ), true ); foreach ( $chunks as $c ) { $t = $c['type']; if ( ! in_array( $t, array( 'IHDR', 'PLTE', 'IDAT', 'IEND', 'tRNS' ), true ) ) { $out['modern_safe'] = false; $out['metadata'] = true; if ( 'iCCP' !== $t ) { $out['imagick_safe'] = false; } } if ( in_array( $t, array( 'acTL', 'fcTL', 'fdAT' ), true ) ) { throw new \RuntimeException( 'animated_image_held' ); } if ( 'tRNS' === $t ) { $out['alpha'] = true; } if ( in_array( $t, array( 'iCCP', 'eXIf', 'tEXt', 'zTXt', 'iTXt', 'gAMA', 'cHRM', 'cICP', 'mDCv', 'cLLi', 'sBIT', 'bKGD' ), true ) ) { $out['modern_safe'] = false; $out['metadata'] = true; } // Unmapped PNG properties are preserved by native repacking, never silently lost on conversion. if ( in_array( $t, array( 'eXIf', 'tEXt', 'zTXt', 'iTXt', 'gAMA', 'cHRM', 'cICP', 'mDCv', 'cLLi', 'sBIT', 'bKGD' ), true ) ) { $out['imagick_safe'] = false; } if ( 'IHDR' !== $t && ctype_upper( $t[0] ) && ! in_array( $t, array( 'PLTE', 'IDAT', 'IEND' ), true ) ) { throw new \RuntimeException( 'unsupported_png_chunk' ); } } } elseif ( 'image/jpeg' === $out['mime'] ) { if ( ( $i['bits'] ?? 8 ) !== 8 || ( $i['channels'] ?? 3 ) !== 3 ) { throw new \RuntimeException( 'unsupported_precision_or_cmyk' ); } foreach ( self::jpeg_segments( $bytes ) as $seg ) { // D-253 (1.7.5): a comment and the JFIF print resolution (units and density) hold nothing of the picture. // They are display notes: WebP and AVIF copies leave them out (RasterEncoder), the original keeps them. if ( 0xfe === $seg['marker'] ) { $out['display_notes'] = true; continue; } $jfif = 0xe0 === $seg['marker'] && 14 === strlen( $seg['data'] ) && "JFIF\0" === substr( $seg['data'], 0, 5 ) && "\0\0" === substr( $seg['data'], 12, 2 ); if ( $jfif && ( 0 !== ord( $seg['data'][7] ) || "\0\1\0\1" !== substr( $seg['data'], 8, 4 ) ) ) { $out['display_notes'] = true; continue; } // Embedded JFIF thumbnails and unknown APP0 data stay native. if ( 0xe0 === $seg['marker'] && ! $jfif ) { $out['metadata'] = true; $out['modern_safe'] = false; $out['imagick_safe'] = false; } if ( $seg['marker'] >= 0xe1 ) { $out['metadata'] = true; $out['modern_safe'] = false; } if ( $seg['marker'] >= 0xe3 ) { $out['imagick_safe'] = false; } if ( 0xe1 === $seg['marker'] && str_starts_with( $seg['data'], "Exif\0\0" ) ) { // Leave rotations to native coefficient-preserving optimization. No hidden dimension changes. $orientation = self::exif_orientation( substr( $seg['data'], 6 ) ); if ( ! in_array( $orientation, array( 0, 1 ), true ) ) { $out['imagick_safe'] = false; } } elseif ( 0xe1 === $seg['marker'] && ! str_starts_with( $seg['data'], "http://ns.adobe.com/xap/1.0/\0" ) ) { $out['imagick_safe'] = false; } } } elseif ( 'image/gif' === $out['mime'] ) { // 1.9.0 (D-500 gap 7): a still GIF. More than one image in the file is an animation, held as animated PNG // and WebP are; comments and application blocks other than the loop block are metadata, kept natively only. $gif = self::gif_blocks( $bytes ); $out['alpha'] = $gif['transparent']; $out['frames'] = $gif['frames']; if ( $gif['frames'] > 1 ) { if ( ! $animation ) { throw new \RuntimeException( 'animated_image_held' ); } // Step 2: an animated copy is made only of what it can show exactly as browsers show the GIF: an animation // that repeats for ever (browsers differ on counted repeats), each image shown at least 20 ms (browsers // show a GIF image timed at 10 ms or less for 100 ms). if ( 0 !== $gif['loop'] ) { throw new \RuntimeException( 'animated_loop_held' ); } if ( $gif['shortest'] < 2 ) { throw new \RuntimeException( 'animated_timing_held' ); } $out['animated'] = true; $out['modern_safe'] = false; } if ( $gif['metadata'] ) { $out['metadata'] = true; $out['modern_safe'] = false; $out['imagick_safe'] = false; } } elseif ( 'image/webp' === $out['mime'] ) { foreach ( self::webp_chunks( $bytes ) as $c ) { if ( in_array( $c['type'], array( 'ANIM', 'ANMF' ), true ) || ( 'VP8X' === $c['type'] && ( ord( $c['data'][0] ) & 2 ) ) ) { throw new \RuntimeException( 'animated_image_held' ); } if ( 'ALPH' === $c['type'] || ( 'VP8X' === $c['type'] && ( ord( $c['data'][0] ) & 16 ) ) || ( 'VP8L' === $c['type'] && strlen( $c['data'] ) >= 5 && ( ord( $c['data'][4] ) & 16 ) ) ) { $out['alpha'] = true; } if ( ! in_array( $c['type'], array( 'VP8X', 'VP8 ', 'VP8L', 'ALPH' ), true ) ) { $out['metadata'] = true; $out['modern_safe'] = false; $out['imagick_safe'] = false; } } } else { throw new \RuntimeException( 'unsupported_format' ); } return $out; } /** * A PNG's chunks in order, each checked for length and checksum. * * @param string $data The PNG. * @return array The chunks, with type and data. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function png_chunks( string $data ): array { if ( substr( $data, 0, 8 ) !== "\x89PNG\r\n\x1a\n" ) { throw new \RuntimeException( 'invalid_png' ); } $out = array(); $pos = 8; $idat = false; $ended_idat = false; $iend = false; while ( $pos + 12 <= strlen( $data ) && count( $out ) < 4096 ) { $len = unpack( 'N', substr( $data, $pos, 4 ) )[1]; $type = substr( $data, $pos + 4, 4 ); if ( $len > strlen( $data ) - $pos - 12 || ! preg_match( '/^[A-Za-z]{4}$/D', $type ) ) { throw new \RuntimeException( 'truncated_png' ); } $payload = substr( $data, $pos + 8, $len ); if ( hash( 'crc32b', $type . $payload, true ) !== substr( $data, $pos + 8 + $len, 4 ) ) { throw new \RuntimeException( 'invalid_png_crc' ); } if ( ! $out && ( 'IHDR' !== $type || 13 !== $len ) || ( $out && 'IHDR' === $type ) ) { throw new \RuntimeException( 'invalid_png' ); } if ( 'IDAT' === $type ) { if ( $ended_idat ) { throw new \RuntimeException( 'invalid_png' ); } $idat = true; } elseif ( $idat ) { $ended_idat = true; } $out[] = array( 'type' => $type, 'data' => $payload, ); $pos += 12 + $len; if ( 'IEND' === $type ) { $iend = true; if ( 0 !== $len ) { throw new \RuntimeException( 'invalid_png' ); } break; } } if ( ! $iend || ! $idat || $pos !== strlen( $data ) ) { throw new \RuntimeException( 'invalid_png' ); } return $out; } /** * One PNG chunk: its length, type, data and checksum. * * @param string $type The chunk type, such as IDAT. * @param string $data The chunk's data. * @return string The chunk's bytes. */ public static function png_chunk( string $type, string $data ): string { return pack( 'N', strlen( $data ) ) . $type . $data . hash( 'crc32b', $type . $data, true ); } /** * Includes metadata between progressive scans; byte-stuffed entropy markers are skipped. * * @param string $data The JPEG. * @return array The segments, with marker and data. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function jpeg_segments( string $data ): array { if ( substr( $data, 0, 2 ) !== "\xff\xd8" || substr( $data, -2 ) !== "\xff\xd9" ) { throw new \RuntimeException( 'truncated_jpeg' ); } $out = array(); $p = 2; $entropy = false; $end = strlen( $data ); $sos = false; while ( $p < $end && count( $out ) < 4096 ) { if ( $entropy ) { $p = strpos( $data, "\xff", $p ); if ( false === $p ) { break; } } $start = $p; if ( "\xff" !== $data[ $p++ ] ) { throw new \RuntimeException( 'truncated_jpeg' ); } while ( $p < $end && "\xff" === $data[ $p ] ) { ++$p; } if ( $p >= $end ) { break; } $marker = ord( $data[ $p++ ] ); if ( $entropy && ( 0 === $marker || ( $marker >= 0xd0 && $marker <= 0xd7 ) ) ) { continue; } if ( 0xd9 === $marker ) { if ( $p !== $end || ! $sos ) { throw new \RuntimeException( 'truncated_jpeg' ); } return $out; } $entropy = false; if ( $p + 2 > $end ) { break; } $len = unpack( 'n', substr( $data, $p, 2 ) )[1]; if ( $len < 2 || $p + $len > $end ) { throw new \RuntimeException( 'truncated_jpeg' ); } $out[] = array( 'marker' => $marker, 'data' => substr( $data, $p + 2, $len - 2 ), 'offset' => $start, 'length' => $p + $len - $start, ); $p += $len; if ( 0xda === $marker ) { $entropy = true; $sos = true; } } throw new \RuntimeException( 'truncated_jpeg' ); } /** * A JPEG's metadata segments (APPn and comments) as marker and hash, sorted, to compare two files' metadata. * * @param string $data The JPEG. * @return array Marker:SHA-256 strings. */ public static function jpeg_metadata( string $data ): array { $m = array(); foreach ( self::jpeg_segments( $data ) as $c ) { if ( $c['marker'] >= 0xe0 || 0xfe === $c['marker'] ) { $m[] = $c['marker'] . ':' . hash( 'sha256', $c['data'] ); } } sort( $m ); return $m; } /** * 1.9.0 (D-500 gap 7): a GIF's blocks: how many images it holds, whether one of them has a transparent colour, and * whether it carries comments, plain text or application data other than the loop block and ImageMagick's gamma note, * how often it repeats and the shortest time one of its images is shown. * * @param string $data The GIF. * @return array frames, transparent, metadata, applications (the identifiers of its application blocks, at most 8), * loop (the loop block's count, 0 for ever; null without one) and shortest (hundredths of a second). * @throws \RuntimeException When the GIF is truncated or malformed. */ public static function gif_blocks( string $data ): array { $len = strlen( $data ); if ( $len < 14 || ! in_array( substr( $data, 0, 6 ), array( 'GIF87a', 'GIF89a' ), true ) ) { throw new \RuntimeException( 'invalid_gif' ); } $out = array( 'frames' => 0, 'transparent' => false, 'metadata' => false, 'applications' => array(), 'loop' => null, 'shortest' => null, ); $delay = 0; $packed = ord( $data[10] ); $p = 13 + ( ( $packed & 0x80 ) ? 3 * ( 2 << ( $packed & 7 ) ) : 0 ); $skip = static function ( int $p ) use ( $data, $len ): int { while ( $p < $len ) { $n = ord( $data[ $p ] ); ++$p; if ( 0 === $n ) { return $p; } $p += $n; } throw new \RuntimeException( 'truncated_gif' ); }; $n = 0; while ( $p < $len && $n++ < 100000 ) { $b = ord( $data[ $p ] ); if ( 0x3b === $b ) { return $out; } if ( 0x21 === $b && $p + 2 < $len ) { $label = ord( $data[ $p + 1 ] ); if ( 0xf9 === $label && $p + 5 < $len ) { // The graphic control block before an image: its transparency flag and how long the image is shown. $out['transparent'] = $out['transparent'] || (bool) ( ord( $data[ $p + 3 ] ) & 1 ); $delay = unpack( 'v', substr( $data, $p + 4, 2 ) )[1]; } elseif ( 0xfe === $label || 0x01 === $label ) { $out['metadata'] = true; } elseif ( 0xff === $label ) { $id = substr( $data, $p + 3, 11 ); if ( count( $out['applications'] ) < 8 ) { $out['applications'][] = preg_replace( '/[^ -~]/', '?', $id ); } // ImageMagick (WordPress's Imagick editor makes every GIF size with it) writes its gamma setting as an // application block of its own, one sub-block "gamma=0.454545"; browsers read no such block (F-IM-29). $size = $p + 14 < $len ? ord( $data[ $p + 14 ] ) : 0; $gamma = 'ImageMagick' === $id && 11 === ord( $data[ $p + 2 ] ) && $p + 15 + $size < $len && 0 === ord( $data[ $p + 15 + $size ] ) && preg_match( '/^gamma=[0-9]+(?:\.[0-9]+)?(?:e[-+]?[0-9]+)?$/D', substr( $data, $p + 15, $size ) ); if ( ! in_array( $id, array( 'NETSCAPE2.0', 'ANIMEXTS1.0' ), true ) && ! $gamma ) { $out['metadata'] = true; } elseif ( ! $gamma && 11 === ord( $data[ $p + 2 ] ) && 3 === $size && $p + 17 < $len && 1 === ord( $data[ $p + 15 ] ) ) { $out['loop'] = unpack( 'v', substr( $data, $p + 16, 2 ) )[1]; } } $p = $skip( $p + 2 ); continue; } if ( 0x2c === $b && $p + 10 <= $len ) { ++$out['frames']; $out['shortest'] = null === $out['shortest'] ? $delay : min( $out['shortest'], $delay ); $delay = 0; $local = ord( $data[ $p + 9 ] ); $p += 10 + ( ( $local & 0x80 ) ? 3 * ( 2 << ( $local & 7 ) ) : 0 ); $p = $skip( $p + 1 ); continue; } throw new \RuntimeException( 'invalid_gif' ); } throw new \RuntimeException( 'truncated_gif' ); } /** * A WebP's chunks, checked: the RIFF size, every chunk inside the file, one image. * * @param string $data The WebP. * @param bool $animation 1.9.0 (D-500 gap 7 step 2): true to read an animation's chunks (an animated copy being * checked) instead of holding it. * @return array * @throws \RuntimeException When the WebP is malformed, or animated without $animation. */ public static function webp_chunks( string $data, bool $animation = false ): array { if ( strlen( $data ) < 21 || substr( $data, 0, 4 ) !== 'RIFF' || substr( $data, 8, 4 ) !== 'WEBP' || unpack( 'V', substr( $data, 4, 4 ) )[1] !== strlen( $data ) - 8 ) { throw new \RuntimeException( 'invalid_image' ); } $p = 12; $out = array(); $image = 0; $animated = false; while ( $p + 8 <= strlen( $data ) && count( $out ) < 1024 ) { $t = substr( $data, $p, 4 ); $n = unpack( 'V', substr( $data, $p + 4, 4 ) )[1]; if ( $n > strlen( $data ) - $p - 8 || ( 'VP8X' === $t && 10 !== $n ) ) { throw new \RuntimeException( 'invalid_image' ); } if ( in_array( $t, array( 'ANIM', 'ANMF' ), true ) || ( 'VP8X' === $t && ( ord( $data[ $p + 8 ] ) & 2 ) ) ) { $animated = true; } if ( in_array( $t, array( 'VP8 ', 'VP8L' ), true ) ) { ++$image; } $out[] = array( 'type' => $t, 'data' => substr( $data, $p + 8, $n ), ); $p += 8 + $n + ( $n % 2 ); } if ( $p !== strlen( $data ) ) { throw new \RuntimeException( 'invalid_image' ); } if ( $animated && $animation ) { return $out; } if ( $animated ) { throw new \RuntimeException( 'animated_image_held' ); } if ( $image !== 1 ) { throw new \RuntimeException( 'invalid_image' ); } return $out; } /** * A reader for the TIFF structure of EXIF data: its byte order and a function that reads numbers inside it. * * @param string $data The EXIF (TIFF) data. * @return array Whether it is little-endian, and the reader. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function tiff_reader( string $data ): array { $little = substr( $data, 0, 2 ) === 'II'; if ( strlen( $data ) < 8 || ! in_array( substr( $data, 0, 2 ), array( 'II', 'MM' ), true ) ) { throw new \RuntimeException( 'metadata_needs_review' ); } $read = static function ( int $at, int $n ) use ( $data, $little ): int { if ( $at < 0 || $at + $n > strlen( $data ) ) { throw new \RuntimeException( 'metadata_needs_review' ); } return unpack( 2 === $n ? ( $little ? 'v' : 'n' ) : ( $little ? 'V' : 'N' ), substr( $data, $at, $n ) )[1]; }; if ( 42 !== $read( 2, 2 ) ) { throw new \RuntimeException( 'metadata_needs_review' ); } return array( $little, $read ); } /** * The orientation an EXIF block gives. * * @param string $data The EXIF (TIFF) data. * @return int The orientation, 1 to 8, or 0 when it gives none. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function exif_orientation( string $data ): int { list( $little, $read ) = self::tiff_reader( $data ); $p = $read( 4, 4 ); if ( $p < 8 ) { throw new \RuntimeException( 'metadata_needs_review' ); } $n = $read( $p, 2 ); if ( $n > 512 || $p + 6 + $n * 12 > strlen( $data ) ) { throw new \RuntimeException( 'metadata_needs_review' ); } for ( $i = 0; $i < $n; ++$i ) { $at = $p + 2 + 12 * $i; if ( 0x0112 === $read( $at, 2 ) ) { if ( 3 !== $read( $at + 2, 2 ) || 1 !== $read( $at + 4, 4 ) ) { throw new \RuntimeException( 'metadata_needs_review' ); } return $read( $at + 8, 2 ); } } return 0; } /** * Remove the standard EXIF GPS IFD AND its out-of-line bytes. Preserve other tags and offsets. * * @param string $data The EXIF (TIFF) data. * @return string The EXIF data without the GPS block. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function scrub_gps( string $data ): string { list( $little, $read ) = self::tiff_reader( $data ); $seen = array(); $keep = array( array( 0, 8 ) ); $wipe = array(); $edits = array(); $walk = function ( int $p, bool $gps, int $depth ) use ( &$walk, &$seen, &$keep, &$wipe, &$edits, $read, $data ): void { if ( 0 === $p ) { return; } if ( $depth > 8 || isset( $seen[ $p ] ) || $p < 8 ) { throw new \RuntimeException( 'metadata_needs_review' ); } $seen[ $p ] = true; $n = $read( $p, 2 ); if ( $n > 512 || $p + 6 + 12 * $n > strlen( $data ) ) { throw new \RuntimeException( 'metadata_needs_review' ); } if ( $gps ) { $wipe[] = array( $p, 6 + 12 * $n ); } else { $keep[] = array( $p, 6 + 12 * $n ); } $entries = array(); $changed = false; for ( $i = 0; $i < $n; ++$i ) { $a = $p + 2 + $i * 12; $tag = $read( $a, 2 ); $type = $read( $a + 2, 2 ); $count = $read( $a + 4, 4 ); $lengths = array( 1 => 1, 2 => 1, 3 => 2, 4 => 4, 5 => 8, 6 => 1, 7 => 1, 8 => 2, 9 => 4, 10 => 8, 11 => 4, 12 => 8, 13 => 4, ); if ( ! isset( $lengths[ $type ] ) || $count > 1048576 ) { throw new \RuntimeException( 'metadata_needs_review' ); } $bytes = $lengths[ $type ] * $count; if ( $bytes > 4 ) { $start = $read( $a + 8, 4 ); if ( $start < 8 || $start + $bytes > strlen( $data ) ) { throw new \RuntimeException( 'metadata_needs_review' ); } if ( $gps ) { $wipe[] = array( $start, $bytes ); } else { $keep[] = array( $start, $bytes ); } } if ( in_array( $tag, array( 0x8825, 0x8769, 0xa005 ), true ) ) { if ( ! in_array( $type, array( 4, 13 ), true ) || 1 !== $count ) { throw new \RuntimeException( 'metadata_needs_review' ); } $walk( $read( $a + 8, 4 ), $gps || 0x8825 === $tag, $depth + 1 ); if ( 0x8825 === $tag && ! $gps ) { $changed = true; continue; } } if ( 0x014a === $tag ) { throw new \RuntimeException( 'metadata_needs_review' ); } // Unsupported SubIFD tree cannot certify privacy. $entries[] = substr( $data, $a, 12 ); } $next = $read( $p + 2 + $n * 12, 4 ); if ( $next ) { $walk( $next, $gps, $depth + 1 ); } if ( $changed ) { $edits[] = array( $p, $n, $entries, substr( $data, $p + 2 + $n * 12, 4 ) ); } }; $walk( $read( 4, 4 ), false, 0 ); foreach ( $wipe as $a ) { foreach ( $keep as $b ) { if ( max( $a[0], $b[0] ) < min( $a[0] + $a[1], $b[0] + $b[1] ) ) { throw new \RuntimeException( 'metadata_needs_review' ); } } } foreach ( $wipe as $a ) { $data = substr_replace( $data, str_repeat( "\0", $a[1] ), $a[0], $a[1] ); } foreach ( $edits as $e ) { $new = pack( $little ? 'v' : 'n', count( $e[2] ) ) . implode( '', $e[2] ) . $e[3]; $data = substr_replace( $data, str_pad( $new, 6 + $e[1] * 12, "\0" ), $e[0], 6 + $e[1] * 12 ); } return $data; } /** * A TIFF block holding only an orientation (big-endian, one IFD with one entry). * * @param int $orientation 2 to 8. * @return string */ private static function orientation_tiff( int $orientation ): string { return "MM\0*" . pack( 'N', 8 ) . pack( 'n', 1 ) . pack( 'nnN', 0x0112, 3, 1 ) . pack( 'n', $orientation ) . "\0\0" . pack( 'N', 0 ); } /** * 1.9.0 (D-500 gap 4): the picture without its photo details: no EXIF (camera, dates, GPS), XMP, IPTC or comments; * the colour profile, the Adobe colour marker and an orientation other than 1 are kept, so a copy looks the same. An * EXIF block whose orientation cannot be read safely needs review, as with Remove GPS. * * @param array $info The read picture. * @return string * @throws \RuntimeException When the format's details cannot be removed safely. */ private static function without_details( array $info ): string { $data = $info['data']; if ( 'image/jpeg' === $info['mime'] ) { $orientation = 1; $drop = array(); $first_app0 = null; foreach ( self::jpeg_segments( $data ) as $s ) { $m = $s['marker']; if ( 0xe0 === $m && null === $first_app0 ) { $first_app0 = $s; } if ( 0xe1 === $m && str_starts_with( $s['data'], "Exif\0\0" ) && 1 === $orientation ) { $orientation = max( 1, self::exif_orientation( substr( $s['data'], 6 ) ) ); } $icc = 0xe2 === $m && str_starts_with( $s['data'], "ICC_PROFILE\0" ); if ( 0xfe === $m || ( $m >= 0xe1 && $m <= 0xef && 0xee !== $m && ! $icc ) ) { $drop[] = $s; } } foreach ( array_reverse( $drop ) as $s ) { $data = substr_replace( $data, '', $s['offset'], $s['length'] ); } if ( $orientation > 1 ) { $payload = "Exif\0\0" . self::orientation_tiff( $orientation ); $at = $first_app0 && 2 === $first_app0['offset'] ? 2 + $first_app0['length'] : 2; $data = substr_replace( $data, "\xff\xe1" . pack( 'n', strlen( $payload ) + 2 ) . $payload, $at, 0 ); } return $data; } if ( 'image/png' === $info['mime'] ) { $out = substr( $data, 0, 8 ); foreach ( self::png_chunks( $data ) as $c ) { if ( in_array( $c['type'], array( 'tEXt', 'zTXt', 'iTXt', 'tIME' ), true ) ) { continue; } if ( 'eXIf' === $c['type'] ) { $orientation = max( 1, self::exif_orientation( $c['data'] ) ); if ( $orientation > 1 ) { $out .= self::png_chunk( 'eXIf', self::orientation_tiff( $orientation ) ); } continue; } $out .= self::png_chunk( $c['type'], $c['data'] ); } return $out; } if ( $info['metadata'] ) { throw new \RuntimeException( 'metadata_needs_review' ); } return $data; } /** * A picture's bytes with the metadata the recipe removes taken out: all photo details, or the GPS position only; * unchanged when the recipe keeps them. * * @param array $info The picture's input record (read()). * @param array $recipe The compression recipe. * @return string The picture's bytes. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function sanitized( array $info, array $recipe ): string { $data = $info['data']; if ( 'remove_all' === $recipe['metadata'] ) { return self::without_details( $info ); } if ( 'remove_gps' !== $recipe['metadata'] ) { return $data; } if ( 'image/jpeg' === $info['mime'] ) { $segments = self::jpeg_segments( $data ); foreach ( array_reverse( $segments ) as $s ) { if ( 0xe1 !== $s['marker'] ) { continue; } if ( ! str_starts_with( $s['data'], "Exif\0\0" ) ) { throw new \RuntimeException( 'gps_xmp_requires_review' ); } $payload = "Exif\0\0" . self::scrub_gps( substr( $s['data'], 6 ) ); $data = substr_replace( $data, "\xff\xe1" . pack( 'n', strlen( $payload ) + 2 ) . $payload, $s['offset'], $s['length'] ); } } elseif ( 'image/png' === $info['mime'] ) { $out = substr( $data, 0, 8 ); foreach ( self::png_chunks( $data ) as $c ) { if ( in_array( $c['type'], array( 'tEXt', 'zTXt', 'iTXt' ), true ) ) { throw new \RuntimeException( 'gps_xmp_requires_review' ); } $out .= self::png_chunk( $c['type'], 'eXIf' === $c['type'] ? self::scrub_gps( $c['data'] ) : $c['data'] ); } $data = $out; } elseif ( $info['metadata'] ) { throw new \RuntimeException( 'metadata_needs_review' ); } return $data; } }