class-gallery.php
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare( strict_types = 1 );
namespace Automattic\WooCommerce\EmailEditor\Integrations\Core\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
/**
* Gallery block renderer.
* This renderer handles core/gallery blocks with proper email-friendly HTML layout.
*/
class Gallery extends Abstract_Block_Renderer {
/**
* Renders the gallery block content using a table-based layout.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @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, $columns, $layout_width );
// If we don't have any images, return empty.
if ( empty( $gallery_images ) ) {
return '';
}
// Build the email-friendly layout.
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, int $columns, int $layout_width ): array {
$gallery_images = array();
$inner_blocks = $parsed_block['innerBlocks'] ?? array();
// 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'] ) ) {
$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;
}
}
return $gallery_images;
}
/**
* 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|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 $aspect_ratio = null, int $cell_width = 0, array $image_attrs = array() ): string {
$result = '';
// First, try to find a linked image (most common case).
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] );
$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 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->prepare_image_html( $img_matches[0], $aspect_ratio, $cell_width, $image_attrs );
if ( '' !== $sanitized_img ) {
$result .= $sanitized_img;
}
}
// Extract the caption if it exists (handle both figcaption and span formats).
// Enhanced security: validate container attributes before extracting content.
if ( preg_match( '/(<figcaption[^>]*>)(.*?)(<\/figcaption>)/s', $html_content, $caption_matches ) ) {
// Validate the figcaption container attributes for security.
if ( Html_Processing_Helper::validate_container_attributes( $caption_matches[1] . $caption_matches[3] ) ) {
$sanitized_caption = Html_Processing_Helper::sanitize_caption_html( $caption_matches[2] );
$result .= '<br><div class="wp-element-caption" style="font-size: 13px; line-height: 1.0;">' . $sanitized_caption . '</div>';
}
} elseif ( preg_match( '/(<span class="wp-element-caption"[^>]*>)(.*?)(<\/span>)/s', $html_content, $caption_matches ) ) {
// Validate the span container attributes for security.
if ( Html_Processing_Helper::validate_container_attributes( $caption_matches[1] . $caption_matches[3] ) ) {
$sanitized_caption = Html_Processing_Helper::sanitize_caption_html( $caption_matches[2] );
$result .= '<br><div class="wp-element-caption" style="font-size: 13px; line-height: 1.0;">' . $sanitized_caption . '</div>';
}
}
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.
*
* 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). A crop happened only when an
// integration returned a *different*, non-empty string. Compare the raw filter result against
// the original src here, BEFORE escaping: get_attribute() returns the entity-decoded src (raw
// "&"), while esc_url() re-encodes "&" to "&", so comparing an escaped URL against the
// decoded original would misclassify any image whose src has a multi-param query string (e.g.
// "?w=600&h=600", common with image CDNs) as server-cropped.
$is_server_cropped = is_string( $filtered_url ) && '' !== $image_url && '' !== $filtered_url && $filtered_url !== $image_url;
// Escape for output only after the decision. If esc_url() reduces a hostile/invalid crop URL
// to an empty string, fall through to the CSS branch rather than emit an empty src.
$cropped_url = $is_server_cropped ? esc_url( (string) $filtered_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 ( '' !== $cropped_url ) {
// 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 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 );
}
/** @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.
*
* @param string $block_content Original block content.
* @return string Gallery caption or empty string if not found.
*/
private function extract_gallery_caption( string $block_content ): string {
// Look for gallery-level caption: <figcaption class="blocks-gallery-caption wp-element-caption">.
// Enhanced security: validate container attributes before extracting content.
if ( preg_match( '/(<figcaption class="blocks-gallery-caption[^"]*"[^>]*>)(.*?)(<\/figcaption>)/s', $block_content, $matches ) ) {
// Validate the figcaption container attributes for security.
if ( Html_Processing_Helper::validate_container_attributes( $matches[1] . $matches[3] ) ) {
return Html_Processing_Helper::sanitize_caption_html( trim( $matches[2] ) );
}
}
return '';
}
/**
* Build the email-friendly layout for gallery blocks.
*
* @param array $gallery_images Array of image HTML strings.
* @param array $parsed_block Full parsed block data.
* @param string $block_content Original block content.
* @param Rendering_Context $rendering_context Rendering context.
* @return string Rendered HTML.
*/
private function build_email_layout( array $gallery_images, array $parsed_block, string $block_content, Rendering_Context $rendering_context ): string {
// Get original wrapper classes from block content.
$original_wrapper_classname = ( new Dom_Document_Helper( $block_content ) )->get_attribute_value_by_tag_name( 'figure', 'class' ) ?? '';
// Get gallery attributes.
$block_attrs = $parsed_block['attrs'] ?? array();
$columns = $this->get_columns_from_attributes( $block_attrs );
// Extract gallery-level caption from the original block content.
$gallery_caption = $this->extract_gallery_caption( $block_content );
// Get block styles using the Styles_Helper.
$block_styles = Styles_Helper::get_block_styles( $block_attrs, $rendering_context, array( 'padding', 'border', 'background', 'background-color', 'color' ) );
$block_styles = Styles_Helper::extend_block_styles(
$block_styles,
array(
'width' => '100%',
'border-collapse' => 'collapse',
'text-align' => $rendering_context->get_default_text_align(),
)
);
// Apply class and style attributes to the wrapper table.
//
// Intentionally omit the `align` attribute. `align="left"` (or "right") on a table renders as
// `float: left` in email clients, taking the gallery out of normal flow — the block that follows
// (e.g. a heading) then fails to clear it and butts up against the gallery with no vertical gap.
// Horizontal alignment is already carried by the `text-align` declaration in $block_styles['css'],
// so dropping the attribute preserves alignment while keeping the gallery in normal flow.
$table_attrs = array(
'class' => 'email-block-gallery ' . Html_Processing_Helper::clean_css_classes( $original_wrapper_classname ),
'style' => $block_styles['css'],
'width' => '100%',
);
// Add email width to cell attributes if available.
$cell_attrs = array();
if ( isset( $parsed_block['email_attrs']['width'] ) ) {
$cell_attrs['width'] = $parsed_block['email_attrs']['width'];
}
// Build the gallery rows with proper table structure.
$gallery_content = $this->build_gallery_table( $gallery_images, $columns );
// Add gallery caption if it exists.
if ( ! empty( $gallery_caption ) ) {
$gallery_content .= '<br><div class="blocks-gallery-caption wp-element-caption" style="font-size: 13px; line-height: 1.0; text-align: center;">' . $gallery_caption . '</div>';
}
// Use Table_Wrapper_Helper for the main container (following tiled gallery pattern).
return Table_Wrapper_Helper::render_table_wrapper( $gallery_content, $table_attrs, $cell_attrs );
}
/**
* Build the gallery table structure with proper rows and cells.
* Uses the tiled gallery pattern: separate tables for each row, then wrap in main table.
*
* @param array $gallery_images Array of image HTML strings.
* @param int $columns Number of columns.
* @return string Gallery table HTML.
*/
private function build_gallery_table( array $gallery_images, int $columns ): string {
$content_parts = array();
$image_count = count( $gallery_images );
$cell_padding = 8; // 0.5em equivalent (approximately 8px)
// Process images in chunks based on columns to create rows.
for ( $i = 0; $i < $image_count; $i += $columns ) {
$row_images = array_slice( $gallery_images, $i, $columns );
$content_parts[] = $this->build_gallery_row_table( $row_images, $columns, $cell_padding );
}
return implode( '', $content_parts );
}
/**
* Build a single gallery row as a separate table (following tiled gallery pattern).
*
* @param array $row_images Images for this row.
* @param int $total_columns Total number of columns.
* @param int $cell_padding Cell padding.
* @return string Row table HTML.
*/
private function build_gallery_row_table( array $row_images, int $total_columns, int $cell_padding ): string {
$images_in_row = count( $row_images );
$row_cells = '';
// If there is exactly one image, span full width; otherwise distribute width evenly across the images in this row.
if ( 1 === $images_in_row ) {
$cell_attrs = array(
'style' => sprintf( 'width: %s; padding: %dpx; vertical-align: top; text-align: center;', Html_Processing_Helper::sanitize_css_value( '100%' ), $cell_padding ),
'valign' => 'top',
'colspan' => $total_columns,
);
$row_cells .= Table_Wrapper_Helper::render_table_cell( $row_images[0], $cell_attrs );
} else {
// Evenly distribute available width among the images in this row.
$cell_width_percent = 100 / $images_in_row;
foreach ( $row_images as $image_html ) {
$cell_attrs = array(
'style' => sprintf(
'width: %s; padding: %dpx; vertical-align: top; text-align: center;',
Html_Processing_Helper::sanitize_css_value( sprintf( '%.2f%%', $cell_width_percent ) ),
$cell_padding
),
'valign' => 'top',
);
$row_cells .= Table_Wrapper_Helper::render_table_cell( $image_html, $cell_attrs );
}
}
// Create a separate table for this row (following tiled gallery pattern).
return sprintf(
'<table role="presentation" style="width: %s; border-collapse: collapse; table-layout: fixed;"><tr>%s</tr></table>',
Html_Processing_Helper::sanitize_css_value( '100%' ),
$row_cells
);
}
/**
* Get the columns value from block attributes.
*
* @param array $block_attrs Block attributes.
* @return int Number of columns (1-8).
*/
private function get_columns_from_attributes( array $block_attrs ): int {
$columns = $block_attrs['columns'] ?? 3;
// Clamp to the same 1-8 range the core gallery block allows, so the email doesn't
// silently render fewer columns than the author chose. A lower cap forced wide galleries
// into extra partial rows whose images then stretched to a size the author never set
// (e.g. a 6-column gallery clamped to 5 rendered 5 + 5 + 2, ballooning the trailing pair).
$columns = max( 1, min( 8, (int) $columns ) );
return $columns;
}
}