Commit 18491330c27 for woocommerce

commit 18491330c276a3b73f8d76efe690a9641517dcf6
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date:   Thu Jul 16 14:00:23 2026 -0400

    [Email Editor] Fix gallery block ignoring aspect ratio crop (#66570)

    * Fix gallery block ignoring aspect ratio crop in emails

    The core/gallery renderer dropped each image's aspectRatio, so cropped
    galleries rendered at the images' natural ratio. Read the per-image and
    gallery-level aspectRatio and apply it: expose the
    woocommerce_email_editor_gallery_cropped_image_url filter so integrations
    can serve a server-side-cropped file (given concrete width/height), and
    fall back to inline aspect-ratio + object-fit CSS otherwise.

    * Add changelog entry for gallery aspect ratio crop fix

    * Fix PHPStan: match accepted_args to callback arity in gallery test

    * Address review: size gallery crop per row, harden filter and attr handling

    - Size each cropped image to its actual rendered cell width so an incomplete
      final row (e.g. a lone trailing image spanning the full width) is no longer
      served an undersized file.
    - Validate the crop filter result is a string and sanitize it before use so an
      extension returning an array/WP_Error/object can't warn, be misclassified as
      a server crop, or produce an empty src.
    - Guard nested block attrs and aspectRatio types before passing to the typed
      extraction method, preventing a TypeError from aborting rendering.
    - Broaden tests to inspect all images (not just the first) and cover the
      incomplete-row sizing case.

    * Address review: clamp crop height and validate per-image ratio override

    - Clamp the derived crop height to at least 1px so a very wide custom ratio
      can't round down to a 0-height, unusable crop.
    - Fall back to the gallery-level ratio when a per-image aspectRatio override is
      a malformed string, instead of silently disabling the crop for that image.
    - Add coverage for the invalid per-image override falling back to the gallery
      ratio.

    * Harden gallery crop: truthful src type check, document style-allowlist bypass

    - Coerce a non-string src (get_attribute can return bool true or null) to an
      empty string explicitly, dropping the inaccurate @var annotation.
    - Document that the appended crop styles intentionally bypass the image style
      sanitizer and that only the regex-validated aspect ratio may be interpolated
      there, so a future edit can't silently introduce an unvalidated value.

diff --git a/packages/php/email-editor/changelog/fix-nl-738-gallery-aspect-ratio-crop b/packages/php/email-editor/changelog/fix-nl-738-gallery-aspect-ratio-crop
new file mode 100644
index 00000000000..e7228c84576
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-nl-738-gallery-aspect-ratio-crop
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Honor the gallery block aspect ratio (crop) when rendering emails. Cropped images now fall back to CSS cropping and expose the `woocommerce_email_editor_gallery_cropped_image_url` filter so integrations can serve server-side-cropped images that render the crop in every email client.
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 6d3ccb5e179..5fb4843af19 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
@@ -28,8 +28,14 @@ class Gallery extends Abstract_Block_Renderer {
 	 * @return string
 	 */
 	protected function render_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context ): string {
+		// The number of columns and the available layout width determine how wide each cropped image
+		// is displayed. We pass that per-image width to the crop so an image CDN can serve an
+		// appropriately sized (and cropped) file.
+		$columns      = $this->get_columns_from_attributes( $parsed_block['attrs'] ?? array() );
+		$layout_width = (int) Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() );
+
 		// Extract images directly from the block content (more efficient than re-rendering).
-		$gallery_images = $this->extract_images_from_gallery_content( $block_content, $parsed_block );
+		$gallery_images = $this->extract_images_from_gallery_content( $block_content, $parsed_block, $columns, $layout_width );

 		// If we don't have any images, return empty.
 		if ( empty( $gallery_images ) ) {
@@ -40,24 +46,69 @@ class Gallery extends Abstract_Block_Renderer {
 		return $this->build_email_layout( $gallery_images, $parsed_block, $block_content, $rendering_context );
 	}

+	/**
+	 * Estimate the rendered width (in px) of the gallery cell that holds a given image.
+	 *
+	 * The gallery packs images into rows of `$columns`. A complete row splits the layout width
+	 * evenly, but an incomplete final row is distributed across only its remaining images — a lone
+	 * trailing image spans the full width (see {@see build_gallery_row_table()}). Sizing the crop to
+	 * the actual cell keeps an image CDN from serving an undersized file for such images.
+	 *
+	 * @param int $index Zero-based index of the image among the rendered images.
+	 * @param int $image_count Total number of rendered images.
+	 * @param int $columns Number of gallery columns.
+	 * @param int $layout_width Available layout width in px.
+	 * @return int Cell width in px (at least 1).
+	 */
+	private function get_cell_width( int $index, int $image_count, int $columns, int $layout_width ): int {
+		$columns       = max( 1, $columns );
+		$row_start     = intdiv( $index, $columns ) * $columns;
+		$images_in_row = max( 1, min( $columns, $image_count - $row_start ) );
+
+		return (int) max( 1, floor( $layout_width / $images_in_row ) );
+	}
+
 	/**
 	 * Extract all images from gallery content with their links and captions.
 	 *
 	 * @param string $block_content The rendered gallery block HTML.
 	 * @param array  $parsed_block The parsed block data.
+	 * @param int    $columns Number of gallery columns.
+	 * @param int    $layout_width Available layout width in px.
 	 * @return array Array of sanitized image HTML strings.
 	 */
-	private function extract_images_from_gallery_content( string $block_content, array $parsed_block ): array {
+	private function extract_images_from_gallery_content( string $block_content, array $parsed_block, int $columns, int $layout_width ): array {
 		$gallery_images = array();
 		$inner_blocks   = $parsed_block['innerBlocks'] ?? array();

-		// Extract images from inner blocks data where the actual image HTML is stored.
+		// The gallery can request a crop (aspect ratio) for all of its images. Individual images
+		// may override it with their own aspectRatio attribute. Guard against malformed input so a
+		// non-string ratio can't throw a TypeError and abort rendering.
+		$gallery_attrs        = isset( $parsed_block['attrs'] ) && is_array( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : array();
+		$gallery_aspect_ratio = isset( $gallery_attrs['aspectRatio'] ) && is_string( $gallery_attrs['aspectRatio'] ) ? $gallery_attrs['aspectRatio'] : null;
+
+		// Collect the image blocks first so we know how many land in each rendered row and can size
+		// each crop to its actual cell (incomplete final rows are wider — see get_cell_width()).
+		$image_blocks = array();
 		foreach ( $inner_blocks as $block ) {
 			if ( 'core/image' === $block['blockName'] && isset( $block['innerHTML'] ) ) {
-				$extracted_image = $this->extract_image_from_html( $block['innerHTML'] );
-				if ( ! empty( $extracted_image ) ) {
-					$gallery_images[] = $extracted_image;
-				}
+				$image_blocks[] = $block;
+			}
+		}
+		$image_count = count( $image_blocks );
+
+		foreach ( $image_blocks as $index => $block ) {
+			$image_attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
+
+			// Use the per-image override only when it is a valid ratio; a malformed override falls
+			// back to the gallery ratio instead of silently disabling the crop for that image.
+			$image_ratio  = isset( $image_attrs['aspectRatio'] ) && is_string( $image_attrs['aspectRatio'] ) ? $image_attrs['aspectRatio'] : null;
+			$aspect_ratio = ( null !== $image_ratio && null !== $this->parse_aspect_ratio( $image_ratio ) ) ? $image_ratio : $gallery_aspect_ratio;
+
+			$cell_width      = $this->get_cell_width( $index, $image_count, $columns, $layout_width );
+			$extracted_image = $this->extract_image_from_html( $block['innerHTML'], $aspect_ratio, $cell_width, $image_attrs );
+			if ( ! empty( $extracted_image ) ) {
+				$gallery_images[] = $extracted_image;
 			}
 		}

@@ -68,10 +119,13 @@ class Gallery extends Abstract_Block_Renderer {
 	 * Extract and sanitize image with optional link and caption from HTML content.
 	 * This is the unified method that handles all image extraction scenarios.
 	 *
-	 * @param string $html_content HTML content containing the image.
+	 * @param string      $html_content HTML content containing the image.
+	 * @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 Sanitized image HTML with proper link and caption handling.
 	 */
-	private function extract_image_from_html( string $html_content ): string {
+	private function extract_image_from_html( string $html_content, ?string $aspect_ratio = null, int $cell_width = 0, array $image_attrs = array() ): string {
 		$result = '';

 		// First, try to find a linked image (most common case).
@@ -79,20 +133,20 @@ class Gallery extends Abstract_Block_Renderer {
 			// Validate and sanitize the link URL.
 			$sanitized_url = esc_url( $link_matches[2] );
 			if ( ! empty( $sanitized_url ) ) {
-				$sanitized_img = Html_Processing_Helper::sanitize_image_html( $link_matches[3] );
+				$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 ) {
 					$result .= '<a href="' . $sanitized_url . '">' . $sanitized_img . '</a>';
 				}
 			} else {
 				// If URL is invalid, extract just the image without link.
-				$sanitized_img = Html_Processing_Helper::sanitize_image_html( $link_matches[3] );
+				$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 ) {
 					$result .= $sanitized_img;
 				}
 			}
 		} elseif ( preg_match( '/<img[^>]*>/', $html_content, $img_matches ) ) {
 			// Image is not linked - just extract the img element with sanitization.
-			$sanitized_img = Html_Processing_Helper::sanitize_image_html( $img_matches[0] );
+			$sanitized_img = $this->apply_aspect_ratio_crop( Html_Processing_Helper::sanitize_image_html( $img_matches[0] ), $aspect_ratio, $cell_width, $image_attrs );
 			if ( '' !== $sanitized_img ) {
 				$result .= $sanitized_img;
 			}
@@ -117,6 +171,127 @@ class Gallery extends Abstract_Block_Renderer {
 		return $result;
 	}

+	/**
+	 * Apply an aspect-ratio crop to a sanitized <img> tag.
+	 *
+	 * Email clients can't crop client-side reliably (`object-fit`/`aspect-ratio` are unsupported in
+	 * Gmail), so the only way to truly honor the crop everywhere is to serve an already-cropped image
+	 * file. This method exposes the {@see 'woocommerce_email_editor_gallery_cropped_image_url'} filter
+	 * so integrations (e.g. Jetpack/Photon on WordPress.com) can rewrite the image URL to a
+	 * server-cropped version. When that happens, the image is given concrete `width`/`height`
+	 * dimensions so it renders correctly even in clients without CSS crop support.
+	 *
+	 * When no integration crops the URL (e.g. self-hosted sites with no image CDN), the method falls
+	 * back to inline `aspect-ratio` + `object-fit: cover` CSS. Clients that support it (Apple Mail,
+	 * iOS Mail, modern webmail) render the crop; the rest fall back to the natural aspect ratio,
+	 * matching the previous behavior with no regression.
+	 *
+	 * @param string      $img_html Sanitized <img> HTML.
+	 * @param string|null $aspect_ratio Aspect ratio to apply (e.g. "1" or "4/3").
+	 * @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 Image HTML with the crop applied, or the input unchanged when no valid ratio.
+	 */
+	private function apply_aspect_ratio_crop( string $img_html, ?string $aspect_ratio, int $cell_width = 0, array $image_attrs = array() ): string {
+		if ( null === $aspect_ratio || '' === $img_html ) {
+			return $img_html;
+		}
+
+		// Only accept simple numeric ratios such as "1", "1.5" or "4/3" to avoid injecting anything unexpected.
+		$aspect_ratio = trim( $aspect_ratio );
+		$ratio_value  = $this->parse_aspect_ratio( $aspect_ratio );
+		if ( null === $ratio_value ) {
+			return $img_html;
+		}
+
+		$html = new \WP_HTML_Tag_Processor( $img_html );
+		if ( ! $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
+			return $img_html;
+		}
+
+		// Derive the target display dimensions from the cell width and the requested ratio. Clamp the
+		// height to at least 1px so a very wide ratio can't round down to a 0-height (unusable) crop.
+		$width  = $cell_width > 0 ? $cell_width : 0;
+		$height = $width > 0 ? max( 1, (int) round( $width / $ratio_value ) ) : 0;
+
+		// get_attribute() can return a string, null (absent), or bool true (valueless attribute);
+		// coerce anything that isn't a real URL string to an empty string.
+		$src_attribute = $html->get_attribute( 'src' );
+		$image_url     = is_string( $src_attribute ) ? $src_attribute : '';
+
+		/**
+		 * Filters the URL of an image inside an email gallery so integrations can serve a
+		 * server-side-cropped file that honors the block's aspect ratio.
+		 *
+		 * Email can't crop client-side reliably, so returning an already-cropped URL (e.g. an image
+		 * CDN URL with resize/crop parameters) is the only way to honor the crop in every client.
+		 * Return the URL unchanged to leave the image uncropped (the renderer then falls back to
+		 * best-effort CSS cropping).
+		 *
+		 * @param string $image_url    The original image URL.
+		 * @param string $aspect_ratio The requested aspect ratio (e.g. "1", "4/3").
+		 * @param int    $width        Target display width of the image in px (0 if unknown).
+		 * @param int    $height       Target display height derived from the aspect ratio in px (0 if unknown).
+		 * @param array  $image_attrs  Parsed attributes of the core/image block (id, sizeSlug, ...).
+		 */
+		$filtered_url = apply_filters( 'woocommerce_email_editor_gallery_cropped_image_url', $image_url, $aspect_ratio, $width, $height, $image_attrs );
+
+		// Extensions can return anything (arrays, WP_Error, objects). Only accept a string, and
+		// sanitize it before we compare or use it so an invalid value can't emit a warning, be
+		// misclassified as a server crop, or become an empty src.
+		$cropped_url = is_string( $filtered_url ) ? esc_url( $filtered_url ) : '';
+
+		$is_server_cropped = '' !== $image_url && '' !== $cropped_url && $cropped_url !== $image_url;
+
+		// These crop styles are appended after Html_Processing_Helper::sanitize_image_html() has run
+		// (its style allowlist would otherwise strip object-fit), so they intentionally bypass that
+		// sanitizer. Only the regex-validated $aspect_ratio may be interpolated here — every other
+		// token is a literal. Do not add dynamic values to $crop_styles without validating them.
+		if ( $is_server_cropped ) {
+			// The file is already cropped to the requested ratio, so we can give it concrete
+			// dimensions. This renders the crop correctly even in clients without CSS crop support.
+			$html->set_attribute( 'src', $cropped_url );
+			if ( $width > 0 && $height > 0 ) {
+				$html->set_attribute( 'width', esc_attr( (string) $width ) );
+				$html->set_attribute( 'height', esc_attr( (string) $height ) );
+			}
+			$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.
+			$crop_styles = sprintf( 'aspect-ratio: %s; object-fit: cover; width: 100%%; height: auto; display: block;', $aspect_ratio );
+		}
+
+		/** @var string $existing_style */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- used for phpstan
+		$existing_style = $html->get_attribute( 'style' ) ?? '';
+		$existing_style = '' !== $existing_style ? ( rtrim( $existing_style, ';' ) . '; ' ) : '';
+		$html->set_attribute( 'style', esc_attr( $existing_style . $crop_styles ) );
+
+		return $html->get_updated_html();
+	}
+
+	/**
+	 * Parse an aspect ratio attribute value (e.g. "1", "1.5", "4/3") into a numeric width/height ratio.
+	 *
+	 * @param string $aspect_ratio Aspect ratio value.
+	 * @return float|null The ratio (width divided by height), or null when the value is invalid.
+	 */
+	private function parse_aspect_ratio( string $aspect_ratio ): ?float {
+		// Only accept simple numeric ratios to avoid injecting anything unexpected into the markup.
+		if ( ! preg_match( '/^(\d+(?:\.\d+)?)(?:\s*\/\s*(\d+(?:\.\d+)?))?$/', trim( $aspect_ratio ), $matches ) ) {
+			return null;
+		}
+
+		$numerator   = (float) $matches[1];
+		$denominator = isset( $matches[2] ) ? (float) $matches[2] : 1.0;
+
+		if ( $numerator <= 0 || $denominator <= 0 ) {
+			return null;
+		}
+
+		return $numerator / $denominator;
+	}
+
 	/**
 	 * Extract gallery-level caption from the original block content.
 	 *
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 999b27cea76..194c699c408 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
@@ -360,4 +360,203 @@ class Gallery_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringContainsString( 'blocks-gallery-caption', $rendered );
 		$this->assertStringContainsString( 'text-align: center', $rendered );
 	}
+
+	/**
+	 * Test it applies a gallery-level aspect ratio crop to every image.
+	 */
+	public function testItAppliesGalleryLevelAspectRatioCrop(): void {
+		$parsed_gallery                         = $this->parsed_gallery;
+		$parsed_gallery['attrs']['aspectRatio'] = '1';
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		// Every image should be cropped square via inline CSS the sanitizer would otherwise strip.
+		$this->assertSame( 3, substr_count( $rendered, 'object-fit: cover' ), 'Each image should get object-fit: cover.' );
+		$this->assertSame( 3, substr_count( $rendered, 'aspect-ratio: 1' ), 'Each image should get the gallery aspect ratio.' );
+	}
+
+	/**
+	 * Test a per-image aspect ratio overrides the gallery-level one.
+	 */
+	public function testItLetsPerImageAspectRatioOverrideGalleryLevel(): void {
+		$parsed_gallery                         = $this->parsed_gallery;
+		$parsed_gallery['attrs']['aspectRatio'] = '1';
+		$parsed_gallery['innerBlocks'][0]['attrs']['aspectRatio'] = '4/3';
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		// The overriding image uses its own ratio, the others fall back to the gallery ratio.
+		$this->assertStringContainsString( 'aspect-ratio: 4/3', $rendered );
+		$this->assertSame( 2, substr_count( $rendered, 'aspect-ratio: 1;' ), 'The two non-overridden images keep the gallery ratio.' );
+		$this->assertSame( 3, substr_count( $rendered, 'object-fit: cover' ), 'All three images are still cropped.' );
+	}
+
+	/**
+	 * Test an invalid per-image aspect ratio falls back to the gallery ratio rather than
+	 * disabling the crop for that image.
+	 */
+	public function testItFallsBackToGalleryRatioWhenPerImageOverrideIsInvalid(): void {
+		$parsed_gallery                         = $this->parsed_gallery;
+		$parsed_gallery['attrs']['aspectRatio'] = '1';
+		$parsed_gallery['innerBlocks'][0]['attrs']['aspectRatio'] = 'not-a-ratio';
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		// All three images (including the one with the malformed override) use the gallery ratio.
+		$this->assertSame( 3, substr_count( $rendered, 'aspect-ratio: 1;' ), 'The malformed override falls back to the gallery ratio.' );
+		$this->assertSame( 3, substr_count( $rendered, 'object-fit: cover' ), 'Every image is still cropped.' );
+		$this->assertStringNotContainsString( 'not-a-ratio', $rendered, 'The invalid value is never emitted into the markup.' );
+	}
+
+	/**
+	 * Test galleries without an aspect ratio are left uncropped (no regression).
+	 */
+	public function testItDoesNotCropWhenNoAspectRatioIsSet(): void {
+		$rendered = $this->gallery_renderer->render( '', $this->parsed_gallery, $this->rendering_context );
+
+		$this->assertStringNotContainsString( 'object-fit', $rendered );
+		$this->assertStringNotContainsString( 'aspect-ratio', $rendered );
+	}
+
+	/**
+	 * Test an invalid aspect ratio value is ignored rather than injected into the markup.
+	 */
+	public function testItIgnoresInvalidAspectRatioValues(): void {
+		$parsed_gallery                         = $this->parsed_gallery;
+		$parsed_gallery['attrs']['aspectRatio'] = 'cover; background:url(javascript:alert(1))';
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		$this->assertStringNotContainsString( 'object-fit', $rendered );
+		$this->assertStringNotContainsString( 'aspect-ratio', $rendered );
+		$this->assertStringNotContainsString( 'javascript:', $rendered );
+	}
+
+	/**
+	 * Test the crop filter receives the image URL, ratio and target dimensions.
+	 */
+	public function testItPassesCropContextToTheImageUrlFilter(): void {
+		$parsed_gallery                         = $this->parsed_gallery;
+		$parsed_gallery['attrs']['aspectRatio'] = '1';
+		$parsed_gallery['attrs']['columns']     = 3;
+
+		$received = array();
+		$filter   = function ( $url, $aspect_ratio, $width, $height, $attrs ) use ( &$received ) {
+			$received[] = array(
+				'url'          => $url,
+				'aspect_ratio' => $aspect_ratio,
+				'width'        => $width,
+				'height'       => $height,
+				'attrs'        => $attrs,
+			);
+			return $url;
+		};
+		add_filter( 'woocommerce_email_editor_gallery_cropped_image_url', $filter, 10, 5 );
+		$this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+		remove_filter( 'woocommerce_email_editor_gallery_cropped_image_url', $filter, 10 );
+
+		$this->assertCount( 3, $received, 'The filter runs once per gallery image.' );
+		$this->assertSame( 'https://example.com/image1.jpg', $received[0]['url'] );
+		$this->assertSame( '1', $received[0]['aspect_ratio'] );
+		// Square crop: derived height equals the target width.
+		$this->assertSame( $received[0]['width'], $received[0]['height'] );
+		$this->assertGreaterThan( 0, $received[0]['width'], 'A concrete cell width is passed for CDN sizing.' );
+		$this->assertSame( 1, $received[0]['attrs']['id'], 'The image block attributes are forwarded.' );
+	}
+
+	/**
+	 * Test a server-cropped URL is used with concrete width/height dimensions.
+	 */
+	public function testItUsesServerCroppedUrlWithConcreteDimensions(): void {
+		$parsed_gallery                         = $this->parsed_gallery;
+		$parsed_gallery['attrs']['aspectRatio'] = '1';
+
+		$filter = function ( $url, $aspect_ratio, $width, $height ) {
+			return $url . '?resize=' . $width . ',' . $height . '&crop=1';
+		};
+		add_filter( 'woocommerce_email_editor_gallery_cropped_image_url', $filter, 10, 4 );
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+		remove_filter( 'woocommerce_email_editor_gallery_cropped_image_url', $filter, 10 );
+
+		// The rewritten (cropped) URL is used.
+		$this->assertStringContainsString( 'crop=1', $rendered );
+		$this->assertSame( 3, substr_count( $rendered, 'crop=1' ), 'Every image URL is rewritten.' );
+
+		// Every image (not just the first) gets equal, positive, concrete width/height attributes so
+		// a square crop renders everywhere.
+		$image_count = 0;
+		$html        = new \WP_HTML_Tag_Processor( $rendered );
+		while ( $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
+			++$image_count;
+			$width  = $html->get_attribute( 'width' );
+			$height = $html->get_attribute( 'height' );
+			$this->assertIsString( $width, 'Each server-cropped image has a concrete width.' );
+			$this->assertIsString( $height, 'Each server-cropped image has a concrete height.' );
+			$this->assertGreaterThan( 0, (int) $width, 'The width is positive.' );
+			$this->assertSame( (int) $width, (int) $height, 'A square crop has equal width and height.' );
+		}
+		$this->assertSame( 3, $image_count, 'All three images are present.' );
+	}
+
+	/**
+	 * Test an unchanged URL from the filter falls back to CSS-only cropping (no dimensions).
+	 */
+	public function testItFallsBackToCssCropWhenFilterLeavesUrlUnchanged(): void {
+		$parsed_gallery                         = $this->parsed_gallery;
+		$parsed_gallery['attrs']['aspectRatio'] = '1';
+
+		$rendered = $this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+
+		// Without a cropping integration, images keep CSS cropping and are not given fixed dimensions.
+		$this->assertStringContainsString( 'object-fit: cover', $rendered );
+
+		$image_count = 0;
+		$html        = new \WP_HTML_Tag_Processor( $rendered );
+		while ( $html->next_tag( array( 'tag_name' => 'img' ) ) ) {
+			++$image_count;
+			$this->assertNull( $html->get_attribute( 'height' ), 'Uncropped images must not get a fixed height that would distort them.' );
+		}
+		$this->assertSame( 3, $image_count, 'All three images are present.' );
+	}
+
+	/**
+	 * Test the crop is sized to the actual rendered cell width, so an incomplete final row
+	 * (e.g. a lone trailing image spanning the full width) is not served an undersized file.
+	 */
+	public function testItSizesCropToTheRenderedRowWidthForIncompleteRows(): void {
+		$parsed_gallery                         = $this->parsed_gallery;
+		$parsed_gallery['attrs']['aspectRatio'] = '1';
+		$parsed_gallery['attrs']['columns']     = 3;
+		// Add a fourth image so the final row holds a single, full-width image.
+		$parsed_gallery['innerBlocks'][3] = array(
+			'blockName'    => 'core/image',
+			'attrs'        => array(
+				'id'              => 4,
+				'sizeSlug'        => 'large',
+				'linkDestination' => 'none',
+			),
+			'innerBlocks'  => array(),
+			'innerHTML'    => '<figure class="wp-block-image size-large"><img src="https://example.com/image4.jpg" alt="Image 4" class="wp-image-4"/></figure>',
+			'innerContent' => array(
+				0 => '<figure class="wp-block-image size-large"><img src="https://example.com/image4.jpg" alt="Image 4" class="wp-image-4"/></figure>',
+			),
+		);
+
+		$widths = array();
+		$filter = function ( $url, $aspect_ratio, $width ) use ( &$widths ) {
+			$widths[] = $width;
+			return $url;
+		};
+		add_filter( 'woocommerce_email_editor_gallery_cropped_image_url', $filter, 10, 3 );
+		$this->gallery_renderer->render( '', $parsed_gallery, $this->rendering_context );
+		remove_filter( 'woocommerce_email_editor_gallery_cropped_image_url', $filter, 10 );
+
+		$this->assertCount( 4, $widths, 'The filter runs once per gallery image.' );
+		// The first three images share a full row (one-third width each); the fourth is alone in the
+		// final row and spans the full width.
+		$this->assertSame( $widths[0], $widths[1], 'Images in the same full row share a width.' );
+		$this->assertSame( $widths[1], $widths[2], 'Images in the same full row share a width.' );
+		$this->assertGreaterThan( $widths[0], $widths[3], 'A lone trailing image is sized to the full row width.' );
+		$this->assertGreaterThanOrEqual( 2 * $widths[0], $widths[3], 'The full-width cell is substantially wider than a one-third cell.' );
+	}
 }