PluginProbe ʕ •ᴥ•ʔ
Modern Image Formats / trunk
Modern Image Formats vtrunk
2.7.0 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.1.0 1.1.1 2.0.0 2.0.1 2.0.2 2.1.0 2.2.0 2.3.0 2.4.0 2.5.0 2.5.1 2.6.0 2.6.1
webp-uploads / helper.php
webp-uploads Last commit date
deprecated.php 19 hours ago helper.php 19 hours ago hooks.php 19 hours ago image-edit.php 19 hours ago load.php 19 hours ago picture-element.php 19 hours ago readme.txt 19 hours ago rest-api.php 19 hours ago settings.php 19 hours ago uninstall.php 19 hours ago
helper.php
609 lines
1 <?php
2 /**
3 * Helper functions used for Modern Image Formats.
4 *
5 * @package webp-uploads
6 *
7 * @since 1.0.0
8 */
9
10 declare( strict_types = 1 );
11
12 // @codeCoverageIgnoreStart
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit; // Exit if accessed directly.
15 }
16 // @codeCoverageIgnoreEnd
17
18 /**
19 * Returns an array with the list of valid mime types that a specific mime type can be converted into it,
20 * for example an image/jpeg can be converted into an image/webp.
21 *
22 * @since 1.0.0
23 * @since 2.0.0 Added support for AVIF.
24 * @since 2.2.0 Added support for PNG.
25 *
26 * @return array<string, array<string>> An array of valid mime types, where the key is the mime type and the value is the extension type.
27 */
28 function webp_uploads_get_upload_image_mime_transforms(): array {
29
30 // Check the selected output format.
31 $output_format = webp_uploads_mime_type_supported( 'image/avif' ) ? webp_uploads_get_image_output_format() : 'webp';
32
33 $default_transforms = array(
34 'image/jpeg' => array( 'image/' . $output_format ),
35 'image/webp' => array( 'image/' . $output_format ),
36 'image/avif' => array( 'image/avif' ),
37 'image/png' => array( 'image/' . $output_format ),
38 );
39
40 // Check setting for whether to generate both JPEG and the modern output format.
41 if ( webp_uploads_is_fallback_enabled() ) {
42 $default_transforms = array(
43 'image/jpeg' => array( 'image/jpeg', 'image/' . $output_format ),
44 'image/png' => array( 'image/png', 'image/' . $output_format ),
45 'image/' . $output_format => array( 'image/' . $output_format, 'image/jpeg' ),
46 );
47 }
48
49 /**
50 * Filter to allow the definition of a custom mime types, in which a defined mime type
51 * can be transformed and provide a wide range of mime types.
52 *
53 * The order of supported mime types matters. If the original mime type of the uploaded image
54 * is not needed, then the first mime type in the list supported by the image editor will be
55 * selected for the default subsizes.
56 *
57 * @since 1.0.0
58 *
59 * @param array $default_transforms A map with the valid mime transforms.
60 */
61 $transforms = apply_filters( 'webp_uploads_upload_image_mime_transforms', $default_transforms );
62
63 // Return the default mime transforms if a non-array result is returned from the filter.
64 if ( ! is_array( $transforms ) ) {
65 return $default_transforms;
66 }
67
68 // Ensure that all mime types have correct transforms. If a mime type has invalid transforms array,
69 // then fallback to the original mime type to make sure that the correct subsizes are created.
70 foreach ( $transforms as $mime_type => $transform_types ) {
71 if ( ! is_array( $transform_types ) || 0 === count( $transform_types ) ) {
72 $transforms[ $mime_type ] = array( $mime_type );
73 }
74 }
75
76 return $transforms;
77 }
78
79 /**
80 * Creates a resized image with the provided dimensions out of an original attachment, the created image
81 * would be saved in the specified mime and stored in the destination file. If the image can't be saved correctly
82 * a WP_Error would be returned otherwise an array with the file and filesize properties.
83 *
84 * @since 1.0.0
85 * @access private
86 *
87 * @param int $attachment_id The ID of the attachment from where this image would be created.
88 * @param string $image_size The size name that would be used to create the image source, out of the registered subsizes.
89 * @param array{ width: int, height: int, crop: bool|array{string, string}} $size_data An array with the dimensions of the image: height, width and crop.
90 * @param string $mime The target mime in which the image should be created.
91 * @param string|null $destination_file_name The path where the file would be stored, including the extension. If null, `generate_filename` is used to create the destination file name.
92 *
93 * @return array{ file: string, filesize: int }|WP_Error An array with the file and filesize if the image was created correctly, otherwise a WP_Error.
94 */
95 function webp_uploads_generate_additional_image_source( int $attachment_id, string $image_size, array $size_data, string $mime, ?string $destination_file_name = null ) {
96 /**
97 * Filter to allow the generation of additional image sources, in which a defined mime type
98 * can be transformed and create additional mime types for the file.
99 *
100 * Returning an image data array or WP_Error here effectively short-circuits the default logic to generate the image source.
101 *
102 * @since 1.1.0
103 *
104 * @param array{
105 * file: string,
106 * path?: string,
107 * filesize?: int
108 * }|null|WP_Error|mixed $image Image data, null, or WP_Error.
109 * @param int $attachment_id The ID of the attachment from where this image would be created.
110 * @param string $image_size The size name that would be used to create this image, out of the registered subsizes.
111 * @param array{
112 * width: int,
113 * height: int,
114 * crop: bool|array{string, string}
115 * } $size_data An array with the dimensions of the image.
116 * @param string $mime The target mime in which the image should be created.
117 */
118 $image = apply_filters( 'webp_uploads_pre_generate_additional_image_source', null, $attachment_id, $image_size, $size_data, $mime );
119 if ( is_wp_error( $image ) ) {
120 return $image;
121 }
122 if ( is_array( $image ) && array_key_exists( 'file', $image ) && is_string( $image['file'] ) ) {
123 // The filtered image provided all we need to short-circuit here.
124 if ( array_key_exists( 'filesize', $image ) && is_int( $image['filesize'] ) && $image['filesize'] > 0 ) {
125 return array(
126 'file' => $image['file'],
127 'filesize' => $image['filesize'],
128 );
129 }
130
131 // Supply the filesize based on the filter-provided path.
132 if ( array_key_exists( 'path', $image ) && is_string( $image['path'] ) ) {
133 $filesize = wp_filesize( $image['path'] );
134 if ( $filesize > 0 ) {
135 return array(
136 'file' => $image['file'],
137 'filesize' => $filesize,
138 );
139 }
140 }
141 }
142
143 $allowed_mimes = array_flip( wp_get_mime_types() );
144 if ( ! isset( $allowed_mimes[ $mime ] ) || ! is_string( $allowed_mimes[ $mime ] ) ) {
145 return new WP_Error( 'image_mime_type_invalid', __( 'The provided mime type is not allowed.', 'webp-uploads' ) );
146 }
147
148 if ( ! wp_image_editor_supports( array( 'mime_type' => $mime ) ) ) {
149 return new WP_Error( 'image_mime_type_not_supported', __( 'The provided mime type is not supported.', 'webp-uploads' ) );
150 }
151
152 $image_path = wp_get_original_image_path( $attachment_id );
153 if ( false === $image_path || ! file_exists( $image_path ) ) {
154 return new WP_Error( 'original_image_file_not_found', __( 'The original image file does not exists, subsizes are created out of the original image.', 'webp-uploads' ) );
155 }
156
157 $editor = wp_get_image_editor( $image_path, array( 'mime_type' => $mime ) );
158 if ( is_wp_error( $editor ) ) {
159 return $editor;
160 }
161
162 $height = (int) ( $size_data['height'] ?? 0 );
163 $width = (int) ( $size_data['width'] ?? 0 );
164 $crop = $size_data['crop'] ?? false;
165 if ( $width <= 0 && $height <= 0 ) {
166 return new WP_Error( 'image_wrong_dimensions', __( 'At least one of the dimensions must be a positive number.', 'webp-uploads' ) );
167 }
168
169 $image_meta = wp_get_attachment_metadata( $attachment_id );
170 // If stored EXIF data exists, rotate the source image before creating sub-sizes.
171 if ( isset( $image_meta['image_meta'] ) && is_array( $image_meta['image_meta'] ) && count( $image_meta['image_meta'] ) > 0 ) {
172 $editor->maybe_exif_rotate();
173 }
174
175 $editor->resize( $width, $height, $crop );
176
177 if ( null === $destination_file_name ) {
178 $ext = pathinfo( $image_path, PATHINFO_EXTENSION );
179 $suffix = $editor->get_suffix();
180 $suffix .= "-{$ext}";
181 $extension = explode( '|', $allowed_mimes[ $mime ] );
182 $destination_file_name = $editor->generate_filename( $suffix, null, $extension[0] );
183 }
184
185 remove_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10 );
186 $image = $editor->save( $destination_file_name, $mime );
187 add_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10, 3 );
188
189 if ( is_wp_error( $image ) ) {
190 return $image;
191 }
192
193 if ( ! isset( $image['file'] ) || ! is_string( $image['file'] ) || '' === $image['file'] ) {
194 return new WP_Error( 'image_file_not_present', __( 'The file key is not present on the image data', 'webp-uploads' ) );
195 }
196
197 return array(
198 'file' => $image['file'],
199 'filesize' => isset( $image['path'] ) ? wp_filesize( $image['path'] ) : 0,
200 );
201 }
202
203 /**
204 * Creates a new image based of the specified attachment with a defined mime type
205 * this image would be stored in the same place as the provided size name inside the
206 * metadata of the attachment.
207 *
208 * @since 1.0.0
209 *
210 * @see wp_create_image_subsizes()
211 *
212 * @param int $attachment_id The ID of the attachment we are going to use as a reference to create the image.
213 * @param string $size The size name that would be used to create this image, out of the registered subsizes.
214 * @param string $mime A mime type we are looking to use to create this image.
215 *
216 * @return array{ file: string, filesize: int }|WP_Error
217 */
218 function webp_uploads_generate_image_size( int $attachment_id, string $size, string $mime ) {
219 $sizes = wp_get_registered_image_subsizes();
220 $metadata = wp_get_attachment_metadata( $attachment_id );
221
222 if (
223 ! isset( $metadata['sizes'][ $size ], $sizes[ $size ] )
224 || ! is_array( $metadata['sizes'][ $size ] )
225 || ! is_array( $sizes[ $size ] )
226 ) {
227 return new WP_Error( 'image_mime_type_invalid_metadata', __( 'The image does not have a valid metadata.', 'webp-uploads' ) );
228 }
229
230 $size_data = array(
231 'width' => 0,
232 'height' => 0,
233 'crop' => false,
234 );
235
236 if ( isset( $sizes[ $size ]['width'] ) ) {
237 $size_data['width'] = $sizes[ $size ]['width'];
238 } elseif ( isset( $metadata['sizes'][ $size ]['width'] ) ) {
239 $size_data['width'] = $metadata['sizes'][ $size ]['width'];
240 }
241
242 if ( isset( $sizes[ $size ]['height'] ) ) {
243 $size_data['height'] = $sizes[ $size ]['height'];
244 } elseif ( isset( $metadata['sizes'][ $size ]['height'] ) ) {
245 $size_data['height'] = $metadata['sizes'][ $size ]['height'];
246 }
247
248 if ( isset( $sizes[ $size ]['crop'] ) ) {
249 $size_data['crop'] = $sizes[ $size ]['crop'];
250 }
251
252 return webp_uploads_generate_additional_image_source( $attachment_id, $size, $size_data, $mime );
253 }
254
255 /**
256 * Returns mime types that should be used for an image in the specific context.
257 *
258 * @since 1.0.0
259 *
260 * @param int $attachment_id The attachment ID.
261 * @param string $context The current context.
262 * @return string[] Mime types to use for the image.
263 */
264 function webp_uploads_get_content_image_mimes( int $attachment_id, string $context ): array {
265 $target_mimes = array( 'image/' . webp_uploads_get_image_output_format(), 'image/jpeg' );
266
267 /**
268 * Filters mime types that should be used to update all images in the content. The order of
269 * mime types matters. The first mime type in the list will be used if it is supported by an image.
270 *
271 * @since 1.0.0
272 *
273 * @param array $target_mimes The list of mime types that can be used to update images in the content.
274 * @param int $attachment_id The attachment ID.
275 * @param string $context The current context.
276 */
277 $target_mimes = apply_filters( 'webp_uploads_content_image_mimes', $target_mimes, $attachment_id, $context );
278 if ( ! is_array( $target_mimes ) ) {
279 $target_mimes = array();
280 }
281
282 return $target_mimes;
283 }
284
285 /**
286 * Verifies if the request is for a frontend context within the <body> tag.
287 *
288 * @since 1.0.0
289 *
290 * @global WP_Query $wp_query WordPress Query object.
291 *
292 * @return bool True if in the <body> within a frontend request, false otherwise.
293 */
294 function webp_uploads_in_frontend_body(): bool {
295 global $wp_query;
296
297 // Check if this request is generally outside (or before) any frontend context.
298 if ( ! isset( $wp_query ) || defined( 'REST_REQUEST' ) || defined( 'XMLRPC_REQUEST' ) || is_feed() ) {
299 return false;
300 }
301
302 // Check if we're anywhere before 'template_redirect' or within the 'wp_head' action.
303 if ( 0 === did_action( 'template_redirect' ) || doing_action( 'wp_head' ) ) {
304 return false;
305 }
306
307 return true;
308 }
309
310 /**
311 * Check whether the additional image is larger than the original image.
312 *
313 * @since 1.0.0
314 *
315 * @param array<string, mixed> $original An array with the metadata of the attachment.
316 * @param array<string, mixed> $additional An array containing the filename and file size for additional mime.
317 * @phpstan-param array{ filesize?: int, ... } $original
318 * @phpstan-param array{ filesize?: int, ... } $additional
319 * @return bool True if the additional image is larger than the original image, otherwise false.
320 */
321 function webp_uploads_should_discard_additional_image_file( array $original, array $additional ): bool {
322 $original_image_filesize = isset( $original['filesize'] ) ? (int) $original['filesize'] : 0;
323 $additional_image_filesize = isset( $additional['filesize'] ) ? (int) $additional['filesize'] : 0;
324 if ( $original_image_filesize > 0 && $additional_image_filesize > 0 ) {
325 /**
326 * Filter whether WebP images that are larger than the matching JPEG should be discarded.
327 *
328 * By default the performance lab plugin will use the mime type with the smaller filesize
329 * rather than defaulting to `webp`.
330 *
331 * @since 1.0.0
332 *
333 * @param bool $preferred_filesize Prioritize file size over mime type. Default true.
334 */
335 $webp_discard_larger_images = apply_filters( 'webp_uploads_discard_larger_generated_images', true );
336
337 if ( $webp_discard_larger_images && $additional_image_filesize >= $original_image_filesize ) {
338 return true;
339 }
340 }
341 return false;
342 }
343
344 /**
345 * Checks if a mime type is supported by the server.
346 *
347 * Includes special handling for false positives on AVIF support.
348 *
349 * @since 2.0.0
350 * @since 2.7.0 AVIF is now only reported as supported when the server can also encode transparent AVIF images.
351 *
352 * @param string $mime_type The mime type to check.
353 * @return bool Whether the server supports a given mime type.
354 */
355 function webp_uploads_mime_type_supported( string $mime_type ): bool {
356 if ( ! wp_image_editor_supports( array( 'mime_type' => $mime_type ) ) ) {
357 return false;
358 }
359
360 // In certain server environments Image editors can report a false positive for AVIF support.
361 if ( 'image/avif' === $mime_type ) {
362 return webp_uploads_avif_transparency_supported();
363 }
364
365 return true;
366 }
367
368 /**
369 * Checks if the server supports AVIF image format.
370 *
371 * @since 2.7.0
372 *
373 * @return bool True if the server supports AVIF, false otherwise.
374 */
375 function webp_uploads_avif_supported(): bool {
376 $editor = _wp_image_editor_choose( array( 'mime_type' => 'image/avif' ) );
377 if ( false === $editor ) {
378 return false;
379 }
380 if ( is_a( $editor, WP_Image_Editor_GD::class, true ) ) {
381 return function_exists( 'imageavif' );
382 }
383 return webp_uploads_imagick_avif_supported( $editor );
384 }
385
386 /**
387 * Checks if the server supports transparent AVIF images.
388 *
389 * @since 2.7.0
390 *
391 * @return bool True if the server supports transparent AVIF images, false otherwise.
392 */
393 function webp_uploads_avif_transparency_supported(): bool {
394 if ( ! webp_uploads_avif_supported() ) {
395 return false;
396 }
397 // AVIF is supported at this point. GD's imageavif() preserves alpha, so for GD (and any non-Imagick editor) AVIF support implies transparency support. The transparency regression is specific to older ImageMagick.
398 $editor = _wp_image_editor_choose( array( 'mime_type' => 'image/avif' ) );
399 if ( is_string( $editor ) && is_a( $editor, WP_Image_Editor_Imagick::class, true ) ) {
400 return webp_uploads_imagick_avif_transparency_supported();
401 }
402 return true;
403 }
404
405 /**
406 * Checks if Imagick supports AVIF format on the server.
407 *
408 * @since 2.7.0
409 *
410 * @param string $editor The image editor class name.
411 * @return bool True if Imagick supports AVIF, false otherwise.
412 */
413 function webp_uploads_imagick_avif_supported( string $editor ): bool {
414 if ( is_a( $editor, WP_Image_Editor_Imagick::class, true ) && class_exists( 'Imagick' ) ) {
415 return 0 !== count( Imagick::queryFormats( 'AVIF' ) );
416 }
417 return false;
418 }
419
420 /**
421 * Get the image output format setting from the option. Default is avif.
422 *
423 * @since 2.0.0
424 *
425 * @return string The image output format. One of 'webp' or 'avif'.
426 */
427 function webp_uploads_get_image_output_format(): string {
428 $image_format = get_option( 'perflab_modern_image_format' );
429 return webp_uploads_sanitize_image_format( $image_format );
430 }
431
432 /**
433 * Sanitizes the image format.
434 *
435 * @since 2.0.0
436 *
437 * @param string|mixed $image_format The image format to check.
438 * @return string Supported image format.
439 */
440 function webp_uploads_sanitize_image_format( $image_format ): string {
441 return in_array( $image_format, array( 'webp', 'avif' ), true ) ? $image_format : 'webp';
442 }
443
444 /**
445 * Checks if the `webp_uploads_use_picture_element` option is enabled.
446 *
447 * @since 2.0.0
448 *
449 * @return bool True if the option is enabled, false otherwise.
450 */
451 function webp_uploads_is_picture_element_enabled(): bool {
452 return webp_uploads_is_fallback_enabled() && (bool) get_option( 'webp_uploads_use_picture_element', false );
453 }
454
455 /**
456 * Checks if the `perflab_generate_webp_and_jpeg` option is enabled.
457 *
458 * @since 2.0.0
459 * @since 2.2.0 Renamed to webp_uploads_is_fallback_enabled().
460 *
461 * @return bool True if the option is enabled, false otherwise.
462 */
463 function webp_uploads_is_fallback_enabled(): bool {
464 return (bool) get_option( 'perflab_generate_webp_and_jpeg' );
465 }
466
467 /**
468 * Checks if the `perflab_generate_all_fallback_sizes` option is enabled.
469 *
470 * @since 2.4.0
471 *
472 * @return bool Whether the option is enabled. Default is false.
473 */
474 function webp_uploads_should_generate_all_fallback_sizes(): bool {
475 return (bool) get_option( 'perflab_generate_all_fallback_sizes', 0 );
476 }
477
478 /**
479 * Retrieves the image URL for a specified MIME type from the attachment metadata.
480 *
481 * This function attempts to locate an alternate image source URL in the
482 * attachment's metadata that matches the provided MIME type.
483 *
484 * @since 2.2.0
485 *
486 * @param int $attachment_id The ID of the attachment.
487 * @param string $src The original image src url.
488 * @param string $mime A mime type we are looking to get image url.
489 * @return non-empty-string|null Returns mime type image if available.
490 */
491 function webp_uploads_get_mime_type_image( int $attachment_id, string $src, string $mime ): ?string {
492 $metadata = wp_get_attachment_metadata( $attachment_id );
493 $src_basename = wp_basename( $src );
494 if ( isset( $metadata['sources'][ $mime ]['file'] ) ) {
495 $basename = wp_basename( $metadata['file'] );
496
497 if ( $src_basename === $basename ) {
498 $result = str_replace(
499 $basename,
500 $metadata['sources'][ $mime ]['file'],
501 $src
502 );
503 return '' === $result ? null : $result;
504 }
505 }
506
507 if ( isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
508 foreach ( $metadata['sizes'] as $size => $size_data ) {
509
510 if ( ! isset( $size_data['file'] ) ) {
511 continue;
512 }
513
514 if ( ! isset( $size_data['sources'][ $mime ]['file'] ) ) {
515 continue;
516 }
517
518 if ( $size_data['file'] === $size_data['sources'][ $mime ]['file'] ) {
519 continue;
520 }
521
522 if ( $src_basename !== $size_data['file'] ) {
523 continue;
524 }
525
526 $result = str_replace(
527 $size_data['file'],
528 $size_data['sources'][ $mime ]['file'],
529 $src
530 );
531 return '' === $result ? null : $result;
532 }
533 }
534
535 return null;
536 }
537
538 /**
539 * Retrieves the MIME type of an attachment file, checking the file directly if possible.
540 *
541 * If checking the file directly fails, the function falls back to the attachment's MIME type.
542 *
543 * @since 2.3.0
544 *
545 * @param int $attachment_id The attachment ID.
546 * @param string $file Optional. The path to the file.
547 * @return string The MIME type of the file, or an empty string if not found.
548 */
549 function webp_uploads_get_attachment_file_mime_type( int $attachment_id, string $file = '' ): string {
550 if ( '' === $file ) {
551 $file = get_attached_file( $attachment_id, true );
552 // File does not exist.
553 if ( false === $file ) {
554 return '';
555 }
556 }
557
558 /*
559 * We need to get the MIME type ideally from the file, as WordPress Core may have already altered it.
560 * The post MIME type is typically not updated during that process.
561 */
562 $filetype = wp_check_filetype( $file );
563 $mime_type = $filetype['type'] ?? get_post_mime_type( $attachment_id );
564 return is_string( $mime_type ) ? $mime_type : '';
565 }
566
567 /**
568 * Checks if Imagick has AVIF transparency support.
569 *
570 * ImageMagick versions prior to 6.9.12-68 report AVIF support (so AVIF images can be
571 * generated), but they do not preserve the alpha channel when encoding AVIF. As a result,
572 * transparent images converted to AVIF on those versions silently lose their transparency.
573 * Support for AVIF transparency was added in 6.9.12-68, so this gates on that minimum version.
574 *
575 * @since 2.7.0
576 *
577 * @link https://github.com/WordPress/performance/issues/2237
578 *
579 * @param string|null $version Optional Imagick version string. If not provided, the version will be retrieved from the Imagick class.
580 * @return bool True if Imagick has AVIF transparency support, false otherwise.
581 */
582 function webp_uploads_imagick_avif_transparency_supported( ?string $version = null ): bool {
583 $imagick_version = $version;
584
585 if ( null === $imagick_version && extension_loaded( 'imagick' ) && class_exists( 'Imagick' ) ) {
586 $version_info = Imagick::getVersion();
587 $imagick_version = is_array( $version_info ) && isset( $version_info['versionString'] ) && is_string( $version_info['versionString'] )
588 ? $version_info['versionString']
589 : '';
590 }
591
592 if ( null === $imagick_version || '' === $imagick_version || ! (bool) preg_match( '/\d+(?:\.\d+)+(?:-\d+)?/', $imagick_version, $matches ) ) {
593 $supported = false;
594 } else {
595 // Transparency in AVIF is only handled correctly as of ImageMagick 6.9.12-68.
596 $imagick_version = $matches[0];
597 $supported = version_compare( $imagick_version, '6.9.12-68', '>=' );
598 }
599
600 /**
601 * Filters whether Imagick has AVIF transparency support.
602 *
603 * @since 2.7.0
604 *
605 * @param bool $supported Whether AVIF transparency is supported.
606 */
607 return (bool) apply_filters( 'webp_uploads_imagick_avif_transparency_supported', $supported );
608 }
609