Skip to content

Commit 4d6a707

Browse files
REST API: Expose size-aware encode quality on attachment responses.
Add an `image_quality` field to the media attachment REST response reporting the `wp_editor_set_quality` filter value, resolved against the output MIME type after `image_editor_output_format`. The `default` key carries the filtered quality (1-100) for the full-size image, while `sizes` lists per-registered-size overrides only where they diverge from `default`. See related Gutenberg pull request: WordPress/gutenberg#78420. Props westonruter, timothyblynjacobs. Fixes #65262. git-svn-id: https://develop.svn.wordpress.org/trunk@62630 602fd350-edb4-49c9-b593-d223f7449a82
1 parent ee37911 commit 4d6a707

4 files changed

Lines changed: 327 additions & 2 deletions

File tree

src/wp-includes/media.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,69 @@ function wp_get_registered_image_subsizes(): array {
973973
return $all_sizes;
974974
}
975975

976+
/**
977+
* Determines the encode quality WordPress would use for an image.
978+
*
979+
* Resolves the quality the same way WP_Image_Editor::set_quality() does when no
980+
* explicit quality is supplied: it starts from the per-format default, applies the
981+
* 'wp_editor_set_quality' filter, then the 'jpeg_quality' filter for JPEG output,
982+
* resets out-of-range values to the per-format default, and squashes 0 to 1.
983+
*
984+
* This lets code outside of an image editor instance - such as the REST API, which
985+
* reports the quality client-side processing should use - resolve the same value the
986+
* server would apply, without loading the image into an editor.
987+
*
988+
* @since 7.1.0
989+
*
990+
* @param string $mime_type The output image MIME type, e.g. 'image/jpeg'.
991+
* @param array $size {
992+
* Optional. Dimensions of the image, passed to the 'wp_editor_set_quality' filter.
993+
*
994+
* @type int $width The image width in pixels.
995+
* @type int $height The image height in pixels.
996+
* }
997+
* @param int|null $default_quality Optional. Starting quality before filters are applied.
998+
* Defaults to the per-format default (86 for WebP, 82 otherwise).
999+
* @return int Encode quality between 1 and 100.
1000+
*
1001+
* @phpstan-param non-empty-string $mime_type
1002+
* @phpstan-param array{ width?: non-negative-int, height?: non-negative-int } $size
1003+
* @phpstan-param int<0, 100>|null $default_quality
1004+
* @phpstan-return int<1, 100>
1005+
*/
1006+
function wp_get_image_encode_quality( string $mime_type, array $size = array(), ?int $default_quality = null ): int {
1007+
if ( null === $default_quality ) {
1008+
// Mirror WP_Image_Editor::get_default_quality(): WebP defaults to 86, everything else to 82.
1009+
$default_quality = ( 'image/webp' === $mime_type ) ? 86 : 82;
1010+
}
1011+
1012+
/** This filter is documented in wp-includes/class-wp-image-editor.php */
1013+
$quality = apply_filters( 'wp_editor_set_quality', $default_quality, $mime_type, $size );
1014+
1015+
if ( 'image/jpeg' === $mime_type ) {
1016+
/** This filter is documented in wp-includes/class-wp-image-editor.php */
1017+
$quality = apply_filters( 'jpeg_quality', $quality, 'image_resize' );
1018+
}
1019+
1020+
if ( ! is_numeric( $quality ) ) {
1021+
$quality = $default_quality;
1022+
} else {
1023+
$quality = (int) $quality;
1024+
}
1025+
1026+
// Reset out-of-range values to the default, matching WP_Image_Editor::set_quality().
1027+
if ( $quality < 0 || $quality > 100 ) {
1028+
$quality = $default_quality;
1029+
}
1030+
1031+
// Allow 0, but squash to 1, matching WP_Image_Editor::set_quality().
1032+
if ( 0 === $quality ) {
1033+
$quality = 1;
1034+
}
1035+
1036+
return $quality;
1037+
}
1038+
9761039
/**
9771040
* Retrieves an image to represent an attachment.
9781041
*

src/wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,7 @@ public function prepare_item_for_response( $item, $request ) {
12181218

12191219
$size_data['source_url'] = $image_src[0];
12201220
}
1221+
unset( $size_data );
12211222

12221223
$full_src = wp_get_attachment_image_src( $post->ID, 'full' );
12231224

@@ -1302,7 +1303,7 @@ public function prepare_item_for_response( $item, $request ) {
13021303
}
13031304

13041305
if ( wp_attachment_is_image( $post ) ) {
1305-
$mime_type = get_post_mime_type( $post );
1306+
$mime_type = (string) get_post_mime_type( $post );
13061307

13071308
/*
13081309
* Per-file output format for images, evaluated with the real filename
@@ -1333,6 +1334,53 @@ public function prepare_item_for_response( $item, $request ) {
13331334
/** This filter is documented in wp-includes/class-wp-image-editor-gd.php */
13341335
$data['image_save_progressive'] = (bool) apply_filters( 'image_save_progressive', false, $mime_type );
13351336
}
1337+
1338+
if ( in_array( 'image_quality', $fields, true ) ) {
1339+
$filename = get_attached_file( $post->ID );
1340+
1341+
/** This filter is documented in wp-includes/media.php */
1342+
$output_formats = apply_filters(
1343+
'image_editor_output_format',
1344+
array( $mime_type => $mime_type ),
1345+
$filename ? $filename : '',
1346+
$mime_type
1347+
);
1348+
$output_mime = $output_formats[ $mime_type ] ?? $mime_type;
1349+
1350+
$metadata = wp_get_attachment_metadata( $post->ID, true );
1351+
$full_width = max( 0, ( is_array( $metadata ) && isset( $metadata['width'] ) ) ? (int) $metadata['width'] : 0 );
1352+
$full_height = max( 0, ( is_array( $metadata ) && isset( $metadata['height'] ) ) ? (int) $metadata['height'] : 0 );
1353+
1354+
$full_quality = wp_get_image_encode_quality(
1355+
$output_mime,
1356+
array(
1357+
'width' => $full_width,
1358+
'height' => $full_height,
1359+
)
1360+
);
1361+
1362+
$size_quality = array();
1363+
1364+
foreach ( wp_get_registered_image_subsizes() as $size_name => $size_data ) {
1365+
$quality = wp_get_image_encode_quality(
1366+
$output_mime,
1367+
array(
1368+
'width' => (int) $size_data['width'],
1369+
'height' => (int) $size_data['height'],
1370+
)
1371+
);
1372+
1373+
// Only report sizes whose quality diverges from the full-size value.
1374+
if ( $quality !== $full_quality ) {
1375+
$size_quality[ $size_name ] = $quality;
1376+
}
1377+
}
1378+
1379+
$data['image_quality'] = array(
1380+
'default' => $full_quality,
1381+
'sizes' => $size_quality,
1382+
);
1383+
}
13361384
}
13371385

13381386
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
@@ -1525,6 +1573,35 @@ public function get_item_schema() {
15251573
'readonly' => true,
15261574
);
15271575

1576+
// Enumerate the registered sub-sizes so the schema documents exactly which
1577+
// keys may appear under "sizes".
1578+
$size_quality_properties = array();
1579+
foreach ( array_keys( wp_get_registered_image_subsizes() ) as $size_name ) {
1580+
$size_quality_properties[ $size_name ] = array(
1581+
'type' => 'integer',
1582+
'minimum' => 1,
1583+
'maximum' => 100,
1584+
);
1585+
}
1586+
1587+
$schema['properties']['image_quality'] = array(
1588+
'description' => __( 'Encode quality (1-100) from the wp_editor_set_quality filter, resolved against the output MIME type. The "default" value applies to the full-size image; "sizes" lists per-registered-size overrides where the filtered value differs from "default".' ),
1589+
'type' => 'object',
1590+
'context' => array( 'edit' ),
1591+
'readonly' => true,
1592+
'properties' => array(
1593+
'default' => array(
1594+
'type' => 'integer',
1595+
'minimum' => 1,
1596+
'maximum' => 100,
1597+
),
1598+
'sizes' => array(
1599+
'type' => 'object',
1600+
'properties' => $size_quality_properties,
1601+
),
1602+
),
1603+
);
1604+
15281605
$schema['properties']['image_output_format'] = array(
15291606
'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.' ),
15301607
'type' => array( 'string', 'null' ),

tests/phpunit/tests/media.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7283,6 +7283,81 @@ private function get_insufficient_width_height_for_high_priority(): array {
72837283
'height' => 100,
72847284
);
72857285
}
7286+
7287+
/**
7288+
* @ticket 65262
7289+
* @covers ::wp_get_image_encode_quality
7290+
*/
7291+
public function test_wp_get_image_encode_quality_defaults() {
7292+
// JPEG (and any non-WebP) defaults to 82, WebP to 86.
7293+
$this->assertSame( 82, wp_get_image_encode_quality( 'image/jpeg' ) );
7294+
$this->assertSame( 82, wp_get_image_encode_quality( 'image/png' ) );
7295+
$this->assertSame( 86, wp_get_image_encode_quality( 'image/webp' ) );
7296+
}
7297+
7298+
/**
7299+
* @ticket 65262
7300+
* @covers ::wp_get_image_encode_quality
7301+
*/
7302+
public function test_wp_get_image_encode_quality_applies_wp_editor_set_quality() {
7303+
$filter = static function ( $quality, $mime_type, $size ) {
7304+
return ( ! empty( $size['width'] ) && $size['width'] <= 300 ) ? 55 : $quality;
7305+
};
7306+
add_filter( 'wp_editor_set_quality', $filter, 10, 3 );
7307+
7308+
$small = wp_get_image_encode_quality( 'image/webp', array( 'width' => 150 ) );
7309+
$large = wp_get_image_encode_quality( 'image/webp', array( 'width' => 1200 ) );
7310+
7311+
remove_filter( 'wp_editor_set_quality', $filter, 10 );
7312+
7313+
$this->assertSame( 55, $small );
7314+
$this->assertSame( 86, $large );
7315+
}
7316+
7317+
/**
7318+
* The legacy jpeg_quality filter must apply for JPEG output only, matching
7319+
* WP_Image_Editor::set_quality().
7320+
*
7321+
* @ticket 65262
7322+
* @covers ::wp_get_image_encode_quality
7323+
*/
7324+
public function test_wp_get_image_encode_quality_applies_jpeg_quality() {
7325+
$filter = static function () {
7326+
return 70;
7327+
};
7328+
add_filter( 'jpeg_quality', $filter );
7329+
7330+
$jpeg = wp_get_image_encode_quality( 'image/jpeg' );
7331+
$webp = wp_get_image_encode_quality( 'image/webp' );
7332+
7333+
remove_filter( 'jpeg_quality', $filter );
7334+
7335+
$this->assertSame( 70, $jpeg );
7336+
// Non-JPEG output ignores jpeg_quality.
7337+
$this->assertSame( 86, $webp );
7338+
}
7339+
7340+
/**
7341+
* Out-of-range filtered values fall back to the default; 0 squashes to 1.
7342+
*
7343+
* @ticket 65262
7344+
* @covers ::wp_get_image_encode_quality
7345+
*/
7346+
public function test_wp_get_image_encode_quality_clamps_out_of_range() {
7347+
$too_high = static function () {
7348+
return 150;
7349+
};
7350+
add_filter( 'wp_editor_set_quality', $too_high );
7351+
$this->assertSame( 82, wp_get_image_encode_quality( 'image/jpeg' ) );
7352+
remove_filter( 'wp_editor_set_quality', $too_high );
7353+
7354+
$zero = static function () {
7355+
return 0;
7356+
};
7357+
add_filter( 'wp_editor_set_quality', $zero );
7358+
$this->assertSame( 1, wp_get_image_encode_quality( 'image/png' ) );
7359+
remove_filter( 'wp_editor_set_quality', $zero );
7360+
}
72867361
}
72877362

72887363
/**

tests/phpunit/tests/rest-api/rest-attachments-controller.php

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1952,10 +1952,11 @@ public function test_get_item_schema() {
19521952
$response = rest_get_server()->dispatch( $request );
19531953
$data = $response->get_data();
19541954
$properties = $data['schema']['properties'];
1955-
$this->assertCount( 34, $properties );
1955+
$this->assertCount( 35, $properties );
19561956
$this->assertArrayHasKey( 'author', $properties );
19571957
$this->assertArrayHasKey( 'alt_text', $properties );
19581958
$this->assertArrayHasKey( 'exif_orientation', $properties );
1959+
$this->assertArrayHasKey( 'image_quality', $properties );
19591960
$this->assertArrayHasKey( 'image_output_format', $properties );
19601961
$this->assertArrayHasKey( 'image_save_progressive', $properties );
19611962
$this->assertArrayHasKey( 'filename', $properties );
@@ -1995,6 +1996,115 @@ public function test_get_item_schema() {
19951996
$this->assertArrayHasKey( 'class_list', $properties );
19961997
}
19971998

1999+
/**
2000+
* @ticket 65262
2001+
*/
2002+
public function test_image_quality_schema() {
2003+
$request = new WP_REST_Request( 'OPTIONS', '/wp/v2/media' );
2004+
$response = rest_get_server()->dispatch( $request );
2005+
$data = $response->get_data();
2006+
$properties = $data['schema']['properties'];
2007+
2008+
$this->assertArrayHasKey( 'image_quality', $properties );
2009+
$this->assertSame( 'object', $properties['image_quality']['type'] );
2010+
$this->assertContains( 'edit', $properties['image_quality']['context'] );
2011+
$this->assertTrue( $properties['image_quality']['readonly'] );
2012+
2013+
$default = $properties['image_quality']['properties']['default'];
2014+
$this->assertSame( 'integer', $default['type'] );
2015+
$this->assertSame( 1, $default['minimum'] );
2016+
$this->assertSame( 100, $default['maximum'] );
2017+
2018+
$sizes = $properties['image_quality']['properties']['sizes'];
2019+
$this->assertSame( 'object', $sizes['type'] );
2020+
// Sizes are enumerated from the registered sub-sizes, each bounded 1-100.
2021+
$this->assertArrayHasKey( 'thumbnail', $sizes['properties'] );
2022+
$this->assertSame( 'integer', $sizes['properties']['thumbnail']['type'] );
2023+
$this->assertSame( 1, $sizes['properties']['thumbnail']['minimum'] );
2024+
$this->assertSame( 100, $sizes['properties']['thumbnail']['maximum'] );
2025+
}
2026+
2027+
/**
2028+
* @ticket 65262
2029+
* @requires function imagejpeg
2030+
*/
2031+
public function test_image_quality_default_in_response() {
2032+
wp_set_current_user( self::$editor_id );
2033+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
2034+
2035+
$request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" );
2036+
$request->set_param( 'context', 'edit' );
2037+
$response = rest_do_request( $request );
2038+
$data = $response->get_data();
2039+
2040+
$this->assertSame( 200, $response->get_status() );
2041+
$this->assertArrayHasKey( 'image_quality', $data );
2042+
// JPEG default quality is 82; no filter, so no per-size overrides.
2043+
$this->assertSame( 82, $data['image_quality']['default'] );
2044+
$this->assertSame( array(), $data['image_quality']['sizes'] );
2045+
}
2046+
2047+
/**
2048+
* @ticket 65262
2049+
* @requires function imagejpeg
2050+
*/
2051+
public function test_image_quality_with_size_aware_filter() {
2052+
wp_set_current_user( self::$editor_id );
2053+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
2054+
2055+
// Lower the quality for small images (e.g. thumbnails) only.
2056+
$filter = static function ( $quality, $mime_type, $size ) {
2057+
if ( is_array( $size ) && ! empty( $size['width'] ) && $size['width'] <= 300 ) {
2058+
return 60;
2059+
}
2060+
return $quality;
2061+
};
2062+
add_filter( 'wp_editor_set_quality', $filter, 10, 3 );
2063+
2064+
$request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" );
2065+
$request->set_param( 'context', 'edit' );
2066+
$response = rest_do_request( $request );
2067+
$data = $response->get_data();
2068+
2069+
remove_filter( 'wp_editor_set_quality', $filter, 10 );
2070+
2071+
$this->assertSame( 200, $response->get_status() );
2072+
$this->assertArrayHasKey( 'image_quality', $data );
2073+
// The full-size image (> 300px wide) keeps the default quality.
2074+
$this->assertSame( 82, $data['image_quality']['default'] );
2075+
// The thumbnail size (150x150) is <= 300px and diverges to 60.
2076+
$this->assertArrayHasKey( 'thumbnail', $data['image_quality']['sizes'] );
2077+
$this->assertSame( 60, $data['image_quality']['sizes']['thumbnail'] );
2078+
}
2079+
2080+
/**
2081+
* The reported quality must include the legacy jpeg_quality filter, the same
2082+
* way WP_Image_Editor::set_quality() applies it for JPEG output.
2083+
*
2084+
* @ticket 65262
2085+
* @requires function imagejpeg
2086+
*/
2087+
public function test_image_quality_honors_jpeg_quality_filter() {
2088+
wp_set_current_user( self::$editor_id );
2089+
$attachment = self::factory()->attachment->create_upload_object( self::$test_file );
2090+
2091+
$filter = static function () {
2092+
return 70;
2093+
};
2094+
add_filter( 'jpeg_quality', $filter );
2095+
2096+
$request = new WP_REST_Request( 'GET', "/wp/v2/media/{$attachment}" );
2097+
$request->set_param( 'context', 'edit' );
2098+
$response = rest_do_request( $request );
2099+
$data = $response->get_data();
2100+
2101+
remove_filter( 'jpeg_quality', $filter );
2102+
2103+
$this->assertSame( 200, $response->get_status() );
2104+
// JPEG output, so the jpeg_quality filter overrides the 82 default.
2105+
$this->assertSame( 70, $data['image_quality']['default'] );
2106+
}
2107+
19982108
/**
19992109
* Tests the image_output_format / image_save_progressive schema properties.
20002110
*

0 commit comments

Comments
 (0)