From 7ce00ee69bb37092b9078527c7abcbd3589eeed6 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 26 Jun 2026 17:24:56 +1000 Subject: [PATCH 01/12] Add image mask support to media edits --- src/wp-includes/class-wp-image-editor-gd.php | 60 ++++++ .../class-wp-image-editor-imagick.php | 73 ++++++++ src/wp-includes/class-wp-image-editor.php | 49 +++++ src/wp-includes/media.php | 15 +- .../class-wp-rest-attachments-controller.php | 89 ++++++++- tests/phpunit/includes/mock-image-editor.php | 13 ++ tests/phpunit/tests/image/editor.php | 31 ++++ tests/phpunit/tests/image/editorGd.php | 55 ++++++ tests/phpunit/tests/image/editorImagick.php | 57 ++++++ .../rest-api/rest-attachments-controller.php | 175 +++++++++++++++++- 10 files changed, 606 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 3d93b5bd8a2c1..c36191d4448ba 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -51,6 +51,17 @@ public static function test( $args = array() ) { return false; } + if ( isset( $args['methods'] ) && in_array( 'mask', $args['methods'], true ) ) { + return ( + self::supports_mime_type( 'image/png' ) && + function_exists( 'imagealphablending' ) && + function_exists( 'imagecolorallocatealpha' ) && + function_exists( 'imagepng' ) && + function_exists( 'imagesavealpha' ) && + function_exists( 'imagesetpixel' ) + ); + } + return true; } @@ -470,6 +481,55 @@ public function flip( $horz, $vert ) { return new WP_Error( 'image_flip_error', __( 'Image flip failed.' ), $this->file ); } + /** + * Applies a mask to the current image. + * + * @since 7.1.0 + * + * @param array $args { + * Mask arguments. + * + * @type string $shape Mask shape. Accepts 'circle'. + * } + * @return true|WP_Error True on success, WP_Error object on failure. + */ + public function mask( $args ) { + $args = $this->validate_mask_args( $args ); + if ( is_wp_error( $args ) ) { + return $args; + } + + if ( function_exists( 'imagepalettetotruecolor' ) ) { + imagepalettetotruecolor( $this->image ); + } + + imagealphablending( $this->image, false ); + imagesavealpha( $this->image, true ); + + $width = imagesx( $this->image ); + $height = imagesy( $this->image ); + $transparent = imagecolorallocatealpha( $this->image, 0, 0, 0, 127 ); + $center_x = ( $width - 1 ) / 2; + $center_y = ( $height - 1 ) / 2; + $radius = min( $width, $height ) / 2; + $radius_squared = $radius * $radius; + + for ( $y = 0; $y < $height; $y++ ) { + for ( $x = 0; $x < $width; $x++ ) { + $dx = $x - $center_x; + $dy = $y - $center_y; + + if ( ( $dx * $dx ) + ( $dy * $dy ) > $radius_squared ) { + imagesetpixel( $this->image, $x, $y, $transparent ); + } + } + } + + $this->mime_type = 'image/png'; + + return true; + } + /** * Saves current in-memory image to file. * diff --git a/src/wp-includes/class-wp-image-editor-imagick.php b/src/wp-includes/class-wp-image-editor-imagick.php index 2cb3a694c567b..2112a28a5a786 100644 --- a/src/wp-includes/class-wp-image-editor-imagick.php +++ b/src/wp-includes/class-wp-image-editor-imagick.php @@ -84,6 +84,22 @@ public static function test( $args = array() ) { return false; } + if ( isset( $args['methods'] ) && in_array( 'mask', $args['methods'], true ) ) { + return ( + class_exists( 'ImagickDraw', false ) && + ( defined( 'Imagick::ALPHACHANNEL_SET' ) || defined( 'Imagick::ALPHACHANNEL_ACTIVATE' ) ) && + defined( 'Imagick::COMPOSITE_DSTIN' ) && + method_exists( 'ImagickDraw', 'ellipse' ) && + method_exists( 'ImagickDraw', 'setFillColor' ) && + method_exists( 'Imagick', 'compositeImage' ) && + method_exists( 'Imagick', 'drawImage' ) && + method_exists( 'Imagick', 'getImageGeometry' ) && + method_exists( 'Imagick', 'newImage' ) && + method_exists( 'Imagick', 'setImageAlphaChannel' ) && + method_exists( 'Imagick', 'setImageFormat' ) + ); + } + return true; } @@ -826,6 +842,63 @@ public function flip( $horz, $vert ) { return true; } + /** + * Applies a mask to the current image. + * + * @since 7.1.0 + * + * @param array $args { + * Mask arguments. + * + * @type string $shape Mask shape. Accepts 'circle'. + * } + * @return true|WP_Error True on success, WP_Error object on failure. + */ + public function mask( $args ) { + $args = $this->validate_mask_args( $args ); + if ( is_wp_error( $args ) ) { + return $args; + } + + try { + if ( defined( 'Imagick::ALPHACHANNEL_SET' ) ) { + $this->image->setImageAlphaChannel( Imagick::ALPHACHANNEL_SET ); + } elseif ( defined( 'Imagick::ALPHACHANNEL_ACTIVATE' ) ) { + $this->image->setImageAlphaChannel( Imagick::ALPHACHANNEL_ACTIVATE ); + } + + $geometry = $this->image->getImageGeometry(); + $width = (int) $geometry['width']; + $height = (int) $geometry['height']; + + $mask = new Imagick(); + $mask->newImage( $width, $height, new ImagickPixel( 'transparent' ), 'png' ); + + $draw = new ImagickDraw(); + $draw->setFillColor( new ImagickPixel( 'white' ) ); + $draw->ellipse( + ( $width - 1 ) / 2, + ( $height - 1 ) / 2, + min( $width, $height ) / 2, + min( $width, $height ) / 2, + 0, + 360 + ); + $mask->drawImage( $draw ); + + $this->image->compositeImage( $mask, Imagick::COMPOSITE_DSTIN, 0, 0 ); + $this->image->setImageFormat( 'PNG' ); + $this->mime_type = 'image/png'; + + $mask->clear(); + $mask->destroy(); + } catch ( Exception $e ) { + return new WP_Error( 'image_mask_error', $e->getMessage(), $this->file ); + } + + return true; + } + /** * Check if a JPEG image has EXIF Orientation tag and rotate it if needed. * diff --git a/src/wp-includes/class-wp-image-editor.php b/src/wp-includes/class-wp-image-editor.php index 8401117836ebc..90404d253ab5b 100644 --- a/src/wp-includes/class-wp-image-editor.php +++ b/src/wp-includes/class-wp-image-editor.php @@ -170,6 +170,55 @@ abstract public function rotate( $angle ); */ abstract public function flip( $horz, $vert ); + /** + * Applies a mask to the current image. + * + * Implementations that support image masks should override this method. + * + * @since 7.1.0 + * + * @param array $args { + * Mask arguments. + * + * @type string $shape Mask shape. Accepts 'circle'. + * } + * @return true|WP_Error True on success, WP_Error object on failure. + */ + public function mask( $args ) { + $args = $this->validate_mask_args( $args ); + if ( is_wp_error( $args ) ) { + return $args; + } + + return new WP_Error( + 'image_mask_unsupported', + __( 'Unsupported image mask.' ), + $this->file + ); + } + + /** + * Validates and normalizes image mask arguments. + * + * @since 7.1.0 + * + * @param array $args Mask arguments. + * @return array|WP_Error Normalized mask arguments on success, WP_Error object on failure. + */ + protected function validate_mask_args( $args ) { + if ( ! is_array( $args ) || 'circle' !== ( $args['shape'] ?? null ) ) { + return new WP_Error( + 'image_mask_unsupported', + __( 'Unsupported image mask.' ), + $this->file + ); + } + + return array( + 'shape' => 'circle', + ); + } + /** * Streams current image to browser. * diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index e5b4276e46af5..328875bc6c727 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4368,7 +4368,7 @@ function wp_get_image_editor( $path, $args = array() ) { } // Check and set the output mime type mapped to the input type. - if ( isset( $args['mime_type'] ) ) { + if ( isset( $args['mime_type'] ) && ! isset( $args['output_mime_type'] ) ) { $output_format = wp_get_image_editor_output_format( $path, $args['mime_type'] ); if ( isset( $output_format[ $args['mime_type'] ] ) ) { $args['output_mime_type'] = $output_format[ $args['mime_type'] ]; @@ -4466,6 +4466,18 @@ function _wp_image_editor_choose( $args = array() ) { continue; } + if ( isset( $args['methods'] ) && in_array( 'mask', $args['methods'], true ) ) { + try { + $mask_method = new ReflectionMethod( $implementation, 'mask' ); + } catch ( ReflectionException $e ) { + continue; + } + + if ( WP_Image_Editor::class === $mask_method->getDeclaringClass()->getName() ) { + continue; + } + } + // Implementation should ideally support the output mime type as well if set and different than the passed type. if ( isset( $args['mime_type'] ) && @@ -6798,4 +6810,3 @@ function wp_add_crossorigin_attributes( string $html ): string { return $processor->get_updated_html(); } - diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index a3f68154c18cd..f99aedc385726 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -1044,6 +1044,14 @@ public function edit_media_item( $request ) { } } + $has_mask_modifier = false; + foreach ( $modifiers as $modifier ) { + if ( 'mask' === ( $modifier['type'] ?? null ) ) { + $has_mask_modifier = true; + break; + } + } + /* * If the file doesn't exist, attempt a URL fopen on the src link. * This can occur with certain file replication plugins. @@ -1054,9 +1062,26 @@ public function edit_media_item( $request ) { $image_file_to_edit = _load_image_to_edit_path( $attachment_id ); } - $image_editor = wp_get_image_editor( $image_file_to_edit ); + $image_editor_args = array(); + if ( $has_mask_modifier ) { + $image_editor_args = array( + 'methods' => array( 'mask' ), + 'mime_type' => $mime_type, + 'output_mime_type' => 'image/png', + ); + } + + $image_editor = wp_get_image_editor( $image_file_to_edit, $image_editor_args ); if ( is_wp_error( $image_editor ) ) { + if ( $has_mask_modifier ) { + return new WP_Error( + 'rest_image_mask_unsupported', + __( 'Unable to mask this image.' ), + array( 'status' => 500 ) + ); + } + return new WP_Error( 'rest_unknown_image_file_type', __( 'Unable to edit this image.' ), @@ -1122,6 +1147,19 @@ public function edit_media_item( $request ) { break; + case 'mask': + $result = $image_editor->mask( $args ); + + if ( is_wp_error( $result ) ) { + return new WP_Error( + 'rest_image_mask_failed', + __( 'Unable to mask this image.' ), + array( 'status' => 500 ) + ); + } + + break; + } } @@ -1141,7 +1179,9 @@ public function edit_media_item( $request ) { $image_name .= '-edited'; } - $filename = "{$image_name}.{$image_ext}"; + $output_mime_type = $has_mask_modifier ? 'image/png' : null; + $output_ext = $has_mask_modifier ? 'png' : $image_ext; + $filename = "{$image_name}.{$output_ext}"; // Create the uploads subdirectory if needed. $uploads = wp_upload_dir(); @@ -1150,7 +1190,19 @@ public function edit_media_item( $request ) { $filename = wp_unique_filename( $uploads['path'], $filename ); // Save to disk. - $saved = $image_editor->save( $uploads['path'] . "/$filename" ); + if ( $has_mask_modifier ) { + $disable_png_output_mapping = static function ( $output_format ) { + unset( $output_format['image/png'] ); + return $output_format; + }; + add_filter( 'image_editor_output_format', $disable_png_output_mapping, PHP_INT_MAX ); + } + + $saved = $image_editor->save( $uploads['path'] . "/$filename", $output_mime_type ); + + if ( $has_mask_modifier ) { + remove_filter( 'image_editor_output_format', $disable_png_output_mapping, PHP_INT_MAX ); + } if ( is_wp_error( $saved ) ) { return $saved; @@ -1162,7 +1214,7 @@ public function edit_media_item( $request ) { // Check request fields and assign default values. $new_attachment_post = $this->prepare_item_for_database( $request ); $new_attachment_post->post_mime_type = $saved['mime-type']; - $new_attachment_post->guid = $uploads['url'] . "/$filename"; + $new_attachment_post->guid = $uploads['url'] . '/' . $saved['file']; // Unset ID so wp_insert_attachment generates a new ID. unset( $new_attachment_post->ID ); @@ -2262,10 +2314,35 @@ protected function get_edit_media_item_args() { ), ), ), + array( + 'title' => __( 'Mask' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'Mask type.' ), + 'type' => 'string', + 'enum' => array( 'mask' ), + ), + 'args' => array( + 'description' => __( 'Mask arguments.' ), + 'type' => 'object', + 'required' => array( + 'shape', + ), + 'properties' => array( + 'shape' => array( + 'description' => __( 'Mask shape.' ), + 'type' => 'string', + 'enum' => array( 'circle' ), + ), + ), + ), + ), + ), ), ), - ), - 'rotation' => array( + ), + 'rotation' => array( 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'integer', 'minimum' => 0, diff --git a/tests/phpunit/includes/mock-image-editor.php b/tests/phpunit/includes/mock-image-editor.php index ab2e7379fd439..c49f3ab9b3c1b 100644 --- a/tests/phpunit/includes/mock-image-editor.php +++ b/tests/phpunit/includes/mock-image-editor.php @@ -56,6 +56,8 @@ public function flip( $horz, $vert ) { } } public function save( $destfilename = null, $mime_type = null ) { + self::$spy[ __FUNCTION__ ][] = func_get_args(); + // Set new mime-type and quality if converting the image. $this->get_output_format( $destfilename, $mime_type ); return self::$save_return; @@ -72,4 +74,15 @@ public function get_size() { } } + class WP_Image_Editor_Mock_With_Mask extends WP_Image_Editor_Mock { + public function mask( $args ) { + self::$spy[ __FUNCTION__ ][] = func_get_args(); + if ( isset( self::$edit_return[ __FUNCTION__ ] ) ) { + return self::$edit_return[ __FUNCTION__ ]; + } + + return true; + } + } + endif; diff --git a/tests/phpunit/tests/image/editor.php b/tests/phpunit/tests/image/editor.php index 58c9880fe396c..aadad31e6f510 100644 --- a/tests/phpunit/tests/image/editor.php +++ b/tests/phpunit/tests/image/editor.php @@ -49,6 +49,37 @@ public function test_get_editor_load_returns_false() { WP_Image_Editor_Mock::$load_return = true; } + /** + * Tests that implementations inheriting the default mask method are not selected for mask requests. + * + * @ticket 44405 + */ + public function test_get_editor_does_not_select_implementation_inheriting_default_mask() { + $editor = wp_get_image_editor( DIR_TESTDATA . '/images/canola.jpg', array( 'methods' => array( 'mask' ) ) ); + + $this->assertWPError( $editor ); + $this->assertSame( 'image_no_editor', $editor->get_error_code() ); + } + + /** + * Tests that implementations overriding the default mask method are selected for mask requests. + * + * @ticket 44405 + */ + public function test_get_editor_selects_implementation_overriding_mask() { + add_filter( + 'wp_image_editors', + static function () { + return array( 'WP_Image_Editor_Mock_With_Mask' ); + }, + 11 + ); + + $editor = wp_get_image_editor( DIR_TESTDATA . '/images/canola.jpg', array( 'methods' => array( 'mask' ) ) ); + + $this->assertInstanceOf( 'WP_Image_Editor_Mock_With_Mask', $editor ); + } + /** * Return integer of 95 for testing. */ diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index ac0e8268390c2..a67c334f43b72 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -51,6 +51,61 @@ public function test_supports_mime_type_gif() { $this->assertSame( $expected, $gd_image_editor->supports_mime_type( 'image/gif' ) ); } + public function test_supports_mask() { + $expected = ( + ( imagetypes() & IMG_PNG ) !== 0 && + function_exists( 'imagealphablending' ) && + function_exists( 'imagecolorallocatealpha' ) && + function_exists( 'imagepng' ) && + function_exists( 'imagesavealpha' ) && + function_exists( 'imagesetpixel' ) + ); + + $this->assertSame( $expected, WP_Image_Editor_GD::test( array( 'methods' => array( 'mask' ) ) ) ); + } + + /** + * @requires function imagepng + * @requires function imagecreatefrompng + */ + public function test_mask_circle() { + if ( ! WP_Image_Editor_GD::test( array( 'methods' => array( 'mask' ) ) ) ) { + $this->markTestSkipped( 'This test requires GD mask support.' ); + } + + $file = DIR_TESTDATA . '/images/canola.jpg'; + + $gd_image_editor = new WP_Image_Editor_GD( $file ); + $gd_image_editor->load(); + $gd_image_editor->crop( 0, 0, 100, 100 ); + $result = $gd_image_editor->mask( array( 'shape' => 'circle' ) ); + + $this->assertTrue( $result ); + + $save_to_file = tempnam( get_temp_dir(), '' ) . '.png'; + $gd_image_editor->save( $save_to_file, 'image/png' ); + + $this->assertImageAlphaAtPointGD( $save_to_file, array( 0, 0 ), 127 ); + + $image = imagecreatefrompng( $save_to_file ); + $center_color = imagecolorsforindex( $image, imagecolorat( $image, 50, 50 ) ); + + $this->assertLessThan( 127, $center_color['alpha'] ); + + unlink( $save_to_file ); + } + + public function test_mask_returns_error_for_unsupported_shape() { + $file = DIR_TESTDATA . '/images/canola.jpg'; + + $gd_image_editor = new WP_Image_Editor_GD( $file ); + $gd_image_editor->load(); + $result = $gd_image_editor->mask( array( 'shape' => 'square' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'image_mask_unsupported', $result->get_error_code() ); + } + /** * Tests resizing an image, not using crop. * diff --git a/tests/phpunit/tests/image/editorImagick.php b/tests/phpunit/tests/image/editorImagick.php index e120c32502ad5..4a14f921e6dd9 100644 --- a/tests/phpunit/tests/image/editorImagick.php +++ b/tests/phpunit/tests/image/editorImagick.php @@ -45,6 +45,63 @@ public function test_supports_mime_type() { $this->assertTrue( $imagick_image_editor->supports_mime_type( 'image/gif' ), 'Does not support image/gif' ); } + public function test_supports_mask() { + $expected = ( + class_exists( 'ImagickDraw', false ) && + ( defined( 'Imagick::ALPHACHANNEL_SET' ) || defined( 'Imagick::ALPHACHANNEL_ACTIVATE' ) ) && + defined( 'Imagick::COMPOSITE_DSTIN' ) && + method_exists( 'ImagickDraw', 'ellipse' ) && + method_exists( 'ImagickDraw', 'setFillColor' ) && + method_exists( 'Imagick', 'compositeImage' ) && + method_exists( 'Imagick', 'drawImage' ) && + method_exists( 'Imagick', 'getImageGeometry' ) && + method_exists( 'Imagick', 'newImage' ) && + method_exists( 'Imagick', 'setImageAlphaChannel' ) && + method_exists( 'Imagick', 'setImageFormat' ) + ); + + $this->assertSame( $expected, WP_Image_Editor_Imagick::test( array( 'methods' => array( 'mask' ) ) ) ); + } + + public function test_mask_circle() { + if ( ! WP_Image_Editor_Imagick::test( array( 'methods' => array( 'mask' ) ) ) ) { + $this->markTestSkipped( 'This test requires Imagick mask support.' ); + } + + $file = DIR_TESTDATA . '/images/canola.jpg'; + + $imagick_image_editor = new WP_Image_Editor_Imagick( $file ); + $imagick_image_editor->load(); + $imagick_image_editor->crop( 0, 0, 100, 100 ); + $result = $imagick_image_editor->mask( array( 'shape' => 'circle' ) ); + + $this->assertTrue( $result ); + + $save_to_file = tempnam( get_temp_dir(), '' ) . '.png'; + $imagick_image_editor->save( $save_to_file, 'image/png' ); + + $image = new Imagick( $save_to_file ); + $corner_alpha = $image->getImagePixelColor( 0, 0 )->getColorValue( imagick::COLOR_ALPHA ); + $center_alpha = $image->getImagePixelColor( 50, 50 )->getColorValue( imagick::COLOR_ALPHA ); + + $this->assertLessThan( 0.5, $corner_alpha ); + $this->assertGreaterThan( 0.5, $center_alpha ); + + $image->destroy(); + unlink( $save_to_file ); + } + + public function test_mask_returns_error_for_unsupported_shape() { + $file = DIR_TESTDATA . '/images/canola.jpg'; + + $imagick_image_editor = new WP_Image_Editor_Imagick( $file ); + $imagick_image_editor->load(); + $result = $imagick_image_editor->mask( array( 'shape' => 'square' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'image_mask_unsupported', $result->get_error_code() ); + } + /** * Tests resizing an image, not using crop. */ diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 55859c085fdd9..d4eead0184bf8 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -189,7 +189,10 @@ public function tear_down() { if ( class_exists( WP_Image_Editor_Mock::class ) ) { WP_Image_Editor_Mock::$spy = array(); WP_Image_Editor_Mock::$edit_return = array(); + WP_Image_Editor_Mock::$save_return = array(); WP_Image_Editor_Mock::$size_return = null; + WP_Image_Editor_Mock::$test_return = true; + WP_Image_Editor_Mock::$load_return = true; } parent::tear_down(); @@ -3111,6 +3114,172 @@ public function test_batch_edit_image() { $this->assertStringContainsString( 'canola', $item['media_details']['parent_image']['file'] ); } + /** + * @ticket 44405 + * @requires function imagecreatefrompng + */ + public function test_edit_image_mask() { + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/jpeg', 'output_mime_type' => 'image/png', 'methods' => array( 'mask' ) ) ) ) { + $this->markTestSkipped( 'This test requires an image editor with mask support.' ); + } + + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $params = array( + 'modifiers' => array( + array( + 'type' => 'crop', + 'args' => array( + 'left' => 12.5, + 'top' => 0, + 'width' => 75, + 'height' => 100, + ), + ), + array( + 'type' => 'mask', + 'args' => array( + 'shape' => 'circle', + ), + ), + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + $item = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'image/png', $item['mime_type'] ); + $this->assertStringEndsWith( '-edited.png', $item['media_details']['file'] ); + $this->assertArrayHasKey( 'parent_image', $item['media_details'] ); + $this->assertSame( (string) $attachment, $item['media_details']['parent_image']['attachment_id'] ); + + $file = get_attached_file( $item['id'] ); + $this->assertSame( 'image/png', wp_get_image_mime( $file ) ); + + $image = imagecreatefrompng( $file ); + $this->assertNotFalse( $image ); + + $corner_color = imagecolorsforindex( $image, imagecolorat( $image, 0, 0 ) ); + $center_color = imagecolorsforindex( $image, imagecolorat( $image, (int) floor( imagesx( $image ) / 2 ), (int) floor( imagesy( $image ) / 2 ) ) ); + + $this->assertSame( 127, $corner_color['alpha'] ); + $this->assertLessThan( 127, $center_color['alpha'] ); + } + + /** + * @ticket 44405 + * @requires function imagejpeg + */ + public function test_edit_image_mask_modifier_calls_mask_after_previous_modifiers() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->setup_mock_editor( 'WP_Image_Editor_Mock_With_Mask' ); + WP_Image_Editor_Mock::$size_return = array( + 'width' => 640, + 'height' => 480, + ); + WP_Image_Editor_Mock::$edit_return['mask'] = new WP_Error(); + + $params = array( + 'modifiers' => array( + array( + 'type' => 'rotate', + 'args' => array( + 'angle' => 60, + ), + ), + array( + 'type' => 'crop', + 'args' => array( + 'left' => 10, + 'top' => 20, + 'width' => 30, + 'height' => 40, + ), + ), + array( + 'type' => 'mask', + 'args' => array( + 'shape' => 'circle', + ), + ), + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_image_mask_failed', $response, 500 ); + $this->assertSame( array( -60 ), WP_Image_Editor_Mock::$spy['rotate'][0] ); + $this->assertSame( array( 64, 96, 192, 192 ), WP_Image_Editor_Mock::$spy['crop'][0] ); + $this->assertSame( array( array( 'shape' => 'circle' ) ), WP_Image_Editor_Mock::$spy['mask'][0] ); + $this->assertArrayNotHasKey( 'save', WP_Image_Editor_Mock::$spy ); + } + + /** + * @ticket 44405 + * @requires function imagejpeg + */ + public function test_edit_image_mask_returns_error_when_editor_inherits_default_mask() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->setup_mock_editor(); + + $params = array( + 'modifiers' => array( + array( + 'type' => 'mask', + 'args' => array( + 'shape' => 'circle', + ), + ), + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_image_mask_unsupported', $response, 500 ); + } + + /** + * @ticket 44405 + * @requires function imagejpeg + */ + public function test_edit_image_mask_rejects_unsupported_shape() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $params = array( + 'modifiers' => array( + array( + 'type' => 'mask', + 'args' => array( + 'shape' => 'square', + ), + ), + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + /** * @ticket 50565 * @requires function imagejpeg @@ -3152,14 +3321,14 @@ public function test_edit_image_returns_error_if_mismatched_src() { * * @since 5.5.0 */ - protected function setup_mock_editor() { + protected function setup_mock_editor( $editor_class = 'WP_Image_Editor_Mock' ) { require_once ABSPATH . WPINC . '/class-wp-image-editor.php'; require_once DIR_TESTDATA . '/../includes/mock-image-editor.php'; add_filter( 'wp_image_editors', - static function () { - return array( 'WP_Image_Editor_Mock' ); + static function () use ( $editor_class ) { + return array( $editor_class ); } ); } From 74f0557c8a12b47e8abd9dd48d8bc92b86e73baa Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 26 Jun 2026 17:28:12 +1000 Subject: [PATCH 02/12] Fix image mask PHPCS formatting --- .../endpoints/class-wp-rest-attachments-controller.php | 4 ++-- .../tests/rest-api/rest-attachments-controller.php | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index f99aedc385726..a3e8f55f395cc 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -2341,8 +2341,8 @@ protected function get_edit_media_item_args() { ), ), ), - ), - 'rotation' => array( + ), + 'rotation' => array( 'description' => __( 'The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.' ), 'type' => 'integer', 'minimum' => 0, diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index d4eead0184bf8..ab1a5390f45d6 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3119,7 +3119,15 @@ public function test_batch_edit_image() { * @requires function imagecreatefrompng */ public function test_edit_image_mask() { - if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/jpeg', 'output_mime_type' => 'image/png', 'methods' => array( 'mask' ) ) ) ) { + if ( + ! wp_image_editor_supports( + array( + 'mime_type' => 'image/jpeg', + 'output_mime_type' => 'image/png', + 'methods' => array( 'mask' ), + ) + ) + ) { $this->markTestSkipped( 'This test requires an image editor with mask support.' ); } From 81078278b0b4f23b802bb7c37ff4b1f6bac7433b Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 26 Jun 2026 17:44:05 +1000 Subject: [PATCH 03/12] Use optional image editor mask method --- src/wp-includes/class-wp-image-editor-gd.php | 9 ++-- .../class-wp-image-editor-imagick.php | 9 ++-- src/wp-includes/class-wp-image-editor.php | 49 ------------------- src/wp-includes/media.php | 12 ----- tests/phpunit/tests/image/editor.php | 8 +-- .../rest-api/rest-attachments-controller.php | 2 +- 6 files changed, 17 insertions(+), 72 deletions(-) diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index c36191d4448ba..6b4fcfe1aaa24 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -494,9 +494,12 @@ public function flip( $horz, $vert ) { * @return true|WP_Error True on success, WP_Error object on failure. */ public function mask( $args ) { - $args = $this->validate_mask_args( $args ); - if ( is_wp_error( $args ) ) { - return $args; + if ( ! is_array( $args ) || 'circle' !== ( $args['shape'] ?? null ) ) { + return new WP_Error( + 'image_mask_unsupported', + __( 'Unsupported image mask.' ), + $this->file + ); } if ( function_exists( 'imagepalettetotruecolor' ) ) { diff --git a/src/wp-includes/class-wp-image-editor-imagick.php b/src/wp-includes/class-wp-image-editor-imagick.php index 2112a28a5a786..43855a4815f8e 100644 --- a/src/wp-includes/class-wp-image-editor-imagick.php +++ b/src/wp-includes/class-wp-image-editor-imagick.php @@ -855,9 +855,12 @@ public function flip( $horz, $vert ) { * @return true|WP_Error True on success, WP_Error object on failure. */ public function mask( $args ) { - $args = $this->validate_mask_args( $args ); - if ( is_wp_error( $args ) ) { - return $args; + if ( ! is_array( $args ) || 'circle' !== ( $args['shape'] ?? null ) ) { + return new WP_Error( + 'image_mask_unsupported', + __( 'Unsupported image mask.' ), + $this->file + ); } try { diff --git a/src/wp-includes/class-wp-image-editor.php b/src/wp-includes/class-wp-image-editor.php index 90404d253ab5b..8401117836ebc 100644 --- a/src/wp-includes/class-wp-image-editor.php +++ b/src/wp-includes/class-wp-image-editor.php @@ -170,55 +170,6 @@ abstract public function rotate( $angle ); */ abstract public function flip( $horz, $vert ); - /** - * Applies a mask to the current image. - * - * Implementations that support image masks should override this method. - * - * @since 7.1.0 - * - * @param array $args { - * Mask arguments. - * - * @type string $shape Mask shape. Accepts 'circle'. - * } - * @return true|WP_Error True on success, WP_Error object on failure. - */ - public function mask( $args ) { - $args = $this->validate_mask_args( $args ); - if ( is_wp_error( $args ) ) { - return $args; - } - - return new WP_Error( - 'image_mask_unsupported', - __( 'Unsupported image mask.' ), - $this->file - ); - } - - /** - * Validates and normalizes image mask arguments. - * - * @since 7.1.0 - * - * @param array $args Mask arguments. - * @return array|WP_Error Normalized mask arguments on success, WP_Error object on failure. - */ - protected function validate_mask_args( $args ) { - if ( ! is_array( $args ) || 'circle' !== ( $args['shape'] ?? null ) ) { - return new WP_Error( - 'image_mask_unsupported', - __( 'Unsupported image mask.' ), - $this->file - ); - } - - return array( - 'shape' => 'circle', - ); - } - /** * Streams current image to browser. * diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 328875bc6c727..704fa5115557d 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4466,18 +4466,6 @@ function _wp_image_editor_choose( $args = array() ) { continue; } - if ( isset( $args['methods'] ) && in_array( 'mask', $args['methods'], true ) ) { - try { - $mask_method = new ReflectionMethod( $implementation, 'mask' ); - } catch ( ReflectionException $e ) { - continue; - } - - if ( WP_Image_Editor::class === $mask_method->getDeclaringClass()->getName() ) { - continue; - } - } - // Implementation should ideally support the output mime type as well if set and different than the passed type. if ( isset( $args['mime_type'] ) && diff --git a/tests/phpunit/tests/image/editor.php b/tests/phpunit/tests/image/editor.php index aadad31e6f510..4336d1086673b 100644 --- a/tests/phpunit/tests/image/editor.php +++ b/tests/phpunit/tests/image/editor.php @@ -50,11 +50,11 @@ public function test_get_editor_load_returns_false() { } /** - * Tests that implementations inheriting the default mask method are not selected for mask requests. + * Tests that implementations without a mask method are not selected for mask requests. * * @ticket 44405 */ - public function test_get_editor_does_not_select_implementation_inheriting_default_mask() { + public function test_get_editor_does_not_select_implementation_without_mask_method() { $editor = wp_get_image_editor( DIR_TESTDATA . '/images/canola.jpg', array( 'methods' => array( 'mask' ) ) ); $this->assertWPError( $editor ); @@ -62,11 +62,11 @@ public function test_get_editor_does_not_select_implementation_inheriting_defaul } /** - * Tests that implementations overriding the default mask method are selected for mask requests. + * Tests that implementations with a mask method are selected for mask requests. * * @ticket 44405 */ - public function test_get_editor_selects_implementation_overriding_mask() { + public function test_get_editor_selects_implementation_with_mask_method() { add_filter( 'wp_image_editors', static function () { diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index ab1a5390f45d6..5e207f22590ce 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3236,7 +3236,7 @@ public function test_edit_image_mask_modifier_calls_mask_after_previous_modifier * @ticket 44405 * @requires function imagejpeg */ - public function test_edit_image_mask_returns_error_when_editor_inherits_default_mask() { + public function test_edit_image_mask_returns_error_when_editor_does_not_support_mask() { wp_set_current_user( self::$superadmin_id ); $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); From 4d2b3efcaf0c9a6cab06f6be928795029715a791 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 26 Jun 2026 17:47:58 +1000 Subject: [PATCH 04/12] Clarify explicit image editor output type --- src/wp-includes/media.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 704fa5115557d..20e05eb4d92e3 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4367,7 +4367,7 @@ function wp_get_image_editor( $path, $args = array() ) { } } - // Check and set the output mime type mapped to the input type. + // Check and set the output mime type mapped to the input type unless a specific output mime type was requested. if ( isset( $args['mime_type'] ) && ! isset( $args['output_mime_type'] ) ) { $output_format = wp_get_image_editor_output_format( $path, $args['mime_type'] ); if ( isset( $output_format[ $args['mime_type'] ] ) ) { From 6267cc30e6f17850d0f0f20f5e086a6eae7b7f97 Mon Sep 17 00:00:00 2001 From: Ramon Date: Fri, 26 Jun 2026 18:08:41 +1000 Subject: [PATCH 05/12] Address image mask review feedback --- src/wp-includes/class-wp-image-editor-gd.php | 26 ++++++++++----- .../class-wp-rest-attachments-controller.php | 2 +- tests/phpunit/tests/image/editorGd.php | 4 +-- .../rest-api/rest-attachments-controller.php | 33 ++++++++++++++++++- 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 6b4fcfe1aaa24..5c848227bfa02 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -56,9 +56,9 @@ public static function test( $args = array() ) { self::supports_mime_type( 'image/png' ) && function_exists( 'imagealphablending' ) && function_exists( 'imagecolorallocatealpha' ) && + function_exists( 'imagefilledrectangle' ) && function_exists( 'imagepng' ) && - function_exists( 'imagesavealpha' ) && - function_exists( 'imagesetpixel' ) + function_exists( 'imagesavealpha' ) ); } @@ -518,13 +518,23 @@ public function mask( $args ) { $radius_squared = $radius * $radius; for ( $y = 0; $y < $height; $y++ ) { - for ( $x = 0; $x < $width; $x++ ) { - $dx = $x - $center_x; - $dy = $y - $center_y; + $dy = $y - $center_y; - if ( ( $dx * $dx ) + ( $dy * $dy ) > $radius_squared ) { - imagesetpixel( $this->image, $x, $y, $transparent ); - } + if ( ( $dy * $dy ) > $radius_squared ) { + imagefilledrectangle( $this->image, 0, $y, $width - 1, $y, $transparent ); + continue; + } + + $span = sqrt( $radius_squared - ( $dy * $dy ) ); + $left = max( 0, (int) ceil( $center_x - $span ) ); + $right = min( $width - 1, (int) floor( $center_x + $span ) ); + + if ( $left > 0 ) { + imagefilledrectangle( $this->image, 0, $y, $left - 1, $y, $transparent ); + } + + if ( $right < $width - 1 ) { + imagefilledrectangle( $this->image, $right + 1, $y, $width - 1, $y, $transparent ); } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index a3e8f55f395cc..bfbcd6c40543b 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -1074,7 +1074,7 @@ public function edit_media_item( $request ) { $image_editor = wp_get_image_editor( $image_file_to_edit, $image_editor_args ); if ( is_wp_error( $image_editor ) ) { - if ( $has_mask_modifier ) { + if ( $has_mask_modifier && 'image_no_editor' === $image_editor->get_error_code() ) { return new WP_Error( 'rest_image_mask_unsupported', __( 'Unable to mask this image.' ), diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index a67c334f43b72..63aa9558e0962 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -56,9 +56,9 @@ public function test_supports_mask() { ( imagetypes() & IMG_PNG ) !== 0 && function_exists( 'imagealphablending' ) && function_exists( 'imagecolorallocatealpha' ) && + function_exists( 'imagefilledrectangle' ) && function_exists( 'imagepng' ) && - function_exists( 'imagesavealpha' ) && - function_exists( 'imagesetpixel' ) + function_exists( 'imagesavealpha' ) ); $this->assertSame( $expected, WP_Image_Editor_GD::test( array( 'methods' => array( 'mask' ) ) ) ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 5e207f22590ce..443b48a4b6f12 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3226,7 +3226,7 @@ public function test_edit_image_mask_modifier_calls_mask_after_previous_modifier $response = rest_do_request( $request ); $this->assertErrorResponse( 'rest_image_mask_failed', $response, 500 ); - $this->assertSame( array( -60 ), WP_Image_Editor_Mock::$spy['rotate'][0] ); + $this->assertSame( array( -60.0 ), WP_Image_Editor_Mock::$spy['rotate'][0] ); $this->assertSame( array( 64, 96, 192, 192 ), WP_Image_Editor_Mock::$spy['crop'][0] ); $this->assertSame( array( array( 'shape' => 'circle' ) ), WP_Image_Editor_Mock::$spy['mask'][0] ); $this->assertArrayNotHasKey( 'save', WP_Image_Editor_Mock::$spy ); @@ -3261,6 +3261,37 @@ public function test_edit_image_mask_returns_error_when_editor_does_not_support_ $this->assertErrorResponse( 'rest_image_mask_unsupported', $response, 500 ); } + /** + * @ticket 44405 + * @requires function imagejpeg + */ + public function test_edit_image_mask_preserves_image_load_errors() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->setup_mock_editor( 'WP_Image_Editor_Mock_With_Mask' ); + WP_Image_Editor_Mock::$load_return = new WP_Error( 'image_load_error' ); + + $params = array( + 'modifiers' => array( + array( + 'type' => 'mask', + 'args' => array( + 'shape' => 'circle', + ), + ), + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_unknown_image_file_type', $response, 500 ); + $this->assertArrayNotHasKey( 'mask', WP_Image_Editor_Mock::$spy ); + } + /** * @ticket 44405 * @requires function imagejpeg From b1f748f88eb0c66c2e8883a4cd8b4260a36e12ad Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 15:56:21 +1000 Subject: [PATCH 06/12] Media: negotiate alpha-capable output format for masked image edits Make the image editor mask() operation format-agnostic and move output format selection into the attachments REST edit flow, so masked crops can honor a site's alpha-capable image_editor_output_format (WebP/AVIF) instead of always forcing PNG. - WP_Image_Editor_GD/Imagick::mask() no longer force the mime type to PNG; the caller now saves in an alpha-capable format. Drop the now-unused setImageFormat capability check from Imagick::test() and its test mirror. - edit_media_item() negotiates the output format: honor the site's image_editor_output_format when it can hold an alpha channel and the editor supports it, otherwise fall back to PNG. Derive the extension from the negotiated mime type and suppress the output-format filter during the save so it cannot be remapped back to an opaque format. - Apply mask modifiers after all geometry modifiers (flip/rotate/crop) regardless of request position, and document that contract in the schema. - Add tests covering format negotiation, the opaque-mapping fallback, and mask reordering. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/class-wp-image-editor-gd.php | 7 +- .../class-wp-image-editor-imagick.php | 10 +- .../class-wp-rest-attachments-controller.php | 75 +++++++-- tests/phpunit/tests/image/editorImagick.php | 3 +- .../rest-api/rest-attachments-controller.php | 159 ++++++++++++++++++ 5 files changed, 233 insertions(+), 21 deletions(-) diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 5c848227bfa02..a4e5c0bd521b3 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -484,6 +484,11 @@ public function flip( $horz, $vert ) { /** * Applies a mask to the current image. * + * The mask introduces transparency, so the caller must save the result in an + * alpha-capable format (PNG, WebP, or AVIF). This method mutates the pixels + * only and leaves the output format to the caller, like the other editor + * transforms. + * * @since 7.1.0 * * @param array $args { @@ -538,8 +543,6 @@ public function mask( $args ) { } } - $this->mime_type = 'image/png'; - return true; } diff --git a/src/wp-includes/class-wp-image-editor-imagick.php b/src/wp-includes/class-wp-image-editor-imagick.php index 43855a4815f8e..6be7beadee454 100644 --- a/src/wp-includes/class-wp-image-editor-imagick.php +++ b/src/wp-includes/class-wp-image-editor-imagick.php @@ -95,8 +95,7 @@ class_exists( 'ImagickDraw', false ) && method_exists( 'Imagick', 'drawImage' ) && method_exists( 'Imagick', 'getImageGeometry' ) && method_exists( 'Imagick', 'newImage' ) && - method_exists( 'Imagick', 'setImageAlphaChannel' ) && - method_exists( 'Imagick', 'setImageFormat' ) + method_exists( 'Imagick', 'setImageAlphaChannel' ) ); } @@ -845,6 +844,11 @@ public function flip( $horz, $vert ) { /** * Applies a mask to the current image. * + * The mask introduces transparency, so the caller must save the result in an + * alpha-capable format (PNG, WebP, or AVIF). This method mutates the pixels + * only and leaves the output format to the caller, like the other editor + * transforms. + * * @since 7.1.0 * * @param array $args { @@ -890,8 +894,6 @@ public function mask( $args ) { $mask->drawImage( $draw ); $this->image->compositeImage( $mask, Imagick::COMPOSITE_DSTIN, 0, 0 ); - $this->image->setImageFormat( 'PNG' ); - $this->mime_type = 'image/png'; $mask->clear(); $mask->destroy(); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index bfbcd6c40543b..76e90e0aae625 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -1044,14 +1044,28 @@ public function edit_media_item( $request ) { } } - $has_mask_modifier = false; + $has_mask_modifier = false; + $geometry_modifiers = array(); + $mask_modifiers = array(); foreach ( $modifiers as $modifier ) { if ( 'mask' === ( $modifier['type'] ?? null ) ) { $has_mask_modifier = true; - break; + $mask_modifiers[] = $modifier; + } else { + $geometry_modifiers[] = $modifier; } } + /* + * A mask operates on the final pixels, so it must run after any geometry + * modifiers (flip, rotate, crop). Reorder so masks are applied last + * regardless of their position in the request, matching the schema's + * documented contract. + */ + if ( $has_mask_modifier ) { + $modifiers = array_merge( $geometry_modifiers, $mask_modifiers ); + } + /* * If the file doesn't exist, attempt a URL fopen on the src link. * This can occur with certain file replication plugins. @@ -1179,9 +1193,43 @@ public function edit_media_item( $request ) { $image_name .= '-edited'; } - $output_mime_type = $has_mask_modifier ? 'image/png' : null; - $output_ext = $has_mask_modifier ? 'png' : $image_ext; - $filename = "{$image_name}.{$output_ext}"; + if ( $has_mask_modifier ) { + /* + * Masking introduces transparency, so the saved image must use an + * alpha-capable format. Honor the site's `image_editor_output_format` + * choice when it can hold an alpha channel (e.g. WebP or AVIF), and + * fall back to PNG otherwise (e.g. for JPEG, GIF, or the default + * HEIC/HEIF -> JPEG mapping). The chosen format is forced for the save + * below so a site filter cannot remap it back to an opaque format. + */ + $alpha_capable_mime_types = array( 'image/png', 'image/webp', 'image/avif' ); + $output_format = wp_get_image_editor_output_format( $image_file, $mime_type ); + $output_mime_type = $output_format[ $mime_type ] ?? $mime_type; + + // Honor the negotiated format only when it can hold an alpha channel; + // otherwise fall back to PNG so the mask's transparency survives. + if ( ! in_array( $output_mime_type, $alpha_capable_mime_types, true ) ) { + $output_mime_type = 'image/png'; + } + + /* + * The chosen format must be writable by this editor. A mask-capable + * editor always supports PNG (guaranteed by its `test()` capability + * check), so PNG stays a safe fallback when a site negotiated an + * alpha-capable format the editor cannot output (e.g. WebP on a build + * without WebP support). + */ + if ( ! $image_editor->supports_mime_type( $output_mime_type ) ) { + $output_mime_type = 'image/png'; + } + + $output_ext = wp_get_default_extension_for_mime_type( $output_mime_type ); + } else { + $output_mime_type = null; + $output_ext = $image_ext; + } + + $filename = "{$image_name}.{$output_ext}"; // Create the uploads subdirectory if needed. $uploads = wp_upload_dir(); @@ -1189,19 +1237,20 @@ public function edit_media_item( $request ) { // Make the file name unique in the (new) upload directory. $filename = wp_unique_filename( $uploads['path'], $filename ); - // Save to disk. + /* + * Save to disk. For masked images, suppress the output-format filter + * during the save so a site filter cannot remap the negotiated + * alpha-capable format back to an opaque one, which would discard the + * mask's transparency. + */ if ( $has_mask_modifier ) { - $disable_png_output_mapping = static function ( $output_format ) { - unset( $output_format['image/png'] ); - return $output_format; - }; - add_filter( 'image_editor_output_format', $disable_png_output_mapping, PHP_INT_MAX ); + add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); } $saved = $image_editor->save( $uploads['path'] . "/$filename", $output_mime_type ); if ( $has_mask_modifier ) { - remove_filter( 'image_editor_output_format', $disable_png_output_mapping, PHP_INT_MAX ); + remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); } if ( is_wp_error( $saved ) ) { @@ -2319,7 +2368,7 @@ protected function get_edit_media_item_args() { 'type' => 'object', 'properties' => array( 'type' => array( - 'description' => __( 'Mask type.' ), + 'description' => __( 'Mask type. Masks are applied after flip, rotate, and crop modifiers, regardless of position.' ), 'type' => 'string', 'enum' => array( 'mask' ), ), diff --git a/tests/phpunit/tests/image/editorImagick.php b/tests/phpunit/tests/image/editorImagick.php index 4a14f921e6dd9..5fd7603a8509c 100644 --- a/tests/phpunit/tests/image/editorImagick.php +++ b/tests/phpunit/tests/image/editorImagick.php @@ -56,8 +56,7 @@ class_exists( 'ImagickDraw', false ) && method_exists( 'Imagick', 'drawImage' ) && method_exists( 'Imagick', 'getImageGeometry' ) && method_exists( 'Imagick', 'newImage' ) && - method_exists( 'Imagick', 'setImageAlphaChannel' ) && - method_exists( 'Imagick', 'setImageFormat' ) + method_exists( 'Imagick', 'setImageAlphaChannel' ) ); $this->assertSame( $expected, WP_Image_Editor_Imagick::test( array( 'methods' => array( 'mask' ) ) ) ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 443b48a4b6f12..784edf0d70622 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -3319,6 +3319,165 @@ public function test_edit_image_mask_rejects_unsupported_shape() { $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); } + /** + * When the site's output-format filter targets an alpha-capable format + * (e.g. WebP), a masked edit should honor it rather than forcing PNG. + * + * @ticket 44405 + * @requires function imagejpeg + */ + public function test_edit_image_mask_honors_alpha_capable_output_format() { + $editor = wp_get_image_editor( + self::$test_file, + array( + 'methods' => array( 'mask' ), + 'mime_type' => 'image/jpeg', + 'output_mime_type' => 'image/png', + ) + ); + + if ( is_wp_error( $editor ) || ! $editor->supports_mime_type( 'image/webp' ) ) { + $this->markTestSkipped( 'This test requires a mask-capable image editor that can output WebP.' ); + } + + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $to_webp = static function () { + return array( 'image/jpeg' => 'image/webp' ); + }; + add_filter( 'image_editor_output_format', $to_webp ); + + $params = array( + 'modifiers' => array( + array( + 'type' => 'mask', + 'args' => array( + 'shape' => 'circle', + ), + ), + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + + remove_filter( 'image_editor_output_format', $to_webp ); + + $item = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'image/webp', $item['mime_type'] ); + $this->assertStringEndsWith( '-edited.webp', $item['media_details']['file'] ); + } + + /** + * A site filter that rewrites an alpha-capable format to an opaque one + * (e.g. PNG -> JPEG) must not strip the mask's transparency. Negotiation + * falls back to an alpha-capable format so the saved image keeps its + * transparency. + * + * @ticket 44405 + * @requires function imagecreatefrompng + */ + public function test_edit_image_mask_forces_alpha_output_when_site_maps_to_opaque() { + if ( + ! wp_image_editor_supports( + array( + 'mime_type' => 'image/png', + 'output_mime_type' => 'image/png', + 'methods' => array( 'mask' ), + ) + ) + ) { + $this->markTestSkipped( 'This test requires an image editor with mask support.' ); + } + + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/one-blue-pixel-100x100.png' ); + + $to_jpeg = static function () { + return array( 'image/png' => 'image/jpeg' ); + }; + add_filter( 'image_editor_output_format', $to_jpeg ); + + $params = array( + 'modifiers' => array( + array( + 'type' => 'mask', + 'args' => array( + 'shape' => 'circle', + ), + ), + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + + remove_filter( 'image_editor_output_format', $to_jpeg ); + + $item = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( 'image/png', $item['mime_type'] ); + } + + /** + * A mask must be applied after geometry modifiers (flip, rotate, crop), + * regardless of its position in the request. The mask is listed before the + * crop here; if it were applied in request order it would error before the + * crop ran, and the crop would never be recorded. + * + * @ticket 44405 + * @requires function imagejpeg + */ + public function test_edit_image_mask_is_applied_after_geometry_modifiers() { + wp_set_current_user( self::$superadmin_id ); + $attachment = self::factory()->attachment->create_upload_object( self::$test_file ); + + $this->setup_mock_editor( 'WP_Image_Editor_Mock_With_Mask' ); + WP_Image_Editor_Mock::$size_return = array( + 'width' => 640, + 'height' => 480, + ); + WP_Image_Editor_Mock::$edit_return['mask'] = new WP_Error(); + + $params = array( + 'modifiers' => array( + array( + 'type' => 'mask', + 'args' => array( + 'shape' => 'circle', + ), + ), + array( + 'type' => 'crop', + 'args' => array( + 'left' => 10, + 'top' => 20, + 'width' => 30, + 'height' => 40, + ), + ), + ), + 'src' => wp_get_attachment_image_url( $attachment, 'full' ), + ); + + $request = new WP_REST_Request( 'POST', "/wp/v2/media/{$attachment}/edit" ); + $request->set_body_params( $params ); + $response = rest_do_request( $request ); + + $this->assertErrorResponse( 'rest_image_mask_failed', $response, 500 ); + $this->assertArrayHasKey( 'crop', WP_Image_Editor_Mock::$spy, 'The crop should be applied before the mask.' ); + $this->assertSame( array( 64, 96, 192, 192 ), WP_Image_Editor_Mock::$spy['crop'][0] ); + $this->assertSame( array( array( 'shape' => 'circle' ) ), WP_Image_Editor_Mock::$spy['mask'][0] ); + } + /** * @ticket 50565 * @requires function imagejpeg From d57c6e0a326d04314710e0977cfc026589e87c48 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 16:14:22 +1000 Subject: [PATCH 07/12] Tests: avoid leaving a temp file behind in image mask tests The GD and Imagick `test_mask_circle` tests appended `.png` to the path returned by tempnam(), which writes to a different file and leaves the original extension-less temp file behind. Remove that file and reuse the unique path with a `.png` extension, which the editors and Imagick's reader need to infer the format. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/phpunit/tests/image/editorGd.php | 6 +++++- tests/phpunit/tests/image/editorImagick.php | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/image/editorGd.php b/tests/phpunit/tests/image/editorGd.php index 63aa9558e0962..92720806aa93a 100644 --- a/tests/phpunit/tests/image/editorGd.php +++ b/tests/phpunit/tests/image/editorGd.php @@ -82,7 +82,11 @@ public function test_mask_circle() { $this->assertTrue( $result ); - $save_to_file = tempnam( get_temp_dir(), '' ) . '.png'; + // Reuse a unique temp path with a `.png` extension without leaving the + // extension-less file created by tempnam() behind. + $save_to_file = tempnam( get_temp_dir(), '' ); + unlink( $save_to_file ); + $save_to_file .= '.png'; $gd_image_editor->save( $save_to_file, 'image/png' ); $this->assertImageAlphaAtPointGD( $save_to_file, array( 0, 0 ), 127 ); diff --git a/tests/phpunit/tests/image/editorImagick.php b/tests/phpunit/tests/image/editorImagick.php index 5fd7603a8509c..6fe8d55dce40e 100644 --- a/tests/phpunit/tests/image/editorImagick.php +++ b/tests/phpunit/tests/image/editorImagick.php @@ -76,7 +76,11 @@ public function test_mask_circle() { $this->assertTrue( $result ); - $save_to_file = tempnam( get_temp_dir(), '' ) . '.png'; + // Reuse a unique temp path with a `.png` extension without leaving the + // extension-less file created by tempnam() behind. + $save_to_file = tempnam( get_temp_dir(), '' ); + unlink( $save_to_file ); + $save_to_file .= '.png'; $imagick_image_editor->save( $save_to_file, 'image/png' ); $image = new Imagick( $save_to_file ); From d13c7256c8fb28c44a3c0c83762713061eb0eacc Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 16:22:23 +1000 Subject: [PATCH 08/12] Media: harden mask output-format suppression priority Register the save-time `image_editor_output_format` suppression at PHP_INT_MAX instead of 100 so a later site filter on the same hook cannot override it and remap the negotiated alpha-capable format back to an opaque one during save. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../endpoints/class-wp-rest-attachments-controller.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index 76e90e0aae625..b5a0a5f7d1aaa 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -1244,13 +1244,13 @@ public function edit_media_item( $request ) { * mask's transparency. */ if ( $has_mask_modifier ) { - add_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + add_filter( 'image_editor_output_format', '__return_empty_array', PHP_INT_MAX ); } $saved = $image_editor->save( $uploads['path'] . "/$filename", $output_mime_type ); if ( $has_mask_modifier ) { - remove_filter( 'image_editor_output_format', '__return_empty_array', 100 ); + remove_filter( 'image_editor_output_format', '__return_empty_array', PHP_INT_MAX ); } if ( is_wp_error( $saved ) ) { From 6c34f4048d809498c93d897f28ba9fd537308463 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 16:29:00 +1000 Subject: [PATCH 09/12] REST API: Regenerate client fixtures for the mask edit modifier Add the `mask` modifier to the media edit route schema in the generated QUnit API client fixture (wp-api-generated.js) so it reflects the new modifier. Only the mask entry is added; unrelated fixture drift is excluded. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/qunit/fixtures/wp-api-generated.js | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 1be56017826ff..c45b1b9419227 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -3555,6 +3555,35 @@ mockedApiResponse.Schema = { } } } + }, + { + "title": "Mask", + "type": "object", + "properties": { + "type": { + "description": "Mask type. Masks are applied after flip, rotate, and crop modifiers, regardless of position.", + "type": "string", + "enum": [ + "mask" + ] + }, + "args": { + "description": "Mask arguments.", + "type": "object", + "required": [ + "shape" + ], + "properties": { + "shape": { + "description": "Mask shape.", + "type": "string", + "enum": [ + "circle" + ] + } + } + } + } } ] }, From 15f7b3f0f9d5259e4102356f991c43edd9673bfe Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 16:39:25 +1000 Subject: [PATCH 10/12] Docs: target 7.2.0 for image mask support and annotate affected methods The mask feature is slated for 7.2.0 rather than 7.1.0. Bump the @since tags on the new WP_Image_Editor_GD::mask() and WP_Image_Editor_Imagick::mask() methods, and add @since 7.2.0 change notes to the methods modified to support masking: WP_REST_Attachments_Controller::edit_media_item(), get_edit_media_item_args(), the GD/Imagick test() capability checks, and wp_get_image_editor() (which now honors a caller-specified output_mime_type). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/class-wp-image-editor-gd.php | 3 ++- src/wp-includes/class-wp-image-editor-imagick.php | 3 ++- src/wp-includes/media.php | 1 + .../endpoints/class-wp-rest-attachments-controller.php | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index a4e5c0bd521b3..682be8971c54b 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -34,6 +34,7 @@ public function __destruct() { * Checks to see if current environment supports GD. * * @since 3.5.0 + * @since 7.2.0 Added support for testing the 'mask' method capability via the 'methods' argument. * * @param array $args * @return bool @@ -489,7 +490,7 @@ public function flip( $horz, $vert ) { * only and leaves the output format to the caller, like the other editor * transforms. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $args { * Mask arguments. diff --git a/src/wp-includes/class-wp-image-editor-imagick.php b/src/wp-includes/class-wp-image-editor-imagick.php index 6be7beadee454..b377ba0043a9b 100644 --- a/src/wp-includes/class-wp-image-editor-imagick.php +++ b/src/wp-includes/class-wp-image-editor-imagick.php @@ -36,6 +36,7 @@ public function __destruct() { * method can be called statically. * * @since 3.5.0 + * @since 7.2.0 Added support for testing the 'mask' method capability via the 'methods' argument. * * @param array $args * @return bool @@ -849,7 +850,7 @@ public function flip( $horz, $vert ) { * only and leaves the output format to the caller, like the other editor * transforms. * - * @since 7.1.0 + * @since 7.2.0 * * @param array $args { * Mask arguments. diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 20e05eb4d92e3..a23a9413efda8 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4344,6 +4344,7 @@ function wp_max_upload_size() { * Returns a WP_Image_Editor instance and loads file into it. * * @since 3.5.0 + * @since 7.2.0 A specified 'output_mime_type' argument is no longer overridden by the image_editor_output_format mapping. * * @param string $path Path to the file to load. * @param array $args Optional. Additional arguments for retrieving the image editor. diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php index b5a0a5f7d1aaa..c760e589e0090 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php @@ -959,6 +959,7 @@ public function edit_media_item_permissions_check( $request ) { * * @since 5.5.0 * @since 6.9.0 Adds flips capability and editable fields for the newly-created attachment post. + * @since 7.2.0 Adds support for the mask modifier. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, WP_Error object on failure. @@ -2241,6 +2242,7 @@ protected function check_upload_size( $file ) { * * @since 5.5.0 * @since 6.9.0 Adds flips capability and editable fields for the newly-created attachment post. + * @since 7.2.0 Adds the mask modifier. * * @return array */ From 9266c0e9f640bd85965a44b30468895b7062af99 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 16:41:04 +1000 Subject: [PATCH 11/12] Apply suggestion from @ramonjd --- src/wp-includes/class-wp-image-editor-gd.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 682be8971c54b..75a890ff560b0 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -523,6 +523,16 @@ public function mask( $args ) { $radius = min( $width, $height ) / 2; $radius_squared = $radius * $radius; + /* + * Clear the pixels outside the inscribed circle one scanline at a time. + * For each row the circle spans the horizontal range + * [center_x - half, center_x + half]; everything outside that span is + * filled transparent with at most two rectangle fills. This keeps the + * cost at O(height) rectangle fills rather than O(width * height) + * per-pixel writes. `ceil()`/`floor()` preserve the same boundary as the + * `dx^2 + dy^2 <= radius^2` test, so a pixel exactly on the radius stays + * opaque and no seam appears at the rim. + */ for ( $y = 0; $y < $height; $y++ ) { $dy = $y - $center_y; From 8c4ddd734f84b3e8b4138844809388507d45c193 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 15 Jul 2026 16:54:17 +1000 Subject: [PATCH 12/12] Docs: match the GD mask comment to the $span variable name The scanline comment referred to the horizontal reach as `half`, but the code names that value `$span`. Align the comment to avoid a doc/code mismatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-includes/class-wp-image-editor-gd.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 75a890ff560b0..ea81b95b66a77 100644 --- a/src/wp-includes/class-wp-image-editor-gd.php +++ b/src/wp-includes/class-wp-image-editor-gd.php @@ -526,7 +526,7 @@ public function mask( $args ) { /* * Clear the pixels outside the inscribed circle one scanline at a time. * For each row the circle spans the horizontal range - * [center_x - half, center_x + half]; everything outside that span is + * [center_x - span, center_x + span]; everything outside that span is * filled transparent with at most two rectangle fills. This keeps the * cost at O(height) rectangle fills rather than O(width * height) * per-pixel writes. `ceil()`/`floor()` preserve the same boundary as the