Commit 0d383711e66 for woocommerce

commit 0d383711e6618f662fa968b40d1cf474e888e1e0
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date:   Tue Jul 21 12:43:35 2026 -0400

    [Email Editor] Clamp oversized gallery image width for email (+ drop web-only class) (#66824)

    fix: clamp oversized gallery image width for email; drop web-only class

    Gallery images leaked the intrinsic width the editor stores (e.g.
    width="2560"), which Outlook honors literally — blowing a thumbnail-sized cell
    wide open. The core/image renderer avoids this via add_image_dimensions(); the
    gallery path, which sizes to a per-cell width rather than the block width, did
    not.

    Route every extracted <img> through a new prepare_image_html() =
    sanitize_image_html() -> apply_aspect_ratio_crop() -> normalize_image_for_email().
    The normalizer clamps an existing oversized width down to the cell it renders in
    (scaling height to keep the ratio) and drops the web-only class the sanitizer
    preserves. It only shrinks a width already present and greater than the cell
    width, so the server-crop's concrete dimensions and the dimensionless CSS-crop
    fallback are preserved.

    srcset/sizes/loading/decoding are already dropped by sanitize_image_html()'s
    allowlist, so no redundant stripping is added here; an integration test guards
    that the rendered output carries none of them.

    All methods are private; no public signature, hook, or interface changes.


    Claude-Session: https://claude.ai/code/session_013bzEe6dZ2Ny1z6nz1U27Rg

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/packages/php/email-editor/changelog/fix-nl-740-gallery-image-hardening b/packages/php/email-editor/changelog/fix-nl-740-gallery-image-hardening
new file mode 100644
index 00000000000..d2d79cbd8ac
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-nl-740-gallery-image-hardening
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Clamp an oversized raw image width in email galleries down to the cell it renders in (scaling height to keep the aspect ratio), so wide originals no longer blow out the layout in Outlook, and drop the web-only class the sanitizer preserves.
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-gallery.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-gallery.php
index 3e3079c670e..e80db83e26c 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-gallery.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-gallery.php
@@ -132,21 +132,18 @@ class Gallery extends Abstract_Block_Renderer {
 		if ( preg_match( '/<a[^>]*href=(["\'])(.*?)\1[^>]*>(\s*<img[^>]*>)\s*<\/a>/s', $html_content, $link_matches ) ) {
 			// Validate and sanitize the link URL.
 			$sanitized_url = esc_url( $link_matches[2] );
-			if ( ! empty( $sanitized_url ) ) {
-				$sanitized_img = $this->apply_aspect_ratio_crop( Html_Processing_Helper::sanitize_image_html( $link_matches[3] ), $aspect_ratio, $cell_width, $image_attrs );
-				if ( '' !== $sanitized_img ) {
+			$sanitized_img = $this->prepare_image_html( $link_matches[3], $aspect_ratio, $cell_width, $image_attrs );
+			if ( '' !== $sanitized_img ) {
+				if ( ! empty( $sanitized_url ) ) {
 					$result .= '<a href="' . $sanitized_url . '">' . $sanitized_img . '</a>';
-				}
-			} else {
-				// If URL is invalid, extract just the image without link.
-				$sanitized_img = $this->apply_aspect_ratio_crop( Html_Processing_Helper::sanitize_image_html( $link_matches[3] ), $aspect_ratio, $cell_width, $image_attrs );
-				if ( '' !== $sanitized_img ) {
+				} else {
+					// If the URL is invalid, extract just the image without the link.
 					$result .= $sanitized_img;
 				}
 			}
 		} elseif ( preg_match( '/<img[^>]*>/', $html_content, $img_matches ) ) {
 			// Image is not linked - just extract the img element with sanitization.
-			$sanitized_img = $this->apply_aspect_ratio_crop( Html_Processing_Helper::sanitize_image_html( $img_matches[0] ), $aspect_ratio, $cell_width, $image_attrs );
+			$sanitized_img = $this->prepare_image_html( $img_matches[0], $aspect_ratio, $cell_width, $image_attrs );
 			if ( '' !== $sanitized_img ) {
 				$result .= $sanitized_img;
 			}
@@ -171,6 +168,85 @@ class Gallery extends Abstract_Block_Renderer {
 		return $result;
 	}

+	/**
+	 * Sanitize a raw gallery <img>, apply the optional aspect-ratio crop, then normalize it for
+	 * email rendering.
+	 *
+	 * This is the single entry point every extraction path uses so image hardening (dropping the
+	 * web-only class and reining in an oversized raw width) is applied consistently, whether the
+	 * image is linked, unlinked, or cropped.
+	 *
+	 * @param string      $raw_img_html Raw <img> HTML extracted from the block content.
+	 * @param string|null $aspect_ratio Optional aspect ratio (e.g. "1" or "4/3") to crop the image to.
+	 * @param int         $cell_width Estimated display width of the gallery cell in px.
+	 * @param array       $image_attrs Parsed attributes of the core/image block (id, sizeSlug, ...).
+	 * @return string Prepared <img> HTML, or empty string when the image is invalid.
+	 */
+	private function prepare_image_html( string $raw_img_html, ?string $aspect_ratio, int $cell_width, array $image_attrs ): string {
+		$sanitized = Html_Processing_Helper::sanitize_image_html( $raw_img_html );
+		$sanitized = $this->apply_aspect_ratio_crop( $sanitized, $aspect_ratio, $cell_width, $image_attrs );
+		return $this->normalize_image_for_email( $sanitized, $cell_width );
+	}
+
+	/**
+	 * Normalize a gallery <img> for email: drop the web-only class and rein in an oversized raw width.
+	 *
+	 * The block editor stores the intrinsic `width`/`height` of the original file (e.g. `width="2560"`).
+	 * Outlook honors that raw width literally — blowing a thumbnail-sized cell wide open. The
+	 * core/image renderer avoids this with add_image_dimensions(); the gallery path (which sizes to a
+	 * per-cell width rather than the block width) needs the equivalent tailored to its cell model. We
+	 * clamp the width down to the cell it renders in, but only when the stored width exceeds it,
+	 * scaling any height to keep the aspect ratio. An image with no explicit width is left responsive
+	 * (no attribute added), and a width already at or below the cell width is untouched — so the
+	 * concrete dimensions the aspect-ratio crop sets for a server-cropped file, and the deliberately
+	 * dimensionless CSS-crop fallback, are both preserved.
+	 *
+	 * The other web-only attributes core emits (`srcset`, `sizes`, `loading`, `decoding`) are already
+	 * stripped upstream by {@see Html_Processing_Helper::sanitize_image_html()}, whose allowlist keeps
+	 * only src/alt/width/height/class/style; the `class` is the one web-only attribute it preserves, so
+	 * that is all we remove here (matching the core/image renderer, which also drops it).
+	 *
+	 * @param string $img_html Sanitized <img> HTML.
+	 * @param int    $cell_width Estimated display width of the gallery cell in px.
+	 * @return string The normalized <img> HTML.
+	 */
+	private function normalize_image_for_email( string $img_html, int $cell_width ): string {
+		if ( '' === $img_html ) {
+			return $img_html;
+		}
+
+		$html = new \WP_HTML_Tag_Processor( $img_html );
+		if ( ! $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
+			return $img_html;
+		}
+
+		// Drop the web-only class the sanitizer preserves (harmless in email, and the core/image
+		// renderer strips it too). remove_attribute() is a no-op when the attribute is absent.
+		$html->remove_attribute( 'class' );
+
+		// Rein in a raw width wider than the cell it renders in (e.g. a 2560px original in a 20%-wide
+		// cell). We only shrink an explicit, oversized width — never add one to an otherwise responsive
+		// image, and never touch a width already sized to (or below) the cell — so the aspect-ratio
+		// crop's concrete server-crop dimensions are left intact.
+		if ( $cell_width > 0 ) {
+			$raw_width = $html->get_attribute( 'width' );
+			$width     = is_string( $raw_width ) && is_numeric( $raw_width ) ? (int) $raw_width : 0;
+
+			if ( $width > $cell_width ) {
+				$raw_height = $html->get_attribute( 'height' );
+				$height     = is_string( $raw_height ) && is_numeric( $raw_height ) ? (int) $raw_height : 0;
+				if ( $height > 0 ) {
+					// Scale the height by the same factor so the image keeps its aspect ratio.
+					$scaled_height = max( 1, (int) round( $height * ( $cell_width / $width ) ) );
+					$html->set_attribute( 'height', esc_attr( (string) $scaled_height ) );
+				}
+				$html->set_attribute( 'width', esc_attr( (string) $cell_width ) );
+			}
+		}
+
+		return $html->get_updated_html();
+	}
+
 	/**
 	 * Apply an aspect-ratio crop to a sanitized <img> tag.
 	 *
@@ -262,8 +338,10 @@ class Gallery extends Abstract_Block_Renderer {
 			}
 			$crop_styles = sprintf( 'aspect-ratio: %s; object-fit: cover; width: 100%%; height: auto; max-width: 100%%; display: block;', $aspect_ratio );
 		} else {
-			// No server-side crop available: fall back to best-effort CSS cropping. We avoid concrete
-			// dimensions here so the natural image isn't distorted in clients that ignore object-fit.
+			// No server-side crop available: fall back to best-effort CSS cropping. We don't stamp
+			// crop dimensions here so the natural image isn't distorted in clients that ignore
+			// object-fit. normalize_image_for_email() may still clamp an oversized width downstream,
+			// but it scales the height with it, preserving the natural ratio rather than the crop.
 			$crop_styles = sprintf( 'aspect-ratio: %s; object-fit: cover; width: 100%%; height: auto; display: block;', $aspect_ratio );
 		}

diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Gallery_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Gallery_Test.php
index f47bc8dd41d..d57cdf98c3c 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Gallery_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Gallery_Test.php
@@ -683,4 +683,186 @@ class Gallery_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'width: 12.50%', $rendered, 'The full row uses the column width.' );
 		$this->assertStringContainsString( 'width: 50.00%', $rendered, 'The trailing pair stretches to fill the row, as in the block.' );
 	}
+
+	/**
+	 * An oversized raw image width (the intrinsic size the editor stores, e.g. width="2560") is
+	 * clamped down to the cell it renders in, so Outlook — which honors the width attribute literally
+	 * — doesn't blow the layout open. The height scales with it to preserve the aspect ratio.
+	 */
+	public function testItClampsAnOversizedRawImageWidthToTheCell(): void {
+		$parsed_gallery                     = $this->parsed_gallery;
+		$parsed_gallery['attrs']['columns'] = 1;
+		$parsed_gallery['innerBlocks']      = array(
+			array(
+				'blockName'   => 'core/image',
+				'attrs'       => array(
+					'id'              => 1,
+					'sizeSlug'        => 'large',
+					'linkDestination' => 'none',
+				),
+				'innerBlocks' => array(),
+				'innerHTML'   => '<figure class="wp-block-image size-large"><img src="https://example.com/image1.jpg" alt="Image 1" width="2560" height="1440"/></figure>',
+			),
+		);
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		// The raw, oversized width must not survive as an attribute.
+		$this->assertStringNotContainsString( 'width="2560"', $rendered, 'The oversized raw width is not emitted.' );
+
+		$html = new \WP_HTML_Tag_Processor( $rendered );
+		$this->assertTrue( $html->next_tag( array( 'tag_name' => 'img' ) ), 'The gallery image is rendered.' );
+		$img_width  = (int) $html->get_attribute( 'width' );
+		$img_height = (int) $html->get_attribute( 'height' );
+		$this->assertGreaterThan( 0, $img_width, 'The image keeps a concrete width.' );
+		$this->assertLessThan( 2560, $img_width, 'The width is clamped below the original.' );
+		$this->assertGreaterThan( 0, $img_height, 'The height is kept.' );
+		// The 16:9 aspect ratio of the original is preserved after clamping.
+		$this->assertEqualsWithDelta( 2560 / 1440, $img_width / $img_height, 0.05, 'Clamping preserves the aspect ratio.' );
+	}
+
+	/**
+	 * A width that already fits its cell is left untouched — clamping only shrinks oversized images,
+	 * it never rewrites images that are already correctly sized.
+	 */
+	public function testItLeavesAWidthThatFitsTheCellUnchanged(): void {
+		$parsed_gallery                     = $this->parsed_gallery;
+		$parsed_gallery['attrs']['columns'] = 1;
+		$parsed_gallery['innerBlocks']      = array(
+			array(
+				'blockName'   => 'core/image',
+				'attrs'       => array(
+					'id'              => 1,
+					'sizeSlug'        => 'large',
+					'linkDestination' => 'none',
+				),
+				'innerBlocks' => array(),
+				'innerHTML'   => '<figure class="wp-block-image size-large"><img src="https://example.com/image1.jpg" alt="Image 1" width="100" height="50"/></figure>',
+			),
+		);
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		$this->assertStringContainsString( 'width="100"', $rendered, 'A small width is preserved.' );
+		$this->assertStringContainsString( 'height="50"', $rendered, 'Its height is preserved too.' );
+	}
+
+	/**
+	 * The rendered gallery image carries none of the web-only <img> attributes core emits for the
+	 * front end: srcset/sizes/loading/decoding are dropped by the sanitizer's allowlist, and the
+	 * class is removed by the renderer (matching core/image). This guards the combined output
+	 * contract regardless of where each attribute is stripped.
+	 */
+	public function testItStripsWebOnlyImageAttributes(): void {
+		$parsed_gallery                                = $this->parsed_gallery;
+		$parsed_gallery['innerBlocks'][0]['innerHTML'] = '<figure class="wp-block-image size-large"><img src="https://example.com/image1.jpg" alt="Image 1" class="wp-image-1" srcset="https://example.com/image1-300.jpg 300w, https://example.com/image1-1024.jpg 1024w" sizes="(max-width: 660px) 100vw, 660px" loading="lazy" decoding="async"/></figure>';
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		$this->assertStringNotContainsString( 'srcset', $rendered, 'srcset is stripped.' );
+		$this->assertStringNotContainsString( 'sizes=', $rendered, 'sizes is stripped.' );
+		$this->assertStringNotContainsString( 'loading=', $rendered, 'loading is stripped.' );
+		$this->assertStringNotContainsString( 'decoding', $rendered, 'decoding is stripped.' );
+		$this->assertStringNotContainsString( 'wp-image-1', $rendered, 'The web-only class is stripped.' );
+		// The image itself still renders.
+		$this->assertStringContainsString( 'image1.jpg', $rendered, 'The image is still rendered.' );
+	}
+
+	/**
+	 * Real editor markup carries a class and an oversized intrinsic width on the same <img>. Removing
+	 * the class and clamping the width happen on one WP_HTML_Tag_Processor pass, so this verifies the
+	 * two edits don't interfere: the class is gone, the width is clamped, and the height still scales.
+	 */
+	public function testItStripsClassAndClampsWidthOnTheSameImage(): void {
+		$parsed_gallery                     = $this->parsed_gallery;
+		$parsed_gallery['attrs']['columns'] = 1;
+		$parsed_gallery['innerBlocks']      = array(
+			array(
+				'blockName'   => 'core/image',
+				'attrs'       => array(
+					'id'              => 1,
+					'sizeSlug'        => 'large',
+					'linkDestination' => 'none',
+				),
+				'innerBlocks' => array(),
+				'innerHTML'   => '<figure class="wp-block-image size-large"><img src="https://example.com/image1.jpg" alt="Image 1" class="wp-image-1" width="2560" height="1440"/></figure>',
+			),
+		);
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		$this->assertStringNotContainsString( 'wp-image-1', $rendered, 'The class is stripped.' );
+		$this->assertStringNotContainsString( 'width="2560"', $rendered, 'The oversized width is clamped.' );
+
+		$html = new \WP_HTML_Tag_Processor( $rendered );
+		$this->assertTrue( $html->next_tag( array( 'tag_name' => 'img' ) ), 'The gallery image is rendered.' );
+		$this->assertNull( $html->get_attribute( 'class' ), 'The image has no class attribute.' );
+		$img_width  = (int) $html->get_attribute( 'width' );
+		$img_height = (int) $html->get_attribute( 'height' );
+		$this->assertGreaterThan( 0, $img_width, 'The image keeps a concrete width.' );
+		$this->assertLessThan( 2560, $img_width, 'The width is clamped below the original.' );
+		$this->assertGreaterThan( 0, $img_height, 'The height is kept.' );
+		// The 16:9 aspect ratio of the original survives the combined edits.
+		$this->assertEqualsWithDelta( 2560 / 1440, $img_width / $img_height, 0.05, 'The aspect ratio is preserved.' );
+	}
+
+	/**
+	 * Clamping an oversized width must not corrupt a multi-parameter src (one containing an
+	 * ampersand, common with image CDNs). The width attribute is reined in while the src is untouched.
+	 */
+	public function testItClampsOversizedWidthWithoutCorruptingAMultiParamSrc(): void {
+		$parsed_gallery                     = $this->parsed_gallery;
+		$parsed_gallery['attrs']['columns'] = 1;
+		$parsed_gallery['innerBlocks']      = array(
+			array(
+				'blockName'   => 'core/image',
+				'attrs'       => array(
+					'id'              => 1,
+					'sizeSlug'        => 'large',
+					'linkDestination' => 'none',
+				),
+				'innerBlocks' => array(),
+				'innerHTML'   => '<figure class="wp-block-image size-large"><img src="https://example.com/image1.jpg?w=2560&h=1440" alt="Image 1" width="2560" height="1440"/></figure>',
+			),
+		);
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		// The oversized width attribute is clamped...
+		$this->assertStringNotContainsString( 'width="2560"', $rendered, 'The oversized width attribute is clamped.' );
+		// ...but the multi-parameter src (with its ampersand) is left intact.
+		$this->assertStringContainsString( 'w=2560', $rendered, 'The src width query parameter survives.' );
+		$this->assertStringContainsString( 'h=1440', $rendered, 'The src height query parameter survives.' );
+	}
+
+	/**
+	 * The clamp applies even when an aspect-ratio crop is in play: a CSS-cropped image still carries
+	 * its oversized raw width attribute, which must be reined in for Outlook without disturbing the
+	 * crop styling for clients that support it.
+	 */
+	public function testItClampsOversizedWidthEvenWhenCropped(): void {
+		$parsed_gallery                         = $this->parsed_gallery;
+		$parsed_gallery['attrs']['columns']     = 1;
+		$parsed_gallery['attrs']['aspectRatio'] = '1';
+		$parsed_gallery['innerBlocks']          = array(
+			array(
+				'blockName'   => 'core/image',
+				'attrs'       => array(
+					'id'              => 1,
+					'sizeSlug'        => 'large',
+					'linkDestination' => 'none',
+				),
+				'innerBlocks' => array(),
+				'innerHTML'   => '<figure class="wp-block-image size-large"><img src="https://example.com/image1.jpg" alt="Image 1" width="2560" height="1440"/></figure>',
+			),
+		);
+
+		// No server-crop integration is attached, so the image falls back to CSS cropping.
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		// CSS crop is still applied for capable clients...
+		$this->assertStringContainsString( 'object-fit: cover', $rendered, 'The CSS crop is preserved.' );
+		// ...and the oversized raw width no longer blows out Outlook.
+		$this->assertStringNotContainsString( 'width="2560"', $rendered, 'The oversized raw width is clamped even when cropped.' );
+	}
 }