diff --git a/src/wp-includes/class-wp-image-editor-gd.php b/src/wp-includes/class-wp-image-editor-gd.php index 3d93b5bd8a2c1..5c848227bfa02 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( 'imagefilledrectangle' ) && + function_exists( 'imagepng' ) && + function_exists( 'imagesavealpha' ) + ); + } + return true; } @@ -470,6 +481,68 @@ 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 ) { + if ( ! is_array( $args ) || 'circle' !== ( $args['shape'] ?? null ) ) { + return new WP_Error( + 'image_mask_unsupported', + __( 'Unsupported image mask.' ), + $this->file + ); + } + + 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++ ) { + $dy = $y - $center_y; + + 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 ); + } + } + + $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..43855a4815f8e 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,66 @@ 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 ) { + if ( ! is_array( $args ) || 'circle' !== ( $args['shape'] ?? null ) ) { + return new WP_Error( + 'image_mask_unsupported', + __( 'Unsupported image mask.' ), + $this->file + ); + } + + 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/media.php b/src/wp-includes/media.php index 4657b5872eb18..50cbbb8b4e684 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -4270,8 +4270,8 @@ 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'] ) ) { + // 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'] ] ) ) { $args['output_mime_type'] = $output_format[ $args['mime_type'] ]; @@ -6674,4 +6674,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 21805778ba659..4cc4e0e85fb55 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 @@ -781,6 +781,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. @@ -791,9 +799,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 && 'image_no_editor' === $image_editor->get_error_code() ) { + 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.' ), @@ -859,6 +884,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; + } } @@ -878,7 +916,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(); @@ -887,7 +927,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; @@ -899,7 +951,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 ); @@ -1873,6 +1925,31 @@ 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' ), + ), + ), + ), + ), + ), ), ), ), 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..4336d1086673b 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 without a mask method are not selected for mask requests. + * + * @ticket 44405 + */ + 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 ); + $this->assertSame( 'image_no_editor', $editor->get_error_code() ); + } + + /** + * Tests that implementations with a mask method are selected for mask requests. + * + * @ticket 44405 + */ + public function test_get_editor_selects_implementation_with_mask_method() { + 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..63aa9558e0962 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( 'imagefilledrectangle' ) && + function_exists( 'imagepng' ) && + function_exists( 'imagesavealpha' ) + ); + + $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 79e9d23cf9dd3..52a186dcffd0f 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -188,7 +188,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(); @@ -2840,6 +2843,211 @@ 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.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 ); + } + + /** + * @ticket 44405 + * @requires function imagejpeg + */ + 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 ); + + $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_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 + */ + 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 @@ -2881,14 +3089,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 ); } ); }