PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / trunk
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
← All changes | classes/Optimization/File.php +219 -48 2.2.9 → trunk View file →
@@ -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.