Skip to content
79 changes: 79 additions & 0 deletions src/wp-includes/block-supports/background.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> elements.
*
* @access private
*
Expand Down Expand Up @@ -79,6 +80,84 @@ function wp_render_background_support( $block_content, $block ) {
}
}

/*
* Use an <img> 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'] ) ) {
Expand Down
154 changes: 154 additions & 0 deletions tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,160 @@ public function data_background_block_support() {
'expected_wrapper' => '<div>Content</div>',
'wrapper' => '<div>Content</div>',
),
'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' => '<div class="has-background" style="background-image:url(&apos;https://example.com/image.jpg&apos;);background-repeat:repeat;background-size:cover;">Content</div>',
'wrapper' => '<div>Content</div>',
),
'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' => '<div class="has-background" style="background-image:url(&apos;https://example.com/image.jpg&apos;);background-size:cover;background-attachment:fixed;">Content</div>',
'wrapper' => '<div>Content</div>',
),
);
}

/**
* 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( '<div>Content</div>', $block );

$this->assertStringContainsString( '<img', $actual, 'no-repeat with contain should still inject an img element.' );
$this->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( '<div>Content</div>', $block );

$this->assertStringContainsString( '<img', $actual, 'Output should contain an img element.' );
$this->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 );
}
}
Loading