PluginProbe ʕ •ᴥ•ʔ
Modern Image Formats / 2.7.0
Modern Image Formats v2.7.0
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 / image-edit.php
webp-uploads Last commit date
deprecated.php 17 hours ago helper.php 17 hours ago hooks.php 17 hours ago image-edit.php 17 hours ago load.php 17 hours ago picture-element.php 17 hours ago readme.txt 17 hours ago rest-api.php 17 hours ago settings.php 17 hours ago uninstall.php 17 hours ago
image-edit.php
533 lines
1 <?php
2 /**
3 * Edit images integration for the plugin, including backup and restore support.
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 * Adds sources to metadata for an attachment.
20 *
21 * @since 1.0.0
22 *
23 * @phpstan-param array{
24 * width: int,
25 * height: int,
26 * file: string,
27 * sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
28 * image_meta: array<string, mixed>,
29 * filesize: int,
30 * sources?: array<string, array{ file: string, filesize: int }>,
31 * original_image?: string
32 * } $metadata
33 * @phpstan-param array<string, array{ file: string, path: string, ... }> $main_images
34 * @phpstan-param array<string, array<string, array{ file: string }>> $subsized_images
35 *
36 * @param array $metadata Metadata of the attachment.
37 * @param string[] $valid_mime_transforms List of valid mime transforms for current image mime type.
38 * @param array $main_images Path of all main image files of all mime types.
39 * @param array $subsized_images Path of all subsized image file of all mime types.
40 *
41 * @return array{
42 * width: int,
43 * height: int,
44 * file: string,
45 * sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
46 * image_meta: array<string, mixed>,
47 * filesize: int,
48 * original_image?: string,
49 * sources?: array<string, array{ file: string, filesize: int }>
50 * } Metadata with sources added.
51 */
52 function webp_uploads_update_sources( array $metadata, array $valid_mime_transforms, array $main_images, array $subsized_images ): array {
53 foreach ( $valid_mime_transforms as $targeted_mime ) {
54 // Make sure the path and file exists as those values are required.
55 $image_directory = null;
56 if ( isset( $main_images[ $targeted_mime ]['path'], $main_images[ $targeted_mime ]['file'] ) && file_exists( $main_images[ $targeted_mime ]['path'] ) ) {
57 // Add sources to original image metadata.
58 $metadata['sources'][ $targeted_mime ] = array(
59 'file' => $main_images[ $targeted_mime ]['file'],
60 'filesize' => wp_filesize( $main_images[ $targeted_mime ]['path'] ),
61 );
62 $image_directory = pathinfo( $main_images[ $targeted_mime ]['path'], PATHINFO_DIRNAME );
63 }
64
65 /**
66 * If no original image was provided the image_directory can't be determined, in that scenario try to
67 * find it from the `file` property.
68 *
69 * @see get_attached_file()
70 */
71 if (
72 null === $image_directory
73 && isset( $metadata['file'] )
74 && 0 !== strpos( $metadata['file'], '/' )
75 && ':\\' !== substr( $metadata['file'], 1, 2 )
76 ) {
77 $uploads = wp_get_upload_dir();
78 if ( false === $uploads['error'] && isset( $uploads['basedir'] ) ) {
79 $file = path_join( $uploads['basedir'], $metadata['file'] );
80 if ( file_exists( $file ) ) {
81 $image_directory = pathinfo( $file, PATHINFO_DIRNAME );
82 }
83 }
84 }
85
86 if ( null === $image_directory ) {
87 continue;
88 }
89
90 foreach ( $metadata['sizes'] as $size_name => $size_details ) {
91 if (
92 ! isset( $subsized_images[ $targeted_mime ][ $size_name ]['file'] ) ||
93 ! is_string( $subsized_images[ $targeted_mime ][ $size_name ]['file'] ) ||
94 '' === $subsized_images[ $targeted_mime ][ $size_name ]['file']
95 ) {
96 continue;
97 }
98
99 // Add sources to resized image metadata.
100 $subsize_path = path_join( $image_directory, $subsized_images[ $targeted_mime ][ $size_name ]['file'] );
101 if ( ! file_exists( $subsize_path ) ) {
102 continue;
103 }
104
105 $metadata['sizes'][ $size_name ]['sources'][ $targeted_mime ] = array(
106 'file' => $subsized_images[ $targeted_mime ][ $size_name ]['file'],
107 'filesize' => wp_filesize( $subsize_path ),
108 );
109 }
110 }
111
112 return $metadata;
113 }
114
115 /**
116 * Creates additional image formats when original image is edited.
117 *
118 * @since 1.0.0
119 *
120 * @param bool|null|mixed $override Value to return instead of saving. Default null.
121 * @param string $file_path Name of the file to be saved.
122 * @param WP_Image_Editor $editor The image editor instance.
123 * @param string $mime_type The mime type of the image.
124 * @param int $post_id Attachment post ID.
125 * @return bool|null Potentially modified $override value.
126 */
127 function webp_uploads_update_image_onchange( $override, string $file_path, WP_Image_Editor $editor, string $mime_type, int $post_id ): ?bool {
128 if ( null !== $override ) {
129 return (bool) $override;
130 }
131
132 $transforms = webp_uploads_get_upload_image_mime_transforms();
133 if ( ! isset( $transforms[ $mime_type ] ) || ! is_array( $transforms[ $mime_type ] ) || 0 === count( $transforms[ $mime_type ] ) ) {
134 return null;
135 }
136
137 $mime_transforms = $transforms[ $mime_type ];
138 // This variable allows to unhook the logic from within the closure without the need for a function name.
139 $callback_executed = false;
140 add_filter(
141 'wp_update_attachment_metadata',
142 static function ( $metadata, $post_meta_id ) use ( $post_id, $file_path, $mime_type, $editor, $mime_transforms, &$callback_executed ) {
143 if ( $post_meta_id !== $post_id ) {
144 return $metadata;
145 }
146
147 // This callback was already executed for this post, nothing to do at this point.
148 if ( $callback_executed ) {
149 return $metadata;
150 }
151 $callback_executed = true;
152 // No sizes to be created.
153 if ( ! isset( $metadata['sizes'] ) || ! is_array( $metadata['sizes'] ) || 0 === count( $metadata['sizes'] ) ) {
154 return $metadata;
155 }
156
157 $old_metadata = wp_get_attachment_metadata( $post_id );
158 $resize_sizes = array();
159 // PHPCS ignore reason: A nonce check is not necessary here as this logic directly ties in with WordPress core
160 // function `wp_ajax_image_editor()` which already has one.
161 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
162 $target = isset( $_REQUEST['target'] ) ? sanitize_key( $_REQUEST['target'] ) : 'all';
163
164 if ( isset( $old_metadata['sizes'] ) ) {
165 foreach ( $old_metadata['sizes'] as $size_name => $size_details ) {
166 // If the target is 'nothumb', skip generating the 'thumbnail' size.
167 if ( webp_uploads_image_edit_thumbnails_separately() && 'nothumb' === $target && 'thumbnail' === $size_name ) {
168 continue;
169 }
170
171 if (
172 isset( $metadata['sizes'][ $size_name ]['file'] ) &&
173 $metadata['sizes'][ $size_name ]['file'] !== $old_metadata['sizes'][ $size_name ]['file']
174 ) {
175 $resize_sizes[ $size_name ] = $metadata['sizes'][ $size_name ];
176 }
177 }
178 }
179
180 $allowed_mimes = array_flip( wp_get_mime_types() );
181 $original_directory = pathinfo( $file_path, PATHINFO_DIRNAME );
182 $filename = pathinfo( $file_path, PATHINFO_FILENAME );
183 $main_images = array();
184 $subsized_images = array();
185
186 foreach ( $mime_transforms as $targeted_mime ) {
187 if ( $targeted_mime === $mime_type ) {
188 // If the target is `thumbnail` make sure it is the only selected size.
189 if ( webp_uploads_image_edit_thumbnails_separately() && 'thumbnail' === $target ) {
190 if ( isset( $metadata['sizes']['thumbnail'] ) ) {
191 $subsized_images[ $targeted_mime ] = array( 'thumbnail' => $metadata['sizes']['thumbnail'] );
192 }
193 // When the targeted thumbnail is selected no additional size and subsize is set.
194 continue;
195 }
196
197 $main_images[ $targeted_mime ] = array(
198 'path' => $file_path,
199 'file' => pathinfo( $file_path, PATHINFO_BASENAME ),
200 );
201 $subsized_images[ $targeted_mime ] = $metadata['sizes'];
202 continue;
203 }
204
205 if ( ! isset( $allowed_mimes[ $targeted_mime ] ) || ! is_string( $allowed_mimes[ $targeted_mime ] ) ) {
206 continue;
207 }
208
209 if ( $editor instanceof WP_Image_Editor && ! $editor::supports_mime_type( $targeted_mime ) ) {
210 continue;
211 }
212
213 $extension = explode( '|', $allowed_mimes[ $targeted_mime ] );
214 $extension = $extension[0];
215
216 // If the target is `thumbnail` make sure only that size is generated.
217 if ( webp_uploads_image_edit_thumbnails_separately() && 'thumbnail' === $target ) {
218 if ( ! isset( $subsized_images[ $mime_type ]['thumbnail']['file'] ) ) {
219 continue;
220 }
221 $thumbnail_file = $subsized_images[ $mime_type ]['thumbnail']['file'];
222 $image_path = path_join( $original_directory, $thumbnail_file );
223 $editor = wp_get_image_editor( $image_path, array( 'mime_type' => $targeted_mime ) );
224
225 if ( is_wp_error( $editor ) ) {
226 continue;
227 }
228
229 $current_extension = pathinfo( $thumbnail_file, PATHINFO_EXTENSION );
230 // Create a file with then new extension out of the targeted file.
231 $target_file_name = preg_replace( "/\.$current_extension$/", ".$extension", $thumbnail_file );
232 $target_file_location = path_join( $original_directory, $target_file_name );
233
234 remove_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10 );
235 $result = $editor->save( $target_file_location, $targeted_mime );
236 add_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10, 3 );
237
238 if ( is_wp_error( $result ) ) {
239 continue;
240 }
241
242 $subsized_images[ $targeted_mime ] = array( 'thumbnail' => $result );
243 } elseif ( $editor instanceof WP_Image_Editor ) {
244 $destination = trailingslashit( $original_directory ) . "{$filename}.{$extension}";
245
246 remove_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10 );
247 $result = $editor->save( $destination, $targeted_mime );
248 add_filter( 'image_editor_output_format', 'webp_uploads_filter_image_editor_output_format', 10, 3 );
249
250 if ( is_wp_error( $result ) ) {
251 continue;
252 }
253
254 $main_images[ $targeted_mime ] = $result;
255 $subsized_images[ $targeted_mime ] = $editor->multi_resize( $resize_sizes );
256 }
257 }
258
259 return webp_uploads_update_sources( $metadata, $mime_transforms, $main_images, $subsized_images );
260 },
261 10,
262 2
263 );
264
265 return null;
266 }
267 add_filter( 'wp_save_image_editor_file', 'webp_uploads_update_image_onchange', 10, 5 );
268
269 /**
270 * Inspect if the current call to `wp_update_attachment_metadata()` was done from within the context
271 * of an edit to an attachment either restore or other type of edit, in that case we perform operations
272 * to save the sources properties, specifically for the `full` size image due this is a virtual image size.
273 *
274 * @since 1.0.0
275 *
276 * @see wp_update_attachment_metadata()
277 *
278 * @phpstan-param array{
279 * width: int,
280 * height: int,
281 * file: string,
282 * sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
283 * image_meta: array<string, mixed>,
284 * filesize: int,
285 * original_image: string
286 * } $data
287 *
288 * @param array<string, mixed> $data The current metadata of the attachment.
289 * @param int $attachment_id The ID of the current attachment.
290 *
291 * @return array{
292 * width: int,
293 * height: int,
294 * file: string,
295 * sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
296 * image_meta: array<string, mixed>,
297 * filesize: int,
298 * original_image: string,
299 * sources?: array<string, array{ file: string, filesize: int }>
300 * } The updated metadata for the attachment to be stored in the meta table.
301 */
302 function webp_uploads_update_attachment_metadata( array $data, int $attachment_id ): array {
303 // PHPCS ignore reason: Update the attachment's metadata by either restoring or editing it.
304 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
305 $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 10 );
306
307 foreach ( $trace as $element ) {
308 switch ( $element['function'] ) {
309 case 'wp_save_image':
310 // Right after an image has been edited.
311 return webp_uploads_backup_sources( $attachment_id, $data );
312 case 'wp_restore_image':
313 // When an image has been restored.
314 $data = webp_uploads_backup_sources( $attachment_id, $data );
315 return webp_uploads_restore_image( $attachment_id, $data );
316 }
317 }
318
319 return $data;
320 }
321 add_filter( 'wp_update_attachment_metadata', 'webp_uploads_update_attachment_metadata', 10, 2 );
322
323 /**
324 * Before saving the metadata of the image store a backup values for the sources and file property
325 * those files would be used and deleted by the backup mechanism, right after the metadata has
326 * been updated. It removes the current sources property due once this function is executed
327 * right after an edit has taken place and the current sources are no longer accurate.
328 *
329 * @since 1.0.0
330 *
331 * @phpstan-param array{
332 * width: int,
333 * height: int,
334 * file: string,
335 * sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
336 * image_meta: array<string, mixed>,
337 * filesize: int,
338 * original_image: string,
339 * sources?: array<string, array{ file: string, filesize: int }>
340 * } $data
341 *
342 * @param int $attachment_id The ID representing the attachment.
343 * @param array<string, mixed> $data The current metadata of the attachment.
344 *
345 * @return array{
346 * width: int,
347 * height: int,
348 * file: string,
349 * sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
350 * image_meta: array<string, mixed>,
351 * filesize: int,
352 * original_image: string,
353 * sources?: array<string, array{ file: string, filesize: int }>
354 * } The updated metadata for the attachment.
355 */
356 function webp_uploads_backup_sources( int $attachment_id, array $data ): array {
357 // PHPCS ignore reason: A nonce check is not necessary here as this logic directly ties in with WordPress core
358 // function `wp_ajax_image_editor()` which already has one.
359 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
360 $target = isset( $_REQUEST['target'] ) ? sanitize_key( $_REQUEST['target'] ) : 'all';
361
362 // When an edit to an image is only applied to a thumbnail there's nothing we need to back up.
363 if ( webp_uploads_image_edit_thumbnails_separately() && 'thumbnail' === $target ) {
364 return $data;
365 }
366
367 $metadata = wp_get_attachment_metadata( $attachment_id );
368 // Nothing to back up.
369 if ( ! isset( $metadata['sources'] ) ) {
370 return $data;
371 }
372
373 $sources = $metadata['sources'];
374 // Prevent execution of the callbacks more than once if the callback was already executed.
375 $has_been_processed = false;
376
377 $hook = static function ( $meta_id, $post_id, $meta_name ) use ( $attachment_id, $sources, &$has_been_processed ): void {
378 // Make sure this hook is only executed in the same context for the provided $attachment_id.
379 if ( $post_id !== $attachment_id ) {
380 return;
381 }
382
383 // This logic should work only if we are looking at the meta key: `_wp_attachment_backup_sizes`.
384 if ( '_wp_attachment_backup_sizes' !== $meta_name ) {
385 return;
386 }
387
388 if ( $has_been_processed ) {
389 return;
390 }
391
392 $has_been_processed = true;
393 webp_uploads_backup_full_image_sources( $post_id, $sources );
394 };
395
396 add_action( 'added_post_meta', $hook, 10, 3 );
397 add_action( 'updated_post_meta', $hook, 10, 3 );
398
399 // Remove the current sources as at this point the current values are no longer accurate.
400 // TODO: Requires to be updated from https://github.com/WordPress/performance/issues/158.
401 unset( $data['sources'] );
402
403 return $data;
404 }
405
406 /**
407 * Stores the provided sources for the attachment ID in the `_wp_attachment_backup_sources` with
408 * the next available target if target is `null` no source would be stored.
409 *
410 * @since 1.0.0
411 *
412 * @param int $attachment_id The ID of the attachment.
413 * @param array<string, array{ file: string, filesize: int }> $sources An array with the full sources to be stored on the next available key.
414 */
415 function webp_uploads_backup_full_image_sources( int $attachment_id, array $sources ): void {
416 if ( 0 === count( $sources ) ) {
417 return;
418 }
419
420 $target = webp_uploads_get_next_full_size_key_from_backup( $attachment_id );
421 if ( null === $target ) {
422 return;
423 }
424
425 $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true );
426 $backup_sources = is_array( $backup_sources ) ? $backup_sources : array();
427 $backup_sources[ $target ] = $sources;
428 // Store the `sources` property into the full size if present.
429 update_post_meta( $attachment_id, '_wp_attachment_backup_sources', $backup_sources );
430 }
431
432 /**
433 * It finds the next available `full-{orig or hash}` key on the images if the name
434 * has not been used as part of the backup sources it would be used if no size is
435 * found or backup exists `null` would be returned instead.
436 *
437 * @since 1.0.0
438 *
439 * @param int $attachment_id The ID of the attachment.
440 * @return null|string The next available full size name.
441 */
442 function webp_uploads_get_next_full_size_key_from_backup( int $attachment_id ): ?string {
443 $backup_sizes = get_post_meta( $attachment_id, '_wp_attachment_backup_sizes', true );
444 $backup_sizes = is_array( $backup_sizes ) ? $backup_sizes : array();
445
446 if ( 0 === count( $backup_sizes ) ) {
447 return null;
448 }
449
450 $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true );
451 $backup_sources = is_array( $backup_sources ) ? $backup_sources : array();
452 foreach ( array_keys( $backup_sizes ) as $size_name ) {
453 // If the target already has the sources attributes find the next one.
454 if ( isset( $backup_sources[ $size_name ] ) ) {
455 continue;
456 }
457
458 // We are only interested in the `full-` sizes.
459 if ( strpos( $size_name, 'full-' ) === false ) {
460 continue;
461 }
462
463 return $size_name;
464 }
465
466 return null;
467 }
468
469 /**
470 * Restore an image from the backup sizes, the current hook moves the `sources` from the `full-orig` key into
471 * the top level `sources` into the metadata, in order to ensure the restore process has a reference to the right
472 * images.
473 *
474 * @since 1.0.0
475 *
476 * @phpstan-param array{
477 * width: int,
478 * height: int,
479 * file: string,
480 * sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
481 * image_meta: array<string, mixed>,
482 * filesize: int,
483 * original_image: string,
484 * sources?: array<string, array{ file: string, filesize: int }>
485 * } $data
486 *
487 * @param int $attachment_id The ID of the attachment.
488 * @param array<string, mixed> $data The current metadata to be stored in the attachment.
489 * @return array{
490 * width: int,
491 * height: int,
492 * file: string,
493 * sizes: array<string, array{ file: string, width: int, height: int, 'mime-type': string, sources?: array<string, array{ file: string, filesize: int }> }>,
494 * image_meta: array<string, mixed>,
495 * filesize: int,
496 * sources?: array<string, array{ file: string, filesize: int }>,
497 * original_image: string
498 * } The updated metadata of the attachment.
499 */
500 function webp_uploads_restore_image( int $attachment_id, array $data ): array {
501 $backup_sources = get_post_meta( $attachment_id, '_wp_attachment_backup_sources', true );
502 if ( ! is_array( $backup_sources ) ) {
503 $backup_sources = array();
504 }
505
506 if ( ! isset( $backup_sources['full-orig'] ) || ! is_array( $backup_sources['full-orig'] ) ) {
507 return $data;
508 }
509
510 // TODO: Handle the case If `IMAGE_EDIT_OVERWRITE` is defined and is truthy remove any edited images if present before replacing the metadata.
511 // See: https://github.com/WordPress/performance/issues/158.
512 $data['sources'] = $backup_sources['full-orig'];
513
514 return $data;
515 }
516
517 /**
518 * Compatibility function to check whether editing image thumbnails separately is enabled.
519 *
520 * The filter {@see 'image_edit_thumbnails_separately'} was introduced in WordPress 6.3 with default value of `false`,
521 * for a behavior that previously was always enabled.
522 *
523 * @since 1.0.2
524 *
525 * @see https://core.trac.wordpress.org/ticket/57685
526 *
527 * @return bool True if editing image thumbnails is enabled, false otherwise.
528 */
529 function webp_uploads_image_edit_thumbnails_separately(): bool {
530 /** This filter is documented in wp-admin/includes/image-edit.php */
531 return (bool) apply_filters( 'image_edit_thumbnails_separately', false ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Intentionally applying core filter.
532 }
533