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