class-product-image.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\WooCommerce\Renderer\Blocks;
use Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Rendering_Context;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
/**
* Renders a WooCommerce product image block for email.
*/
class Product_Image extends Abstract_Product_Block_Renderer {
/**
* Render the product image block content for email.
*
* @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 {
$product = $this->get_product_from_context( $parsed_block );
if ( ! $product ) {
return '';
}
$attributes = $this->parse_attributes( $parsed_block['attrs'] ?? array() );
$image_data = $this->get_product_image_data( $product, $attributes );
if ( ! $image_data ) {
return '';
}
$parsed_block = $this->add_image_size_when_missing( $parsed_block, $rendering_context );
$attributes = $this->parse_attributes( $parsed_block['attrs'] ?? array() );
$image_html = $this->build_image_html( $image_data, $attributes, $rendering_context );
$inner_blocks = $this->process_inner_blocks( $parsed_block, $product, $rendering_context );
$combined_content = $this->create_overlay_structure(
$image_html,
$inner_blocks['badges'],
$inner_blocks['other_content'],
$inner_blocks['badge_alignment'],
$product,
$attributes['showProductLink']
);
return $this->apply_email_wrapper( $combined_content, $parsed_block, $rendering_context );
}
/**
* Process inner blocks (like sale badges) from block content.
* Handles special positioning for email compatibility.
*
* @param array $parsed_block Parsed block.
* @param \WC_Product $product Product object.
* @param Rendering_Context $rendering_context Rendering context.
* @return array Array with 'badges' and 'other_content' keys
*/
private function process_inner_blocks( array $parsed_block, \WC_Product $product, Rendering_Context $rendering_context ): array {
$badges = '';
$other_content = '';
$badge_alignment = 'left';
if ( ! empty( $parsed_block['innerBlocks'] ) ) {
foreach ( $parsed_block['innerBlocks'] as $inner_block ) {
$inner_block['context'] = $inner_block['context'] ?? array();
$inner_block['context']['postId'] = $product->get_id();
if ( 'woocommerce/product-sale-badge' === $inner_block['blockName'] ) {
$badges .= $this->render_overlay_badge( $inner_block, $product, $rendering_context );
$badge_alignment = $inner_block['attrs']['align'] ?? 'left';
} else {
$other_content .= render_block( $inner_block );
}
}
}
return array(
'badges' => $badges,
'other_content' => $other_content,
'badge_alignment' => $badge_alignment,
);
}
/**
* Render a sale badge with email-compatible overlay positioning.
*
* @param array $badge_block Badge block data.
* @param \WC_Product $product Product object.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
private function render_overlay_badge( array $badge_block, \WC_Product $product, Rendering_Context $rendering_context ): string {
if ( ! $product->is_on_sale() ) {
return '';
}
$sale_text = apply_filters( 'woocommerce_sale_badge_text', __( 'Sale', 'woocommerce' ), $product );
$badge_attributes = array_replace_recursive(
array(
'textColor' => '#43454b',
'backgroundColor' => '#fff',
'style' => array(
'border' => array(
'width' => '1px',
'radius' => '4px',
'color' => '#43454b',
),
'spacing' => array(
'padding' => '4px 12px',
),
'typography' => array(
'fontSize' => '14px',
'fontWeight' => '600',
'textTransform' => 'uppercase',
'lineHeight' => '1.5',
),
),
),
wp_parse_args( $badge_block['attrs'] ?? array() )
);
$block_styles = Styles_Helper::get_block_styles(
$badge_attributes,
$rendering_context,
array( 'border', 'background-color', 'color', 'typography', 'spacing' )
);
$additional_styles = array(
'display' => 'inline-block',
'width' => 'fit-content',
'box-sizing' => 'border-box',
);
$final_styles = Styles_Helper::extend_block_styles( $block_styles, $additional_styles );
return sprintf(
'<span class="wc-block-components-product-sale-badge__text" style="%s">%s</span>',
esc_attr( $final_styles['css'] ),
esc_html( $sale_text )
);
}
/**
* Create overlay structure for email compatibility.
* Uses Faux Absolute Position with badge-below fallback for better cross-client support.
*
* @param string $image_html Image HTML.
* @param string $badges_html Badges HTML.
* @param string $other_content Other inner content.
* @param string $badge_alignment Badge alignment.
* @param \WC_Product|null $product Product object for link.
* @param bool $show_product_link Whether to show product link.
* @return string
*/
private function create_overlay_structure(
string $image_html,
string $badges_html,
string $other_content,
string $badge_alignment,
?\WC_Product $product = null,
bool $show_product_link = false
): string {
if ( empty( $badges_html ) ) {
$linked_image_html = $image_html;
if ( $show_product_link && $product ) {
$linked_image_html = $this->wrap_with_link( $image_html, $product );
}
return $linked_image_html . $other_content;
}
$image_width = $this->extract_image_width( $image_html );
$image_height = $this->extract_image_height( $image_html );
$linked_image_html = $image_html;
if ( $show_product_link && $product ) {
$linked_image_html = $this->wrap_with_link( $image_html, $product );
}
$vml_side = ( 'left' === $badge_alignment ) ? 'left' : 'right';
$overlay_html = sprintf(
'<table cellpadding="0" cellspacing="0" border="0" style="width: %dpx; height: %dpx; table-layout: fixed;">
<tr>
<td style="font-size: 0; line-height: 0; padding: 0; height: %dpx; width: %dpx;">
<div style="max-height:0; position:relative; opacity:0.999;">
<!--[if mso]>
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" stroked="false" filled="false" style="mso-width-percent: 1000; position:absolute; top:16px; ' . esc_attr( $vml_side ) . ':16px;">
<v:textbox inset="0,0,0,0">
<![endif]-->
<div style="padding: 12px; box-sizing: border-box; display: inline-block; width: 100%%; text-align: %s;">
%s
</div>
<!--[if mso]>
</v:textbox>
</v:rect>
<![endif]-->
</div>
%s
</td>
</tr>
</table>%s',
$image_width,
$image_height,
$image_height,
$image_width,
$badge_alignment,
$badges_html,
$linked_image_html,
$other_content
);
return $overlay_html;
}
/**
* Extract image width from HTML for positioning calculations.
*
* @param string $image_html Image HTML.
* @return float Image width in pixels.
*/
private function extract_image_width( string $image_html ): float {
$width = ( new Dom_Document_Helper( $image_html ) )->get_attribute_value_by_tag_name( 'img', 'width' ) ?? '';
if ( $width ) {
return Styles_Helper::parse_value( $width );
}
return 300;
}
/**
* Extract image height from HTML for positioning calculations.
*
* @param string $image_html Image HTML.
* @return float Image height in pixels.
*/
private function extract_image_height( string $image_html ): float {
$height = ( new Dom_Document_Helper( $image_html ) )->get_attribute_value_by_tag_name( 'img', 'height' ) ?? '';
if ( $height ) {
return Styles_Helper::parse_value( $height );
}
return 300;
}
/**
* When the width is not set, it's important to get it for the image to be displayed correctly.
* Based on the email Image renderer logic.
*
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return array
*/
private function add_image_size_when_missing( array $parsed_block, Rendering_Context $rendering_context ): array {
if ( isset( $parsed_block['attrs']['width'] ) ) {
return $parsed_block;
}
if ( ! isset( $parsed_block['email_attrs']['width'] ) ) {
$parsed_block['attrs']['width'] = '100%';
return $parsed_block;
}
$parsed_block['attrs']['width'] = $rendering_context->get_layout_width_without_padding();
return $parsed_block;
}
/**
* Parse block attributes with defaults.
*
* @param array $attributes Block attributes.
* @return array
*/
private function parse_attributes( array $attributes ): array {
return wp_parse_args(
$attributes,
array(
'showProductLink' => true,
'imageSizing' => 'single',
'scale' => 'cover',
'showSaleBadge' => false,
'saleBadgeAlign' => 'right',
)
);
}
/**
* Get product image data.
*
* @param \WC_Product $product Product object.
* @param array $attributes Parsed attributes.
* @return array|null
*/
private function get_product_image_data( \WC_Product $product, array $attributes ): ?array {
$image_size = 'single' === $attributes['imageSizing'] ? 'woocommerce_single' : 'woocommerce_thumbnail';
$image_id = (int) $product->get_image_id();
if ( ! $image_id ) {
$placeholder = wc_placeholder_img_src( $image_size );
return array(
'url' => $placeholder,
'alt' => $product->get_name(),
'width' => 300,
'height' => 300,
);
}
$image_url = wp_get_attachment_image_url( $image_id, $image_size );
if ( ! $image_url ) {
return null;
}
$alt_text = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
$image_meta = wp_get_attachment_metadata( $image_id );
return array(
'url' => $image_url,
'alt' => $alt_text ? $alt_text : $product->get_name(),
'width' => $image_meta['width'] ?? 300,
'height' => $image_meta['height'] ?? 300,
);
}
/**
* Build email-compatible image HTML.
*
* @param array $image_data Image data.
* @param array $attributes Block attributes.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
private function build_image_html( array $image_data, array $attributes, Rendering_Context $rendering_context ): string {
$style_parts = array(
'max-width' => '100%',
'height' => 'auto',
'display' => 'block',
);
if ( ! empty( $attributes['scale'] ) ) {
$style_parts['object-fit'] = $attributes['scale'];
}
if ( ! empty( $attributes['width'] ) ) {
$style_parts['width'] = $attributes['width'];
}
if ( ! empty( $attributes['height'] ) ) {
$style_parts['height'] = $attributes['height'];
}
if ( ! empty( $attributes['aspectRatio'] ) ) {
$style_parts['aspect-ratio'] = $attributes['aspectRatio'];
}
$width = ! empty( $attributes['width'] ) ? Styles_Helper::parse_value( $attributes['width'] ) : $image_data['width'];
$layout_width = Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() );
if ( $width > $layout_width ) {
$width = $layout_width;
$aspect_ratio = $image_data['height'] / $image_data['width'];
$attributes['height'] = round( $width * $aspect_ratio ) . 'px';
}
$height = $image_data['height'];
if ( ! empty( $attributes['height'] ) ) {
$height = Styles_Helper::parse_value( $attributes['height'] );
} elseif ( ! empty( $attributes['width'] ) && $image_data['width'] > 0 ) {
$aspect_ratio = $image_data['height'] / $image_data['width'];
$height = round( $width * $aspect_ratio );
}
return sprintf(
'<img class="email-editor-product-image skip-lazy" data-skip-lazy="1" loading="eager" decoding="auto" src="%s" alt="%s" style="%s" width="%d" height="%d" />',
esc_url( $image_data['url'] ),
esc_attr( $image_data['alt'] ),
esc_attr( \WP_Style_Engine::compile_css( $style_parts, '' ) ),
$width,
$height
);
}
/**
* Wrap image with product link.
*
* @param string $image_html Image HTML.
* @param \WC_Product $product Product object.
* @return string
*/
private function wrap_with_link( string $image_html, \WC_Product $product ): string {
$product_url = $product->get_permalink();
return sprintf(
'<a href="%s" style="display: block; text-decoration: none;">%s</a>',
esc_url( $product_url ),
$image_html
);
}
/**
* Apply email-compatible table wrapper (similar to Image renderer).
*
* @param string $image_html Image HTML.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string
*/
private function apply_email_wrapper( string $image_html, array $parsed_block, Rendering_Context $rendering_context ): string {
$width = $parsed_block['attrs']['width'] ?? '';
$wrapper_width = ( $width && '100%' !== $width ) ? $width : 'auto';
$image_height = $this->extract_image_height( $image_html ) . 'px';
$wrapper_styles = array(
'border-collapse' => 'separate',
'width' => $wrapper_width,
);
$cell_styles = array(
'overflow' => 'hidden',
'vertical-align' => 'top',
);
$align = $parsed_block['attrs']['align'] ?? 'left';
$cell_styles['text-align'] = $align;
$outer_table_attrs = array(
'style' => \WP_Style_Engine::compile_css(
array(
'border-collapse' => 'collapse',
'border-spacing' => '0px',
'width' => '100%',
'height' => $image_height,
),
''
),
'width' => '100%',
);
$outer_cell_attrs = array(
'align' => $align,
);
$inner_table_attrs = array(
'style' => \WP_Style_Engine::compile_css( $wrapper_styles, '' ),
'width' => $wrapper_width,
'height' => $image_height,
);
$inner_cell_attrs = array(
'style' => \WP_Style_Engine::compile_css( $cell_styles, '' ),
);
$inner_content = Table_Wrapper_Helper::render_table_wrapper( $image_html, $inner_table_attrs, $inner_cell_attrs );
return Table_Wrapper_Helper::render_table_wrapper( $inner_content, $outer_table_attrs, $outer_cell_attrs );
}
}