Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/wp-includes/class-wp-image-editor-gd.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.
*
Expand Down
76 changes: 76 additions & 0 deletions src/wp-includes/class-wp-image-editor-imagick.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 ) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's my thinking:

WP_Image_Editor does not need to add mask() if we treat masking as an optional editor capability.

Adding an abstract mask() there would force every third-party image editor subclass to implement it.

That would be a hard backwards compat break, no?

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.
*
Expand Down
5 changes: 2 additions & 3 deletions src/wp-includes/media.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ];
Expand Down Expand Up @@ -6674,4 +6674,3 @@ function wp_add_crossorigin_attributes( string $html ): string {

return $processor->get_updated_html();
}

Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 )
);
}
Comment on lines 813 to +820

return new WP_Error(
'rest_unknown_image_file_type',
__( 'Unable to edit this image.' ),
Expand Down Expand Up @@ -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;

}
}

Expand All @@ -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();
Expand All @@ -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 );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Masked circle crops must save as PNG to preserve transparent corners.

WordPress allows plugins to globally remap image output formats, for example PNG to WebP. E.g.,

$output_format['image/png'] = 'image/webp';

If that mapping runs here, the code could ask to save a .png mask but the editor might convert it to WebP during save(), which would break the strict “masked output is PNG” expectation.

}

$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;
Expand All @@ -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 );
Expand Down Expand Up @@ -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' ),
),
),
),
),
),
),
),
),
Expand Down
13 changes: 13 additions & 0 deletions tests/phpunit/includes/mock-image-editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
31 changes: 31 additions & 0 deletions tests/phpunit/tests/image/editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading
Loading