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