Commit 4ee4cc9ed0c for woocommerce

commit 4ee4cc9ed0c1bf68aa75c5528038192b7c2f200f
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date:   Wed Nov 11 13:09:40 2026 -0500

    Keep an image's aspect ratio when the email caps its width (#70867)

    * fix: keep image aspect ratio when the email caps its width

    The core/image renderer rewrites the <img> width attribute when it caps an
    image to the content column, but left the saved height attribute alone. Outlook
    renders with Word's engine, which sizes from those attributes rather than the
    inlined height:auto, so a 1024x555 image sent at 660px wide was drawn 555px tall
    — stretched by two thirds. Gmail, New Outlook and the editor preview follow
    height:auto and looked correct, which is why this went unnoticed.

    Scale the height by the same factor as the width, as the gallery and product
    image renderers already do. The height is left alone when the image carries no
    saved dimensions to derive a ratio from, when the style sets a fixed height, or
    when the width is a percentage.

    Fixes NL-1136.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * fix: use strpos for the percentage check to keep PHP 7.4 support

    str_contains is PHP 8.0+, and the package's PHPStan run for PHP 7.4 only
    whitelists the core-polyfilled str_starts_with and str_ends_with. strpos is
    what this file already uses for a substring check.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/packages/php/email-editor/changelog/fix-nl-1136-image-height-aspect-ratio b/packages/php/email-editor/changelog/fix-nl-1136-image-height-aspect-ratio
new file mode 100644
index 00000000000..98949383b8d
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-nl-1136-image-height-aspect-ratio
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep an image's aspect ratio in Outlook when the email shrinks it to fit the content width.
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
index 66390e55790..20665894f83 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
@@ -332,17 +332,23 @@ class Image extends Abstract_Block_Renderer {
 		if ( $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
 			// Getting height from styles and if it's set, we set the height attribute.
 			/** @var string $styles */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
-			$styles = $html->get_attribute( 'style' ) ?? '';
-			$styles = Styles_Helper::parse_styles_to_array( $styles );
-			$height = $styles['height'] ?? null;
-			if ( $height && 'auto' !== $height ) {
+			$styles           = $html->get_attribute( 'style' ) ?? '';
+			$styles           = Styles_Helper::parse_styles_to_array( $styles );
+			$height           = $styles['height'] ?? null;
+			$has_fixed_height = $height && 'auto' !== $height;
+			if ( $has_fixed_height ) {
 				$height = Styles_Helper::parse_value( $height );
 				/* @phpstan-ignore-next-line Wrong annotation for parameter in WP. */
 				$html->set_attribute( 'height', esc_attr( $height ) );
 			}

 			if ( isset( $parsed_block['attrs']['width'] ) ) {
-				$width = Styles_Helper::parse_value( $parsed_block['attrs']['width'] );
+				$block_width = (string) $parsed_block['attrs']['width'];
+				$width       = Styles_Helper::parse_value( $block_width );
+				// A percentage can't be scaled against, and a fixed height is the author's choice.
+				if ( ! $has_fixed_height && strpos( $block_width, '%' ) === false ) {
+					$this->scale_height_attribute( $html, $width );
+				}
 				/* @phpstan-ignore-next-line Wrong annotation for parameter in WP. */
 				$html->set_attribute( 'width', esc_attr( $width ) );
 			}
@@ -352,6 +358,35 @@ class Image extends Abstract_Block_Renderer {
 		return $block_content;
 	}

+	/**
+	 * Scale the image's saved height attribute to the width the email renders it at.
+	 *
+	 * Outlook renders with Word's engine, which sizes an image from its width and height
+	 * attributes and ignores the inlined `height: auto`. A saved height left beside a rewritten
+	 * width therefore stretches the image, so the height is scaled by the same factor.
+	 *
+	 * The height is left alone when either saved dimension is missing or non-numeric, since there
+	 * is then no ratio to scale by.
+	 *
+	 * @param \WP_HTML_Tag_Processor $html Processor positioned on the img tag.
+	 * @param float                  $new_width Width in pixels the image is rendered at.
+	 */
+	private function scale_height_attribute( \WP_HTML_Tag_Processor $html, float $new_width ): void {
+		$saved_width  = $html->get_attribute( 'width' );
+		$saved_height = $html->get_attribute( 'height' );
+
+		if ( ! is_string( $saved_width ) || ! is_numeric( $saved_width ) || ! is_string( $saved_height ) || ! is_numeric( $saved_height ) ) {
+			return;
+		}
+
+		if ( (float) $saved_width <= 0 ) {
+			return;
+		}
+
+		$scaled_height = max( 1, (int) round( (float) $saved_height * ( $new_width / (float) $saved_width ) ) );
+		$html->set_attribute( 'height', esc_attr( (string) $scaled_height ) );
+	}
+
 	/**
 	 * Add core's caption class to the caption element when it is missing.
 	 *
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
index f37ab164b3d..3869a7e6c79 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
@@ -514,4 +514,165 @@ class Image_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'object-fit:cover', (string) $html->get_attribute( 'style' ) );
 		$this->assertStringContainsString( 'aspect-ratio:2', (string) $html->get_attribute( 'style' ) );
 	}
+
+	/**
+	 * Test the saved height is scaled when capping the width shrinks the image.
+	 *
+	 * Outlook sizes from the width and height attributes, so a saved height left beside the capped
+	 * width would stretch the image vertically.
+	 */
+	public function testItScalesTheSavedHeightWhenItCapsTheWidth(): void {
+		$image_content = '
+			<figure class="wp-block-image size-large">
+				<img src="https://test.com/wp-content/uploads/2023/05/image.jpg" alt="" width="1024" height="555" style="max-width:100%;height:auto;"/>
+			</figure>
+		';
+		$parsed_image  = $this->parsed_image;
+		unset( $parsed_image['attrs']['width'] );
+		$parsed_image['email_attrs']['width'] = '660px';
+		$parsed_image['innerHTML']            = $image_content;
+
+		$rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+
+		$this->assertSame( '660', $this->get_image_attribute( $rendered, 'width' ) );
+		// 555 * ( 660 / 1024 ) = 357.76.
+		$this->assertSame( '358', $this->get_image_attribute( $rendered, 'height' ) );
+	}
+
+	/**
+	 * Test the saved height is scaled to a width set on the block.
+	 */
+	public function testItScalesTheSavedHeightToABlockWidth(): void {
+		$image_content                  = '
+			<figure class="wp-block-image size-large">
+				<img src="https://test.com/wp-content/uploads/2023/05/image.jpg" alt="" width="1024" height="555" style="max-width:100%;height:auto;"/>
+			</figure>
+		';
+		$parsed_image                   = $this->parsed_image;
+		$parsed_image['attrs']['width'] = '400px';
+		$parsed_image['innerHTML']      = $image_content;
+
+		$rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+
+		$this->assertSame( '400', $this->get_image_attribute( $rendered, 'width' ) );
+		// 555 * ( 400 / 1024 ) = 216.8.
+		$this->assertSame( '217', $this->get_image_attribute( $rendered, 'height' ) );
+	}
+
+	/**
+	 * Test an image narrower than the content column keeps its saved dimensions.
+	 */
+	public function testItKeepsTheSavedHeightWhenTheImageFitsTheColumn(): void {
+		$image_content = '
+			<figure class="wp-block-image size-large">
+				<img src="https://test.com/wp-content/uploads/2023/05/image.jpg?w=400" alt="" width="400" height="200" style="max-width:100%;height:auto;"/>
+			</figure>
+		';
+		$parsed_image  = $this->parsed_image;
+		unset( $parsed_image['attrs']['width'] );
+		$parsed_image['email_attrs']['width'] = '660px';
+		$parsed_image['innerHTML']            = $image_content;
+
+		$rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+
+		$this->assertSame( '400', $this->get_image_attribute( $rendered, 'width' ) );
+		$this->assertSame( '200', $this->get_image_attribute( $rendered, 'height' ) );
+	}
+
+	/**
+	 * Test a fixed height in the image's style wins over the scaled height.
+	 */
+	public function testItKeepsAFixedStyleHeight(): void {
+		$image_content = '
+			<figure class="wp-block-image size-large">
+				<img src="https://test.com/wp-content/uploads/2023/05/image.jpg" alt="" width="1024" height="555" style="max-width:100%;height:300px;"/>
+			</figure>
+		';
+		$parsed_image  = $this->parsed_image;
+		unset( $parsed_image['attrs']['width'] );
+		$parsed_image['email_attrs']['width'] = '660px';
+		$parsed_image['innerHTML']            = $image_content;
+
+		$rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+
+		$this->assertSame( '660', $this->get_image_attribute( $rendered, 'width' ) );
+		$this->assertSame( '300', $this->get_image_attribute( $rendered, 'height' ) );
+	}
+
+	/**
+	 * Test the saved height is kept when the width falls back to a percentage.
+	 *
+	 * There is no pixel width to scale the height against.
+	 */
+	public function testItKeepsTheSavedHeightWhenTheWidthFallsBackToAPercentage(): void {
+		$image_content = '
+			<figure class="wp-block-image size-large">
+				<img src="https://test.com/wp-content/uploads/2023/05/image.jpg" alt="" width="1024" height="555" style="max-width:100%;height:auto;"/>
+			</figure>
+		';
+		$parsed_image  = $this->parsed_image;
+		unset( $parsed_image['attrs']['width'] );
+		unset( $parsed_image['email_attrs']['width'] );
+		$parsed_image['innerHTML'] = $image_content;
+
+		$rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+
+		$this->assertSame( '555', $this->get_image_attribute( $rendered, 'height' ) );
+	}
+
+	/**
+	 * Test the saved height is kept when the image has no saved width to scale it against.
+	 */
+	public function testItKeepsTheSavedHeightWhenTheImageHasNoSavedWidth(): void {
+		$image_content = '
+			<figure class="wp-block-image size-large">
+				<img src="https://test.com/wp-content/uploads/2023/05/image.jpg" alt="" height="555" style="max-width:100%;height:auto;"/>
+			</figure>
+		';
+		$parsed_image  = $this->parsed_image;
+		unset( $parsed_image['attrs']['width'] );
+		$parsed_image['email_attrs']['width'] = '660px';
+		$parsed_image['innerHTML']            = $image_content;
+
+		$rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+
+		$this->assertSame( '660', $this->get_image_attribute( $rendered, 'width' ) );
+		$this->assertSame( '555', $this->get_image_attribute( $rendered, 'height' ) );
+	}
+
+	/**
+	 * Test no height attribute is invented for an image saved without dimensions.
+	 *
+	 * This is how current core saves an image block: the size lives in the style, not in attributes.
+	 */
+	public function testItAddsNoHeightWhenTheImageHasNoSavedDimensions(): void {
+		$image_content = '
+			<figure class="wp-block-image size-large">
+				<img src="https://test.com/wp-content/uploads/2023/05/image.jpg" alt="" style="max-width:100%;height:auto;"/>
+			</figure>
+		';
+		$parsed_image  = $this->parsed_image;
+		unset( $parsed_image['attrs']['width'] );
+		$parsed_image['email_attrs']['width'] = '660px';
+		$parsed_image['innerHTML']            = $image_content;
+
+		$rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+
+		$this->assertSame( '660', $this->get_image_attribute( $rendered, 'width' ) );
+		$this->assertNull( $this->get_image_attribute( $rendered, 'height' ) );
+	}
+
+	/**
+	 * Read an attribute off the rendered image.
+	 *
+	 * @param string $rendered Rendered block content.
+	 * @param string $attribute Attribute name.
+	 * @return string|null The attribute value, or null when the image doesn't carry it.
+	 */
+	private function get_image_attribute( string $rendered, string $attribute ): ?string {
+		$html = new \WP_HTML_Tag_Processor( $rendered );
+		$this->assertTrue( $html->next_tag( array( 'tag_name' => 'img' ) ), 'The rendered content has no image.' );
+		$value = $html->get_attribute( $attribute );
+		return is_string( $value ) ? $value : null;
+	}
 }