| @@ -215,47 +215,9 @@ | ||
| 215 | 215 | if ( $this->filesystem->can_get_exif() && 'image/jpeg' === $this->get_mime_type() ) { |
| 216 | 216 | $exif = $this->filesystem->get_image_exif( $this->path ); |
| 217 | 217 | $orientation = isset( $exif['Orientation'] ) ? (int) $exif['Orientation'] : 1; |
| 218 | 218 | |
| 219 | - switch ( $orientation ) { | |
| 220 | - case 2: | |
| 221 | - // Flip horizontally. | |
| 222 | - $editor->flip( true, false ); | |
| 223 | - break; | |
| 224 | - case 3: | |
| 225 | - // Rotate 180 degrees or flip horizontally and vertically. | |
| 226 | - // Flipping seems faster/uses less resources. | |
| 227 | - $editor->flip( true, true ); | |
| 228 | - break; | |
| 229 | - case 4: | |
| 230 | - // Flip vertically. | |
| 231 | - $editor->flip( false, true ); | |
| 232 | - break; | |
| 233 | - case 5: | |
| 234 | - // Rotate 90 degrees counter-clockwise and flip vertically. | |
| 235 | - $result = $editor->rotate( 90 ); | |
| 236 | - | |
| 237 | - if ( ! is_wp_error( $result ) ) { | |
| 238 | - $editor->flip( false, true ); | |
| 239 | - } | |
| 240 | - break; | |
| 241 | - case 6: | |
| 242 | - // Rotate 90 degrees clockwise (270 counter-clockwise). | |
| 243 | - $editor->rotate( 270 ); | |
| 244 | - break; | |
| 245 | - case 7: | |
| 246 | - // Rotate 90 degrees counter-clockwise and flip horizontally. | |
| 247 | - $result = $editor->rotate( 90 ); | |
| 248 | - | |
| 249 | - if ( ! is_wp_error( $result ) ) { | |
| 250 | - $editor->flip( true, false ); | |
| 251 | - } | |
| 252 | - break; | |
| 253 | - case 8: | |
| 254 | - // Rotate 90 degrees counter-clockwise. | |
| 255 | - $editor->rotate( 90 ); | |
| 256 | - break; | |
| 257 | - } | |
| 219 | + $this->rotate_editor_by_exif_orientation( $editor, $orientation ); | |
| 258 | 220 | } |
| 259 | 221 | |
| 260 | 222 | if ( ! $dimensions ) { |
| 261 | 223 | $dimensions = $this->get_dimensions(); |
| @@ -285,8 +247,113 @@ | ||
| 285 | 247 | return $resized_image_path; |
| 286 | 248 | } |
| 287 | 249 | |
| 288 | 250 | /** |
| 251 | + * Rotate/flip an image editor instance according to a JPEG EXIF "Orientation" value. | |
| 252 | + * | |
| 253 | + * @since 2.3.1 | |
| 254 | + * | |
| 255 | + * @param \WP_Image_Editor_Imagick|\WP_Image_Editor_GD $editor The image editor instance. | |
| 256 | + * @param int $orientation The EXIF "Orientation" value. | |
| 257 | + * @return void | |
| 258 | + */ | |
| 259 | + protected function rotate_editor_by_exif_orientation( $editor, $orientation ) { | |
| 260 | + switch ( $orientation ) { | |
| 261 | + case 2: | |
| 262 | + // Flip horizontally. | |
| 263 | + $editor->flip( true, false ); | |
| 264 | + break; | |
| 265 | + case 3: | |
| 266 | + // Rotate 180 degrees or flip horizontally and vertically. | |
| 267 | + // Flipping seems faster/uses less resources. | |
| 268 | + $editor->flip( true, true ); | |
| 269 | + break; | |
| 270 | + case 4: | |
| 271 | + // Flip vertically. | |
| 272 | + $editor->flip( false, true ); | |
| 273 | + break; | |
| 274 | + case 5: | |
| 275 | + // Rotate 90 degrees counter-clockwise and flip vertically. | |
| 276 | + $result = $editor->rotate( 90 ); | |
| 277 | + | |
| 278 | + if ( ! is_wp_error( $result ) ) { | |
| 279 | + $editor->flip( false, true ); | |
| 280 | + } | |
| 281 | + break; | |
| 282 | + case 6: | |
| 283 | + // Rotate 90 degrees clockwise (270 counter-clockwise). | |
| 284 | + $editor->rotate( 270 ); | |
| 285 | + break; | |
| 286 | + case 7: | |
| 287 | + // Rotate 90 degrees counter-clockwise and flip horizontally. | |
| 288 | + $result = $editor->rotate( 90 ); | |
| 289 | + | |
| 290 | + if ( ! is_wp_error( $result ) ) { | |
| 291 | + $editor->flip( true, false ); | |
| 292 | + } | |
| 293 | + break; | |
| 294 | + case 8: | |
| 295 | + // Rotate 90 degrees counter-clockwise. | |
| 296 | + $editor->rotate( 90 ); | |
| 297 | + break; | |
| 298 | + } | |
| 299 | + } | |
| 300 | + | |
| 301 | + /** | |
| 302 | + * Correct the EXIF orientation of the current file, in place, if needed. | |
| 303 | + * | |
| 304 | + * WordPress auto-rotates JPEGs with a non-default EXIF orientation on upload (the resulting, | |
| 305 | + * rotated file has its orientation reset to 1), but Imagify's backup keeps a copy of the | |
| 306 | + * original, un-rotated file. When a temporary working copy is created from that backup (for | |
| 307 | + * example to generate a Next-Gen version of an already-optimized "full" size), the copy must | |
| 308 | + * be rotated the same way WordPress would have rotated it, otherwise the resulting file (WebP, | |
| 309 | + * AVIF...) ends up with the wrong orientation. | |
| 310 | + * | |
| 311 | + * This method must only ever be called on a disposable working copy: it saves the rotated | |
| 312 | + * image over $this->path, and must never be used on the actual backup file. | |
| 313 | + * | |
| 314 | + * @since 2.3.1 | |
| 315 | + * | |
| 316 | + * @return bool|WP_Error True if the file was rotated and saved. False if no rotation was | |
| 317 | + * needed (or EXIF data isn't available/readable). A WP_Error object on | |
| 318 | + * failure. | |
| 319 | + */ | |
| 320 | + public function maybe_correct_exif_orientation() { | |
| 321 | + if ( ! $this->filesystem->can_get_exif() || 'image/jpeg' !== $this->get_mime_type() ) { | |
| 322 | + return false; | |
| 323 | + } | |
| 324 | + | |
| 325 | + $exif = $this->filesystem->get_image_exif( $this->path ); | |
| 326 | + $orientation = isset( $exif['Orientation'] ) ? (int) $exif['Orientation'] : 1; | |
| 327 | + | |
| 328 | + if ( 1 === $orientation ) { | |
| 329 | + // Nothing to correct: either there is no orientation data, or the file is already | |
| 330 | + // upright (this also prevents rotating the same file twice). | |
| 331 | + return false; | |
| 332 | + } | |
| 333 | + | |
| 334 | + $editor = $this->get_editor(); | |
| 335 | + | |
| 336 | + if ( is_wp_error( $editor ) ) { | |
| 337 | + return $editor; | |
| 338 | + } | |
| 339 | + | |
| 340 | + $this->rotate_editor_by_exif_orientation( $editor, $orientation ); | |
| 341 | + | |
| 342 | + $saved = $editor->save( $this->path ); | |
| 343 | + | |
| 344 | + if ( is_wp_error( $saved ) ) { | |
| 345 | + return $saved; | |
| 346 | + } | |
| 347 | + | |
| 348 | + // The file on disk changed: reset the cached data related to it. | |
| 349 | + $this->file_type = null; | |
| 350 | + $this->editor = null; | |
| 351 | + | |
| 352 | + return true; | |
| 353 | + } | |
| 354 | + | |
| 355 | + /** | |
| 289 | 356 | * Create a thumbnail. |
| 290 | 357 | * Warning: If the destination file already exists, it will be overwritten. |
| 291 | 358 | * |
| 292 | 359 | * @since 1.9 |
| @@ -560,21 +627,58 @@ | ||
| 560 | 627 | if ( is_wp_error( $temp_file ) ) { |
| 561 | 628 | return new \WP_Error( 'temp_file_not_found', $temp_file->get_error_message() ); |
| 562 | 629 | } |
| 563 | 630 | |
| 564 | - if ( property_exists( $response, 'message' ) ) { | |
| 631 | + $formats = [ | |
| 632 | + 'webp', | |
| 633 | + 'avif', | |
| 634 | + ]; | |
| 635 | + $is_nextgen_request = in_array( $args['convert'], $formats, true ); | |
| 636 | + | |
| 637 | + if ( property_exists( $response, 'message' ) && ! $is_nextgen_request ) { | |
| 565 | 638 | $args['convert'] = ''; |
| 566 | 639 | } |
| 567 | 640 | |
| 568 | - $formats = [ | |
| 569 | - 'webp', | |
| 570 | - 'avif', | |
| 571 | - ]; | |
| 572 | - if ( in_array( $args['convert'], $formats, true ) ) { | |
| 641 | + if ( $is_nextgen_request && property_exists( $response, 'message' ) ) { | |
| 642 | + /* | |
| 643 | + * The API can return a `message` alongside the source's own bytes instead of a | |
| 644 | + * converted file (e.g. "Webp is less performant than original" or "already | |
| 645 | + * compressed"). In that case the downloaded file is NOT the requested next-gen | |
| 646 | + * format. Writing it to the next-gen path would create a corrupt file, and | |
| 647 | + * writing it to `$this->path` would overwrite the original thumbnail. Verify the | |
| 648 | + * actual bytes before doing either. | |
| 649 | + */ | |
| 650 | + if ( ! $this->is_file_format( $temp_file, $args['convert'] ) ) { | |
| 651 | + $this->filesystem->delete( $temp_file ); | |
| 652 | + | |
| 653 | + return new \WP_Error( | |
| 654 | + 'no_next_gen_returned', | |
| 655 | + $response->message | |
| 656 | + ); | |
| 657 | + } | |
| 658 | + } | |
| 659 | + | |
| 660 | + if ( $is_nextgen_request ) { | |
| 573 | 661 | $destination_path = $this->get_path_to_nextgen( $args['convert'] ); |
| 574 | - $this->path = $destination_path; | |
| 575 | - $this->file_type = null; | |
| 576 | - $this->editor = null; | |
| 662 | + | |
| 663 | + /* | |
| 664 | + * get_path_to_nextgen() returns false when the file is not an image, or when it | |
| 665 | + * already is in a next-gen format. Passing that along would hand an empty path to | |
| 666 | + * the filesystem, which throws an uncaught ValueError on PHP 8 and kills the | |
| 667 | + * background process mid-batch, leaving its lock behind and stalling the queue. | |
| 668 | + */ | |
| 669 | + if ( empty( $destination_path ) ) { | |
| 670 | + $this->filesystem->delete( $temp_file ); | |
| 671 | + | |
| 672 | + return new \WP_Error( | |
| 673 | + 'no_nextgen_destination', | |
| 674 | + __( 'Could not determine the destination path for the next-gen file.', 'imagify' ) | |
| 675 | + ); | |
| 676 | + } | |
| 677 | + | |
| 678 | + $this->path = $destination_path; | |
| 679 | + $this->file_type = null; | |
| 680 | + $this->editor = null; | |
| 577 | 681 | } else { |
| 578 | 682 | $destination_path = $this->path; |
| 579 | 683 | } |
| 580 | 684 | |
| @@ -844,8 +948,75 @@ | ||
| 844 | 948 | * @return bool |
| 845 | 949 | */ |
| 846 | 950 | public function is_avif() { |
| 847 | 951 | return preg_match( '@(?!^|/|\\\)\.avif$@i', $this->path ); |
| 952 | + } | |
| 953 | + | |
| 954 | + /** | |
| 955 | + * Tell if a file's actual content matches the given next-gen format, by reading its | |
| 956 | + * magic bytes. The file's extension can't be trusted here, since it may be a temp | |
| 957 | + * file downloaded with an unpredictable name (see download_url()). | |
| 958 | + * | |
| 959 | + * @since 2.3.2 | |
| 960 | + * | |
| 961 | + * @param string $file_path Absolute path to the file to check. | |
| 962 | + * @param string $format 'webp' or 'avif'. | |
| 963 | + * @return bool | |
| 964 | + */ | |
| 965 | + protected function is_file_format( $file_path, $format ) { | |
| 966 | + $contents = $this->filesystem->get_contents( $file_path ); | |
| 967 | + | |
| 968 | + if ( ! is_string( $contents ) || strlen( $contents ) < 12 ) { | |
| 969 | + return false; | |
| 970 | + } | |
| 971 | + | |
| 972 | + if ( 'webp' === $format ) { | |
| 973 | + // RIFF....WEBP. | |
| 974 | + return 'RIFF' === substr( $contents, 0, 4 ) && 'WEBP' === substr( $contents, 8, 4 ); | |
| 975 | + } | |
| 976 | + | |
| 977 | + if ( 'avif' === $format ) { | |
| 978 | + return $this->is_avif_ftyp_box( $contents ); | |
| 979 | + } | |
| 980 | + | |
| 981 | + return false; | |
| 982 | + } | |
| 983 | + | |
| 984 | + /** | |
| 985 | + * Tell if the given content starts with an AVIF `ftyp` box, i.e. its major brand or one of | |
| 986 | + * its compatible brands is `avif`/`avis`. A file can legitimately declare a major brand of | |
| 987 | + * `mif1`/`msf1` (generic HEIF-family brands) while listing `avif` only among the compatible | |
| 988 | + * brands, so both must be checked. | |
| 989 | + * | |
| 990 | + * @since 2.3.2 | |
| 991 | + * | |
| 992 | + * @param string $contents The file content (or at least its leading bytes). | |
| 993 | + * @return bool | |
| 994 | + */ | |
| 995 | + private function is_avif_ftyp_box( $contents ) { | |
| 996 | + if ( 'ftyp' !== substr( $contents, 4, 4 ) ) { | |
| 997 | + return false; | |
| 998 | + } | |
| 999 | + | |
| 1000 | + $avif_brands = [ 'avif', 'avis' ]; | |
| 1001 | + | |
| 1002 | + if ( in_array( substr( $contents, 8, 4 ), $avif_brands, true ) ) { | |
| 1003 | + // Major brand. | |
| 1004 | + return true; | |
| 1005 | + } | |
| 1006 | + | |
| 1007 | + // Box size (big-endian uint32), bounding how far the compatible brands list extends. | |
| 1008 | + $box_size = unpack( 'N', substr( $contents, 0, 4 ) )[1]; | |
| 1009 | + $box_size = min( $box_size, strlen( $contents ) ); | |
| 1010 | + | |
| 1011 | + // Compatible brands: 4-byte entries, starting right after the minor version, at offset 16. | |
| 1012 | + for ( $offset = 16; $offset + 4 <= $box_size; $offset += 4 ) { | |
| 1013 | + if ( in_array( substr( $contents, $offset, 4 ), $avif_brands, true ) ) { | |
| 1014 | + return true; | |
| 1015 | + } | |
| 1016 | + } | |
| 1017 | + | |
| 1018 | + return false; | |
| 848 | 1019 | } |
| 849 | 1020 | |
| 850 | 1021 | /** |
| 851 | 1022 | * Get the file mime type + file extension. |