diff --git a/src/wp-includes/rest-api/class-wp-rest-server.php b/src/wp-includes/rest-api/class-wp-rest-server.php index 704a990298826..ec66f4a70a525 100644 --- a/src/wp-includes/rest-api/class-wp-rest-server.php +++ b/src/wp-includes/rest-api/class-wp-rest-server.php @@ -1378,22 +1378,6 @@ public function get_index( $request ) { /** This filter is documented in wp-admin/includes/image.php */ $available['image_size_threshold'] = (int) apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ); - - // Image output formats. - $input_formats = array( 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic' ); - $output_formats = array(); - foreach ( $input_formats as $mime_type ) { - /** This filter is documented in wp-includes/media.php */ - $output_formats = apply_filters( 'image_editor_output_format', $output_formats, '', $mime_type ); - } - $available['image_output_formats'] = (object) $output_formats; - - /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */ - $available['jpeg_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/jpeg' ); - /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */ - $available['png_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/png' ); - /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */ - $available['gif_interlaced'] = (bool) apply_filters( 'image_save_progressive', false, 'image/gif' ); } $response = new WP_REST_Response( $available ); 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..4e99c5931dfb0 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 @@ -1042,7 +1042,8 @@ public function prepare_item_for_response( $item, $request ) { $response = parent::prepare_item_for_response( $post, $request ); $fields = $this->get_fields_for_response( $request ); - $data = $response->get_data(); + /** @var array $data */ + $data = $response->get_data(); if ( in_array( 'description', $fields, true ) ) { $data['description'] = array( @@ -1183,6 +1184,36 @@ public function prepare_item_for_response( $item, $request ) { $data['exif_orientation'] = $orientation; } + if ( wp_attachment_is_image( $post ) ) { + $mime_type = get_post_mime_type( $post ); + + // Per-file output format for images, evaluated with the real filename + // and MIME type so plugins filtering image_editor_output_format can + // make per-attachment decisions (e.g. JPEG -> WebP). Resolved the same + // way WP_Image_Editor::set_quality() resolves the output format. + if ( in_array( 'image_output_format', $fields, true ) ) { + $filename = get_attached_file( $post->ID ); + + /** This filter is documented in wp-includes/media.php */ + $output_formats = apply_filters( + 'image_editor_output_format', + array( $mime_type => $mime_type ), + $filename ? $filename : '', + $mime_type + ); + + $output_mime = $output_formats[ $mime_type ] ?? $mime_type; + $data['image_output_format'] = ( $output_mime !== $mime_type ) ? $output_mime : null; + } + + // Per-file progressive/interlaced encoding flag for images, evaluated + // against the attachment's MIME type. + if ( in_array( 'image_save_progressive', $fields, true ) ) { + /** This filter is documented in wp-includes/class-wp-image-editor-gd.php */ + $data['image_save_progressive'] = (bool) apply_filters( 'image_save_progressive', false, $mime_type ); + } + } + $context = ! empty( $request['context'] ) ? $request['context'] : 'view'; $data = $this->filter_response_by_context( $data, $context ); @@ -1373,6 +1404,20 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['image_output_format'] = array( + 'description' => __( 'The output MIME type this image should be converted to, based on the image_editor_output_format filter. Null if no conversion is needed.' ), + 'type' => array( 'string', 'null' ), + 'context' => array( 'edit' ), + 'readonly' => true, + ); + + $schema['properties']['image_save_progressive'] = array( + 'description' => __( 'Whether to use progressive/interlaced encoding when saving this image.' ), + 'type' => 'boolean', + 'context' => array( 'edit' ), + 'readonly' => true, + ); + unset( $schema['properties']['password'] ); $this->schema = $schema; diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index 0ab54a3a0d384..83c83ad6d5720 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -2404,6 +2404,13 @@ protected function get_available_actions( $post, $request ) { * @since 4.7.0 * * @return array Item schema data. + * + * @phpstan-return array{ + * title: non-empty-string, + * type: non-empty-string, + * properties: array>, + * ... + * } */ public function get_item_schema() { if ( $this->schema ) { diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 79e9d23cf9dd3..03249a008ba7d 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -1951,10 +1951,12 @@ public function test_get_item_schema() { $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); $properties = $data['schema']['properties']; - $this->assertCount( 32, $properties ); + $this->assertCount( 34, $properties ); $this->assertArrayHasKey( 'author', $properties ); $this->assertArrayHasKey( 'alt_text', $properties ); $this->assertArrayHasKey( 'exif_orientation', $properties ); + $this->assertArrayHasKey( 'image_output_format', $properties ); + $this->assertArrayHasKey( 'image_save_progressive', $properties ); $this->assertArrayHasKey( 'filename', $properties ); $this->assertArrayHasKey( 'filesize', $properties ); $this->assertArrayHasKey( 'caption', $properties ); @@ -1992,6 +1994,164 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'class_list', $properties ); } + /** + * Tests the image_output_format / image_save_progressive schema properties. + * + * @ticket 65367 + * + * @covers WP_REST_Attachments_Controller::get_item_schema + */ + public function test_image_output_format_and_progressive_schema(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/media' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertArrayHasKey( 'image_output_format', $properties ); + $this->assertSame( array( 'string', 'null' ), $properties['image_output_format']['type'] ); + $this->assertSame( array( 'edit' ), $properties['image_output_format']['context'] ); + $this->assertTrue( $properties['image_output_format']['readonly'] ); + + $this->assertArrayHasKey( 'image_save_progressive', $properties ); + $this->assertSame( 'boolean', $properties['image_save_progressive']['type'] ); + $this->assertSame( array( 'edit' ), $properties['image_save_progressive']['context'] ); + $this->assertTrue( $properties['image_save_progressive']['readonly'] ); + } + + /** + * Verifies image_output_format is null by default (no conversion needed) and + * image_save_progressive defaults to false on a freshly uploaded JPEG. + * + * @ticket 65367 + * + * @covers WP_REST_Attachments_Controller::create_item + * @covers WP_REST_Attachments_Controller::prepare_item_for_response + */ + public function test_image_output_format_and_progressive_defaults_in_create_response(): void { + wp_set_current_user( self::$superadmin_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/media' ); + $request->set_header( 'Content-Type', 'image/jpeg' ); + $request->set_header( 'Content-Disposition', 'attachment; filename=canola.jpg' ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'generate_sub_sizes', false ); + $request->set_body( file_get_contents( DIR_TESTDATA . '/images/canola.jpg' ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertArrayHasKey( 'image_output_format', $data ); + $this->assertNull( $data['image_output_format'] ); + $this->assertArrayHasKey( 'image_save_progressive', $data ); + $this->assertFalse( $data['image_save_progressive'] ); + } + + /** + * Verifies image_output_format reflects an image_editor_output_format filter + * that remaps JPEG to WebP, and that the filter sees the real attached + * filename and MIME type. + * + * @ticket 65367 + * + * @covers WP_REST_Attachments_Controller::prepare_item_for_response + */ + public function test_image_output_format_with_custom_filter(): void { + wp_set_current_user( self::$superadmin_id ); + + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + $captured = array(); + add_filter( + 'image_editor_output_format', + static function ( $formats, $filename, $mime_type ) use ( &$captured ) { + $captured['filename'] = $filename; + $captured['mime_type'] = $mime_type; + $formats['image/jpeg'] = 'image/webp'; + return $formats; + }, + 10, + 3 + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/media/' . $attachment_id ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'image_output_format', $data ); + $this->assertSame( 'image/webp', $data['image_output_format'] ); + + // The filter must be invoked with the real attached filename and MIME type. + $this->assertStringEndsWith( '.jpg', (string) $captured['filename'] ); + $this->assertSame( 'image/jpeg', $captured['mime_type'] ); + } + + /** + * Verifies image_save_progressive surfaces the filter result for the + * attachment's MIME type. + * + * @ticket 65367 + * + * @covers WP_REST_Attachments_Controller::prepare_item_for_response + */ + public function test_image_save_progressive_with_custom_filter(): void { + wp_set_current_user( self::$superadmin_id ); + + $attachment_id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ); + + add_filter( + 'image_save_progressive', + static function ( $progressive, $mime_type ) { + return 'image/jpeg' === $mime_type; + }, + 10, + 2 + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/media/' . $attachment_id ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( 'image_save_progressive', $data ); + $this->assertTrue( $data['image_save_progressive'] ); + } + + /** + * Non-image attachments must not surface the image_* fields. + * + * @ticket 65367 + * + * @covers WP_REST_Attachments_Controller::prepare_item_for_response + */ + public function test_image_output_format_skipped_for_non_image(): void { + wp_set_current_user( self::$superadmin_id ); + + $attachment_id = self::factory()->attachment->create_object( + DIR_TESTDATA . '/uploads/dashicons.woff', + 0, + array( + 'post_mime_type' => 'application/font-woff', + 'post_type' => 'attachment', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/media/' . $attachment_id ); + $request->set_param( 'context', 'edit' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'image_output_format', $data ); + $this->assertArrayNotHasKey( 'image_save_progressive', $data ); + } + public function test_get_additional_field_registration() { $schema = array( diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index fa03d9751fe99..1a2d728a2d7cd 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12806,10 +12806,6 @@ mockedApiResponse.Schema = { } }, "image_size_threshold": 2560, - "image_output_formats": {}, - "jpeg_interlaced": false, - "png_interlaced": false, - "gif_interlaced": false, "site_logo": 0, "site_icon": 0, "site_icon_url": ""