diff --git a/src/wp-includes/block-supports/background.php b/src/wp-includes/block-supports/background.php index cc4d451e1d8bd..bece55d80fca9 100644 --- a/src/wp-includes/block-supports/background.php +++ b/src/wp-includes/block-supports/background.php @@ -43,6 +43,7 @@ function wp_register_background_support( $block_type ) { * @since 6.5.0 Added support for `backgroundPosition` and `backgroundRepeat` output. * @since 6.6.0 Removed requirement for `backgroundImage.source`. A file/url is the default. * @since 6.7.0 Added support for `backgroundAttachment` output. + * @since 6.8.0 Added support for rendering background images as elements. * * @access private * @@ -79,6 +80,84 @@ function wp_render_background_support( $block_content, $block ) { } } + /* + * Use an element for media library images with cover/contain sizing and no tiling, + * so the browser can benefit from srcset, sizes, loading="lazy", and decoding="async". + * 'no-repeat' is treated the same as unset — both are compatible with object-fit. + * Only explicit tiling values (repeat, repeat-x, repeat-y) require CSS background-image. + */ + $attachment_id = is_array( $background_styles['backgroundImage'] ) + ? (int) ( $background_styles['backgroundImage']['id'] ?? 0 ) + : 0; + + $use_img_element = ( + $attachment_id > 0 && + in_array( $background_styles['backgroundSize'], array( 'cover', 'contain' ), true ) && + ( empty( $background_styles['backgroundRepeat'] ) || 'no-repeat' === $background_styles['backgroundRepeat'] ) && + empty( $background_styles['backgroundAttachment'] ) + ); + + if ( $use_img_element ) { + $object_fit = $background_styles['backgroundSize']; + $object_position = $background_styles['backgroundPosition'] ?? null; + + $img_style = 'position:absolute;top:0;left:0;right:0;bottom:0;margin:0;padding:0;width:100%;height:100%;max-width:none;max-height:none;pointer-events:none;object-fit:' . esc_attr( $object_fit ) . ';'; + if ( $object_position ) { + $img_style .= 'object-position:' . esc_attr( $object_position ) . ';'; + } + + $img_attrs = array( + 'class' => 'wp-block__background-image', + 'style' => $img_style, + 'alt' => '', + 'aria-hidden' => 'true', + 'data-object-fit' => $object_fit, + 'loading' => 'lazy', + 'decoding' => 'async', + ); + if ( $object_position ) { + $img_attrs['data-object-position'] = $object_position; + } + + $img_html = wp_get_attachment_image( $attachment_id, 'full', false, $img_attrs ); + + if ( $img_html ) { + $tags = new WP_HTML_Tag_Processor( $block_content ); + if ( $tags->next_tag() ) { + $tag_name = strtolower( $tags->get_tag() ); + $existing_style = $tags->get_attribute( 'style' ); + + if ( is_string( $existing_style ) && '' !== $existing_style ) { + $separator = str_ends_with( $existing_style, ';' ) ? '' : ';'; + // Only add position:relative when there is no existing position rule. + if ( ! preg_match( '/(?:^|;)\s*position\s*:/', $existing_style ) ) { + $wrapper_style = $existing_style . $separator . 'position:relative;'; + } else { + $wrapper_style = rtrim( $existing_style, ';' ) . ';'; + } + } else { + $wrapper_style = 'position:relative;'; + } + + $tags->set_attribute( 'style', $wrapper_style ); + $tags->add_class( 'has-background' ); + } + $modified_content = $tags->get_updated_html(); + + // Insert the img as the first child of the wrapper element. + // Find the end of the opening tag by locating the first '>'. The + // WP_HTML_Tag_Processor guarantees attribute values are properly + // escaped, so the first raw '>' is always the closing bracket of + // the opening wrapper tag. + $close_bracket = strpos( $modified_content, '>' ); + if ( false !== $close_bracket ) { + $modified_content = substr( $modified_content, 0, $close_bracket + 1 ) . $img_html . substr( $modified_content, $close_bracket + 1 ); + } + + return $modified_content; + } + } + $styles = wp_style_engine_get_styles( array( 'background' => $background_styles ) ); if ( ! empty( $styles['css'] ) ) { diff --git a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php index 5a4faf46085c0..98a08bccb59d4 100644 --- a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php @@ -213,6 +213,160 @@ public function data_background_block_support() { 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), + 'background image with attachment id and repeat falls back to CSS' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-rules-are-output', + 'background_settings' => array( + 'backgroundImage' => true, + ), + 'background_style' => array( + 'backgroundImage' => array( + 'id' => 99999, + 'url' => 'https://example.com/image.jpg', + ), + 'backgroundRepeat' => 'repeat', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background image with attachment id and fixed attachment falls back to CSS' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-rules-are-output', + 'background_settings' => array( + 'backgroundImage' => true, + ), + 'background_style' => array( + 'backgroundImage' => array( + 'id' => 99999, + 'url' => 'https://example.com/image.jpg', + ), + 'backgroundSize' => 'cover', + 'backgroundAttachment' => 'fixed', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + ); + } + + /** + * Tests that a background image with backgroundRepeat:'no-repeat' still injects + * an img element (no-repeat is compatible with object-fit; only tiled/repeat + * values require CSS background-image). + * + * @ticket 64725 + * + * @covers ::wp_render_background_support + */ + public function test_background_img_element_is_injected_with_no_repeat() { + switch_theme( 'block-theme-child-with-fluid-typography' ); + $this->test_block_name = 'test/background-img-element-no-repeat'; + + $attachment_id = self::factory()->attachment->create_upload_object( + DIR_TESTDATA . '/images/canola.jpg', + 0, + array( 'post_mime_type' => 'image/jpeg' ) + ); + + register_block_type( + $this->test_block_name, + array( + 'api_version' => 2, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( + 'background' => array( + 'backgroundImage' => true, + ), + ), + ) + ); + + $block = array( + 'blockName' => $this->test_block_name, + 'attrs' => array( + 'style' => array( + 'background' => array( + 'backgroundImage' => array( + 'id' => $attachment_id, + 'url' => wp_get_attachment_url( $attachment_id ), + ), + 'backgroundSize' => 'contain', + 'backgroundRepeat' => 'no-repeat', + ), + ), + ), + ); + + $actual = wp_render_background_support( '
Content
', $block ); + + $this->assertStringContainsString( 'assertStringNotContainsString( 'background-image:', $actual, 'Output should not contain background-image CSS.' ); + $this->assertStringContainsString( 'has-background', $actual, 'Wrapper should have has-background class.' ); + $this->assertStringContainsString( 'position:relative', $actual, 'Wrapper should have position:relative style.' ); + + wp_delete_attachment( $attachment_id, true ); + } + + /** + * Tests that a background image with an attachment ID injects an img element. + * + * @ticket 64725 + * + * @covers ::wp_render_background_support + */ + public function test_background_img_element_is_injected() { + switch_theme( 'block-theme-child-with-fluid-typography' ); + $this->test_block_name = 'test/background-img-element'; + + $attachment_id = self::factory()->attachment->create_upload_object( + DIR_TESTDATA . '/images/canola.jpg', + 0, + array( 'post_mime_type' => 'image/jpeg' ) ); + + register_block_type( + $this->test_block_name, + array( + 'api_version' => 2, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( + 'background' => array( + 'backgroundImage' => true, + ), + ), + ) + ); + + $block = array( + 'blockName' => $this->test_block_name, + 'attrs' => array( + 'style' => array( + 'background' => array( + 'backgroundImage' => array( + 'id' => $attachment_id, + 'url' => wp_get_attachment_url( $attachment_id ), + ), + 'backgroundSize' => 'cover', + ), + ), + ), + ); + + $actual = wp_render_background_support( '
Content
', $block ); + + $this->assertStringContainsString( 'assertStringNotContainsString( 'background-image:', $actual, 'Output should not contain background-image CSS.' ); + $this->assertStringContainsString( 'has-background', $actual, 'Wrapper should have has-background class.' ); + $this->assertStringContainsString( 'position:relative', $actual, 'Wrapper should have position:relative style.' ); + + wp_delete_attachment( $attachment_id, true ); } }