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.' );
+ }
}