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
imagify / classes / Optimization / File.php

File.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF trunk, at classes/Optimization/File.php

1,102 lines 29.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Imagify\Optimization;
3
4 use Imagify_Requirements;
5 use WP_Error;
6
7 /**
8 * A generic optimization class focused on the file itself.
9 *
10 * @since 1.9
11 * @author Grégory Viguier
12 */
13 class File {
14
15 /**
16 * Absolute path to the file.
17 *
18 * @var string
19 * @since 1.9
20 * @author Grégory Viguier
21 */
22 protected $path;
23
24 /**
25 * Tell if the file is an image.
26 *
27 * @var bool
28 * @since 1.9
29 * @see $this->is_image()
30 * @author Grégory Viguier
31 */
32 protected $is_image;
33
34 /**
35 * Store the file mime type + file extension (if the file is supported).
36 *
37 * @var array
38 * @since 1.9
39 * @see $this->get_file_type()
40 * @author Grégory Viguier
41 */
42 protected $file_type;
43
44 /**
45 * Filesystem object.
46 *
47 * @var \Imagify_Filesystem
48 * @since 1.9
49 * @author Grégory Viguier
50 */
51 protected $filesystem;
52
53 /**
54 * The editor instance used to resize the file.
55 *
56 * @var \WP_Image_Editor_Imagick|\WP_Image_Editor_GD|WP_Error.
57 * @since 1.9
58 * @author Grégory Viguier
59 */
60 protected $editor;
61
62 /**
63 * Used to cache the plugin’s options.
64 *
65 * @var array
66 * @since 1.9
67 * @author Grégory Viguier
68 */
69 protected $options = [];
70
71 /**
72 * The constructor.
73 *
74 * @since 1.9
75 * @author Grégory Viguier
76 *
77 * @param string $file_path Absolute path to the file.
78 */
79 public function __construct( $file_path ) {
80 $this->path = $file_path;
81 $this->filesystem = \Imagify_Filesystem::get_instance();
82 }
83
84 /**
85 * Tell if the file is valid.
86 *
87 * @since 1.9
88 * @author Grégory Viguier
89 *
90 * @return bool
91 */
92 public function is_valid() {
93 return (bool) $this->path;
94 }
95
96 /**
97 * Tell if the file can be processed.
98 *
99 * @since 1.9
100 * @author Grégory Viguier
101 *
102 * @return bool|WP_Error
103 */
104 public function can_be_processed() {
105 if ( ! $this->path ) {
106 return new \WP_Error( 'empty_path', __( 'File path is empty.', 'imagify' ) );
107 }
108
109 if ( ! empty( $this->filesystem->errors->errors ) ) {
110 return new \WP_Error( 'filesystem_error', __( 'Filesystem error.', 'imagify' ), $this->filesystem->errors );
111 }
112
113 if ( ! $this->filesystem->exists( $this->path ) ) {
114 return new \WP_Error(
115 'not_exists',
116 sprintf(
117 /* translators: %s is a file path. */
118 __( 'The file %s does not seem to exist.', 'imagify' ),
119 '<code>' . esc_html( $this->filesystem->make_path_relative( $this->path ) ) . '</code>'
120 )
121 );
122 }
123
124 if ( ! $this->filesystem->is_file( $this->path ) ) {
125 return new \WP_Error(
126 'not_a_file',
127 sprintf(
128 /* translators: %s is a file path. */
129 __( 'This does not seem to be a file: %s.', 'imagify' ),
130 '<code>' . esc_html( $this->filesystem->make_path_relative( $this->path ) ) . '</code>'
131 )
132 );
133 }
134
135 if ( ! $this->filesystem->is_writable( $this->path ) ) {
136 return new \WP_Error(
137 'not_writable',
138 sprintf(
139 /* translators: %s is a file path. */
140 __( 'The file %s does not seem to be writable.', 'imagify' ),
141 '<code>' . esc_html( $this->filesystem->make_path_relative( $this->path ) ) . '</code>'
142 )
143 );
144 }
145
146 $parent_folder = $this->filesystem->dir_path( $this->path );
147
148 if ( ! $this->filesystem->is_writable( $parent_folder ) ) {
149 return new \WP_Error(
150 'folder_not_writable',
151 sprintf(
152 /* translators: %s is a file path. */
153 __( 'The folder %s does not seem to be writable.', 'imagify' ),
154 '<code>' . esc_html( $this->filesystem->make_path_relative( $parent_folder ) ) . '</code>'
155 )
156 );
157 }
158
159 return true;
160 }
161
162
163 /** ----------------------------------------------------------------------------------------- */
164 /** EDITION ================================================================================= */
165 /** ----------------------------------------------------------------------------------------- */
166
167 /**
168 * Resize (and rotate) an image if it is bigger than the maximum width provided.
169 *
170 * @since 1.9
171 * @author Grégory Viguier
172 * @author Remy Perona
173 *
174 * @param array $dimensions {
175 * Array of image dimensions.
176 *
177 * @type int $width The image width.
178 * @type int $height The image height.
179 * }
180 * @param int $max_width Maximum width to resize to.
181 * @return string|WP_Error Path the the resized image. A WP_Error object on failure.
182 */
183 public function resize( $dimensions = [], $max_width = 0 ) {
184 $can_be_processed = $this->can_be_processed();
185
186 if ( is_wp_error( $can_be_processed ) ) {
187 return $can_be_processed;
188 }
189
190 if ( ! $max_width ) {
191 return new \WP_Error(
192 'no_resizing_threshold',
193 __( 'No threshold provided for resizing.', 'imagify' )
194 );
195 }
196
197 if ( ! $this->is_image() ) {
198 return new \WP_Error(
199 'not_an_image',
200 sprintf(
201 /* translators: %s is a file path. */
202 __( 'The file %s does not seem to be an image, and cannot be resized.', 'imagify' ),
203 '<code>' . esc_html( $this->filesystem->make_path_relative( $this->path ) ) . '</code>'
204 )
205 );
206 }
207
208 $editor = $this->get_editor();
209
210 if ( is_wp_error( $editor ) ) {
211 return $editor;
212 }
213
214 // Try to correct the auto-rotation if the info is available.
215 if ( $this->filesystem->can_get_exif() && 'image/jpeg' === $this->get_mime_type() ) {
216 $exif = $this->filesystem->get_image_exif( $this->path );
217 $orientation = isset( $exif['Orientation'] ) ? (int) $exif['Orientation'] : 1;
218
219 $this->rotate_editor_by_exif_orientation( $editor, $orientation );
220 }
221
222 if ( ! $dimensions ) {
223 $dimensions = $this->get_dimensions();
224 }
225
226 // Prevent removal of the exif data when resizing (only works with Imagick).
227 add_filter( 'image_strip_meta', '__return_false', 789 );
228
229 // Resize.
230 $new_sizes = wp_constrain_dimensions( $dimensions['width'], $dimensions['height'], $max_width );
231 $resized = $editor->resize( $new_sizes[0], $new_sizes[1], false );
232
233 // Remove the filter when we're done to prevent any conflict.
234 remove_filter( 'image_strip_meta', '__return_false', 789 );
235
236 if ( is_wp_error( $resized ) ) {
237 return $resized;
238 }
239
240 $resized_image_path = $editor->generate_filename( 'imagifyresized' );
241 $resized_image_saved = $editor->save( $resized_image_path );
242
243 if ( is_wp_error( $resized_image_saved ) ) {
244 return $resized_image_saved;
245 }
246
247 return $resized_image_path;
248 }
249
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 /**
356 * Create a thumbnail.
357 * Warning: If the destination file already exists, it will be overwritten.
358 *
359 * @since 1.9
360 * @author Grégory Viguier
361 *
362 * @param array $destination {
363 * The thumbnail data.
364 *
365 * @type string $path Path to the destination file.
366 * @type int $width The image width.
367 * @type int $height The image height.
368 * @type bool $crop True to crop, false to resize.
369 * @type bool $adjust_filename True to adjust the file name like what `$editor->multi_resize()` returns, like WP default behavior (default). False to prevent it, and use the file name from $path instead.
370 * }
371 * @return bool|array|WP_Error {
372 * A WP_Error object on error. True if the file exists.
373 * An array of thumbnail data if the file has just been created:
374 *
375 * @type string $file File name.
376 * @type int $width The image width.
377 * @type int $height The image height.
378 * @type string $mime-type The mime type.
379 * }
380 */
381 public function create_thumbnail( $destination ) {
382 $can_be_processed = $this->can_be_processed();
383
384 if ( is_wp_error( $can_be_processed ) ) {
385 return $can_be_processed;
386 }
387
388 if ( ! $this->is_image() ) {
389 return new WP_Error(
390 'not_an_image',
391 sprintf(
392 /* translators: %s is a file path. */
393 __( 'The file %s does not seem to be an image, and cannot be resized.', 'imagify' ),
394 '<code>' . esc_html( $this->filesystem->make_path_relative( $this->path ) ) . '</code>'
395 )
396 );
397 }
398
399 $editor = $this->get_editor();
400
401 if ( is_wp_error( $editor ) ) {
402 return $editor;
403 }
404
405 // Create the file.
406 $result = $editor->multi_resize( [ $destination ] );
407
408 if ( ! $result ) {
409 return new WP_Error( 'image_resize_error', __( 'The thumbnail could not be created.', 'imagify' ) );
410 }
411
412 $result = reset( $result );
413
414 $filename = $result['file'];
415 $source_thumb_path = $this->filesystem->dir_path( $this->path ) . $filename;
416
417 if ( ! isset( $destination['adjust_filename'] ) || $destination['adjust_filename'] ) {
418 // The file name can change from what we expected (1px wider, etc), let's use the resulting data to move the file to the right place.
419 $destination_thumb_path = $this->filesystem->dir_path( $destination['path'] ) . $filename;
420 } else {
421 // Respect what is set in $path.
422 $destination_thumb_path = $destination['path'];
423 $result['file'] = $this->filesystem->file_name( $destination['path'] );
424 }
425
426 if ( $source_thumb_path === $destination_thumb_path ) {
427 return $result;
428 }
429
430 $moved = $this->filesystem->move( $source_thumb_path, $destination_thumb_path, true );
431
432 if ( ! $moved ) {
433 return new WP_Error( 'move_error', __( 'The file could not be moved to its final destination.', 'imagify' ) );
434 }
435
436 return $result;
437 }
438
439 /**
440 * Backup a file.
441 *
442 * @since 1.9
443 * @since 1.9.8 Added $backup_source argument.
444 * @author Grégory Viguier
445 *
446 * @param string $backup_path The backup path.
447 * @param string $backup_source Path to the file to backup. This is useful in WP 5.3+ when we want to optimize the full size: in that case we need to backup the original file.
448 * @return bool|WP_Error True on success. False if the backup option is disabled. A WP_Error object on failure.
449 */
450 public function backup( $backup_path = null, $backup_source = null ) {
451 $can_be_processed = $this->can_be_processed();
452
453 if ( is_wp_error( $can_be_processed ) ) {
454 return $can_be_processed;
455 }
456
457 // Make sure the backups directory has no errors.
458 if ( ! $backup_path ) {
459 return new \WP_Error( 'wp_upload_error', __( 'Error while retrieving the backups directory path.', 'imagify' ) );
460 }
461
462 // Create sub-directories.
463 $created = $this->filesystem->make_dir( $this->filesystem->dir_path( $backup_path ) );
464
465 if ( ! $created ) {
466 return new \WP_Error( 'backup_dir_not_writable', __( 'The backup directory is not writable.', 'imagify' ) );
467 }
468
469 $path = $backup_source && $this->filesystem->exists( $backup_source ) ? $backup_source : $this->path;
470
471 /**
472 * Allow to overwrite the backup file if it already exists.
473 *
474 * @since 1.6.9
475 * @author Grégory Viguier
476 *
477 * @param bool $overwrite Whether to overwrite the backup file.
478 * @param string $path The file path.
479 * @param string $backup_path The backup path.
480 */
481 $overwrite = apply_filters( 'imagify_backup_overwrite_backup', false, $path, $backup_path );
482
483 // Copy the file.
484 $this->filesystem->copy( $path, $backup_path, $overwrite, FS_CHMOD_FILE );
485
486 // Make sure the backup copy exists.
487 if ( ! $this->filesystem->exists( $backup_path ) ) {
488 return new \WP_Error(
489 'backup_doesnt_exist',
490 __( 'The file could not be saved.', 'imagify' ),
491 [
492 'file_path' => $this->filesystem->make_path_relative( $path ),
493 'backup_path' => $this->filesystem->make_path_relative( $backup_path ),
494 ]
495 );
496 }
497
498 // Check if a '-scaled' version of the image exists.
499 $scaled_path = preg_replace( '/(\.)([^\.]+)$/', '-scaled.$2', $backup_source );
500 if ( $this->filesystem->exists( $scaled_path ) ) {
501 // Create a backup path for the scaled image.
502 $scaled_backup_path = preg_replace( '/(\.)([^\.]+)$/', '-scaled.$2', $backup_path );
503 // Copy the '-scaled' version to the backup.
504 $this->filesystem->copy( $scaled_path, $scaled_backup_path, $overwrite, FS_CHMOD_FILE );
505
506 if ( ! $this->filesystem->exists( $scaled_backup_path ) ) {
507 return new \WP_Error(
508 'backup_doesnt_exist',
509 __( 'The file could not be saved.', 'imagify' ),
510 [
511 'file_path' => $this->filesystem->make_path_relative( $scaled_path ),
512 'backup_path' => $this->filesystem->make_path_relative( $scaled_backup_path ),
513 ]
514 );
515 }
516 }
517
518 return true;
519 }
520
521 /**
522 * Optimize a file with Imagify.
523 *
524 * @since 1.9
525 * @author Grégory Viguier
526 *
527 * @param array $args {
528 * Optional. An array of arguments.
529 *
530 * @type bool $backup False to prevent backup. True to follow the user's setting. A backup can't be forced.
531 * @type string $backup_path If a backup must be done, this is the path to use. Default is the backup path used for the WP Media Library.
532 * @type int $optimization_level The optimization level (2=ultra, 1=aggressive, 0=normal).
533 * @type string $convert Set to 'webp' to convert the image to WebP, 'avif' to convert image to AVIF.
534 * @type string $context The context.
535 * @type int $original_size The file size, sent to the API.
536 * }
537 * @return \sdtClass|\WP_Error Optimized image data. A \WP_Error object on error.
538 */
539 public function optimize( $args = [] ) {
540 $args = array_merge(
541 [
542 'backup' => true,
543 'backup_path' => null,
544 'backup_source' => null,
545 'optimization_level' => 0,
546 'convert' => '',
547 'context' => 'wp',
548 'original_size' => 0,
549 ],
550 $args
551 );
552
553 $can_be_processed = $this->can_be_processed();
554
555 if ( is_wp_error( $can_be_processed ) ) {
556 return $can_be_processed;
557 }
558
559 // Check if external HTTP requests are blocked.
560 if ( Imagify_Requirements::is_imagify_blocked() ) {
561 return new \WP_Error( 'http_block_external', __( 'External HTTP requests are blocked.', 'imagify' ) );
562 }
563
564 /**
565 * Fires before a media file optimization.
566 *
567 * @since 1.9
568 * @author Grégory Viguier
569 *
570 * @param string $path Absolute path to the media file.
571 * @param array $args Arguments passed to the method.
572 */
573 do_action( 'imagify_before_optimize_file', $this->path, $args );
574
575 /**
576 * Fires before to optimize the Image with Imagify.
577 *
578 * @since 1.0
579 * @deprecated
580 *
581 * @param string $path Absolute path to the image file.
582 * @param bool $backup True if a backup will be make.
583 */
584 do_action_deprecated( 'before_do_imagify', [ $this->path, $args['backup'] ], '1.9', 'imagify_before_optimize_file' );
585
586 if ( $args['backup'] ) {
587 $backup_result = $this->backup( $args['backup_path'], $args['backup_source'] );
588
589 if ( is_wp_error( $backup_result ) ) {
590 // Stop the process if we can't backup the file.
591 return $backup_result;
592 }
593 }
594
595 // Send file for optimization and fetch the response.
596 $data = [
597 'normal' => 0 === $args['optimization_level'],
598 'aggressive' => 1 === $args['optimization_level'],
599 'ultra' => 2 === $args['optimization_level'],
600 'keep_exif' => true,
601 'original_size' => $args['original_size'],
602 'context' => $args['context'],
603 ];
604
605 if ( $args['convert'] ) {
606 $data['convert'] = $args['convert'];
607 $format = $args['convert'];
608 }
609
610 $response = upload_imagify_image(
611 [
612 'image' => $this->path,
613 'data' => wp_json_encode( $data ),
614 ]
615 );
616
617 if ( is_wp_error( $response ) ) {
618 return new \WP_Error( 'api_error', $response->get_error_message() );
619 }
620
621 if ( ! function_exists( 'download_url' ) ) {
622 require_once ABSPATH . 'wp-admin/includes/file.php';
623 }
624
625 $temp_file = download_url( $response->image );
626
627 if ( is_wp_error( $temp_file ) ) {
628 return new \WP_Error( 'temp_file_not_found', $temp_file->get_error_message() );
629 }
630
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 ) {
638 $args['convert'] = '';
639 }
640
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 ) {
661 $destination_path = $this->get_path_to_nextgen( $args['convert'] );
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;
681 } else {
682 $destination_path = $this->path;
683 }
684
685 $moved = $this->filesystem->move( $temp_file, $destination_path, true );
686
687 if ( ! $moved ) {
688 return new \WP_Error( 'move_error', __( 'The file could not be moved to its final destination.', 'imagify' ) );
689 }
690
691 /**
692 * Fires after to optimize the Image with Imagify.
693 *
694 * @since 1.0
695 * @deprecated
696 *
697 * @param string $path Absolute path to the image file.
698 * @param bool $backup True if a backup has been made.
699 */
700 do_action_deprecated( 'after_do_imagify', [ $this->path, $args['backup'] ], '1.9', 'imagify_before_optimize_file' );
701
702 /**
703 * Fires after a media file optimization.
704 *
705 * @since 1.9
706 * @author Grégory Viguier
707 *
708 * @param string $path Absolute path to the media file.
709 * @param array $args Arguments passed to the method.
710 */
711 do_action( 'imagify_after_optimize_file', $this->path, $args );
712
713 return $response;
714 }
715
716
717 /** ----------------------------------------------------------------------------------------- */
718 /** IMAGE EDITOR (GD/IMAGEMAGICK) =========================================================== */
719 /** ----------------------------------------------------------------------------------------- */
720
721 /**
722 * Get an image editor instance (WP_Image_Editor_Imagick, WP_Image_Editor_GD).
723 *
724 * @since 1.9
725 * @author Grégory Viguier
726 *
727 * @return WP_Image_Editor_Imagick|WP_Image_Editor_GD|WP_Error
728 */
729 protected function get_editor() {
730 if ( isset( $this->editor ) ) {
731 return $this->editor;
732 }
733
734 $this->editor = wp_get_image_editor(
735 $this->path,
736 [
737 'methods' => $this->get_editor_methods(),
738 ]
739 );
740
741 if ( ! is_wp_error( $this->editor ) ) {
742 return $this->editor;
743 }
744
745 $this->editor = new \WP_Error(
746 'image_editor',
747 sprintf(
748 /* translators: %1$s is an error message, %2$s is a "More info?" link. */
749 __( 'No php extensions are available to edit images on the server. ImageMagick or GD is required. The internal error is: %1$s. %2$s', 'imagify' ),
750 $this->editor->get_error_message(),
751 '<a href="' . esc_url( imagify_get_external_url( 'documentation-imagick-gd' ) ) . '" target="_blank">' . __( 'More info?', 'imagify' ) . '</a>'
752 )
753 );
754
755 return $this->editor;
756 }
757
758 /**
759 * Get the image editor methods we will use.
760 *
761 * @since 1.9
762 * @author Grégory Viguier
763 *
764 * @return array
765 */
766 protected function get_editor_methods() {
767 static $methods;
768
769 if ( isset( $methods ) ) {
770 return $methods;
771 }
772
773 $methods = [
774 'resize',
775 'multi_resize',
776 'generate_filename',
777 'save',
778 ];
779
780 if ( $this->filesystem->can_get_exif() ) {
781 $methods[] = 'rotate';
782 }
783
784 return $methods;
785 }
786
787
788 /** ----------------------------------------------------------------------------------------- */
789 /** VARIOUS TOOLS =========================================================================== */
790 /** ----------------------------------------------------------------------------------------- */
791
792 /**
793 * Check if a file exceeds the weight limit (> 5mo).
794 *
795 * @since 1.9
796 * @author Grégory Viguier
797 *
798 * @return bool
799 */
800 public function is_exceeded() {
801 if ( ! $this->is_valid() ) {
802 return false;
803 }
804
805 $size = $this->filesystem->size( $this->path );
806
807 return $size > IMAGIFY_MAX_BYTES;
808 }
809
810 /**
811 * Tell if the current file is supported for a given context.
812 *
813 * @since 1.9
814 * @see imagify_get_mime_types()
815 * @author Grégory Viguier
816 *
817 * @param array $allowed_mime_types A list of allowed mime types.
818 * @return bool
819 */
820 public function is_supported( $allowed_mime_types ) {
821 return in_array( $this->get_mime_type(), $allowed_mime_types, true );
822 }
823
824 /**
825 * Tell if the file is an image.
826 *
827 * @since 1.9
828 * @author Grégory Viguier
829 *
830 * @return bool
831 */
832 public function is_image() {
833 if ( isset( $this->is_image ) ) {
834 return $this->is_image;
835 }
836
837 $this->is_image = strpos( $this->get_mime_type(), 'image/' ) === 0;
838
839 return $this->is_image;
840 }
841
842 /**
843 * Tell if the file is a pdf.
844 *
845 * @since 1.9
846 * @author Grégory Viguier
847 *
848 * @return bool
849 */
850 public function is_pdf() {
851 return 'application/pdf' === $this->get_mime_type();
852 }
853
854 /**
855 * Get the file mime type.
856 *
857 * @since 1.9
858 * @author Grégory Viguier
859 *
860 * @return string
861 */
862 public function get_mime_type() {
863 return $this->get_file_type()->type;
864 }
865
866 /**
867 * Get the file extension.
868 *
869 * @since 1.9
870 *
871 * @return string|false
872 */
873 public function get_extension() {
874 return $this->get_file_type()->ext;
875 }
876
877 /**
878 * Get the file path.
879 *
880 * @since 1.9
881 * @author Grégory Viguier
882 *
883 * @return string
884 */
885 public function get_path() {
886 return $this->path;
887 }
888
889 /**
890 * Replace the file extension by WebP.
891 *
892 * @since 1.9
893 * @author Grégory Viguier
894 *
895 * @return string|bool The file path on success. False if not an image or on failure.
896 */
897 public function get_path_to_webp() {
898 if ( ! $this->is_image() ) {
899 return false;
900 }
901
902 if ( $this->is_webp() ) {
903 return false;
904 }
905
906 return imagify_path_to_webp( $this->path );
907 }
908
909 /**
910 * Replace the file extension by its next-gen format extension.
911 *
912 * @since 2.2
913 *
914 * @param string $format the format we are targeting.
915 * @return string|bool The file path on success. False if not an image or on failure.
916 */
917 public function get_path_to_nextgen( string $format ) {
918 if ( ! $this->is_image() ) {
919 return false;
920 }
921
922 if ( $this->is_webp() || $this->is_avif() ) {
923 return false;
924 }
925
926 return imagify_path_to_nextgen( $this->path, $format );
927 }
928
929 /**
930 * Tell if the file is a WebP image.
931 * Rejects "path/to/.webp" files.
932 *
933 * @since 1.9
934 * @author Grégory Viguier
935 *
936 * @return bool
937 */
938 public function is_webp() {
939 return preg_match( '@(?!^|/|\\\)\.webp$@i', $this->path );
940 }
941
942 /**
943 * Tell if the file is an AVIF image.
944 * Rejects "path/to/.avif" files.
945 *
946 * @since 2.2
947 *
948 * @return bool
949 */
950 public function is_avif() {
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;
1019 }
1020
1021 /**
1022 * Get the file mime type + file extension.
1023 *
1024 * @since 1.9
1025 * @see wp_check_filetype()
1026 * @author Grégory Viguier
1027 *
1028 * @return object {
1029 * @type string $ext The file extension.
1030 * @type string $type The mime type.
1031 * }
1032 */
1033 protected function get_file_type() {
1034 if ( isset( $this->file_type ) ) {
1035 return $this->file_type;
1036 }
1037
1038 $this->file_type = (object) [
1039 'ext' => '',
1040 'type' => '',
1041 ];
1042
1043 if ( ! $this->is_valid() ) {
1044 return $this->file_type;
1045 }
1046
1047 $this->file_type = (object) wp_check_filetype( $this->path );
1048
1049 return $this->file_type;
1050 }
1051
1052 /**
1053 * If the media is an image, get its width and height.
1054 *
1055 * @since 1.9
1056 * @author Grégory Viguier
1057 *
1058 * @return array
1059 */
1060 public function get_dimensions() {
1061 if ( ! $this->is_image() ) {
1062 return [
1063 'width' => 0,
1064 'height' => 0,
1065 ];
1066 }
1067
1068 $values = $this->filesystem->get_image_size( $this->path );
1069
1070 if ( empty( $values ) ) {
1071 return [
1072 'width' => 0,
1073 'height' => 0,
1074 ];
1075 }
1076
1077 return [
1078 'width' => $values['width'],
1079 'height' => $values['height'],
1080 ];
1081 }
1082
1083 /**
1084 * Get a plugin’s option.
1085 *
1086 * @since 1.9
1087 * @author Grégory Viguier
1088 *
1089 * @param string $option_name The option nme.
1090 * @return mixed
1091 */
1092 protected function get_option( $option_name ) {
1093 if ( isset( $this->options[ $option_name ] ) ) {
1094 return $this->options[ $option_name ];
1095 }
1096
1097 $this->options[ $option_name ] = get_imagify_option( $option_name );
1098
1099 return $this->options[ $option_name ];
1100 }
1101 }
1102