class-table.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\Dom_Document_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper;
use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper;
/**
* Renders a table block.
*/
class Table extends Abstract_Block_Renderer {
/**
* Valid text alignment values.
*/
private const VALID_TEXT_ALIGNMENTS = array( 'left', 'center', 'right' );
/**
* Renders the block content.
*
* @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 {
// Extract table content and caption from figure wrapper if present.
$extracted_data = $this->extract_table_and_caption_from_figure( $block_content );
$table_content = $extracted_data['table'];
$caption = $extracted_data['caption'];
// Validate that we have actual table content.
if ( ! $this->is_valid_table_content( $table_content ) ) {
return '';
}
// Check for empty table structures - tables with no th or td elements.
if ( ! preg_match( '/<(th|td)/i', $table_content ) ) {
return '';
}
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
array(
'textAlign' => 'left',
'style' => array(),
)
);
$html = new \WP_HTML_Tag_Processor( $table_content );
$classes = 'email-table-block';
if ( $html->next_tag() ) {
$block_classes = (string) ( $html->get_attribute( 'class' ) ?? '' );
$classes .= ' ' . $block_classes;
// Clean classes for table element.
$block_classes = Html_Processing_Helper::clean_css_classes( $block_classes );
$html->set_attribute( 'class', $block_classes );
$table_content = $html->get_updated_html();
}
// Clean wrapper classes.
$classes = Html_Processing_Helper::clean_css_classes( $classes );
// Get spacing styles for wrapper and table-specific styles separately.
$spacing_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'spacing' ) );
$table_styles = Styles_Helper::get_block_styles( $block_attributes, $rendering_context, array( 'background-color', 'color', 'typography' ) );
// Ensure background styles are completely removed from spacing styles and force transparent background.
$spacing_css = $spacing_styles['css'] ?? '';
$spacing_css = (string) ( preg_replace( '/background[^;]*;?/', '', $spacing_css ) ?? '' );
$spacing_css = (string) ( preg_replace( '/\s*;\s*;/', ';', $spacing_css ) ?? '' ); // Clean up double semicolons.
$spacing_css = trim( $spacing_css, '; ' );
// Force transparent background on wrapper to prevent any background leakage.
$spacing_styles['css'] = $spacing_css ? $spacing_css . '; background: transparent !important;' : 'background: transparent !important;';
$additional_styles = array(
'min-width' => '100%', // Prevent Gmail App from shrinking the table on mobile devices.
);
// Add fallback text color when no custom text color or preset text color is set.
if ( empty( $table_styles['declarations']['color'] ) ) {
$email_styles = $rendering_context->get_theme_styles();
$color = $parsed_block['email_attrs']['color'] ?? $email_styles['color']['text'] ?? '#000000';
// Sanitize color value to ensure it's a valid hex color.
$additional_styles['color'] = Html_Processing_Helper::sanitize_color( $color );
}
$additional_styles['text-align'] = 'left';
if ( ! empty( $parsed_block['attrs']['textAlign'] ) ) { // In this case, textAlign needs to be one of 'left', 'center', 'right'.
$text_align = $parsed_block['attrs']['textAlign'];
if ( in_array( $text_align, self::VALID_TEXT_ALIGNMENTS, true ) ) {
$additional_styles['text-align'] = $text_align;
}
} elseif ( in_array( $parsed_block['attrs']['align'] ?? null, self::VALID_TEXT_ALIGNMENTS, true ) ) {
$additional_styles['text-align'] = $parsed_block['attrs']['align'];
}
$table_styles = Styles_Helper::extend_block_styles( $table_styles, $additional_styles );
// Check if this is a striped table style.
$is_striped_table = $this->is_striped_table( $block_content, $parsed_block );
// Process the table content to ensure email compatibility BEFORE wrapping.
$table_content = $this->process_table_content( $table_content, $parsed_block, $rendering_context, $is_striped_table );
// Apply table-specific styles (background, color, typography) directly to the table element.
$table_content_with_styles = $this->apply_styles_to_table_element( $table_content, $table_styles['css'] );
// Add wp-block-table class to the table element for theme.json CSS rules.
if ( false !== strpos( $block_content, 'wp-block-table' ) ) {
$table_content_with_styles = $this->add_class_to_table_element( $table_content_with_styles, 'wp-block-table' );
}
// Build complete content (table + caption).
$complete_content = $table_content_with_styles;
if ( ! empty( $caption ) ) {
// Use HTML API to safely allow specific tags in caption.
$sanitized_caption = Html_Processing_Helper::sanitize_caption_html( $caption );
// Extract typography styles from table styles (not spacing styles) and apply to caption.
$caption_styles = $this->extract_typography_styles_for_caption( $table_styles['css'] );
$complete_content .= '<div style="text-align: center; margin-top: 8px; ' . $caption_styles . '">' . $sanitized_caption . '</div>';
}
$table_attrs = array(
'style' => 'border-collapse: separate;', // Needed because of border radius.
'width' => '100%',
);
// Use spacing styles only for the wrapper.
$cell_attrs = array(
'class' => $classes,
'style' => $spacing_styles['css'],
'align' => $additional_styles['text-align'],
);
$rendered_table = Table_Wrapper_Helper::render_table_wrapper( $complete_content, $table_attrs, $cell_attrs );
return $rendered_table;
}
/**
* Process table content to ensure email client compatibility.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @param bool $is_striped_table Whether this is a striped table.
* @return string
*/
private function process_table_content( string $block_content, array $parsed_block, Rendering_Context $rendering_context, bool $is_striped_table = false ): string {
$html = new \WP_HTML_Tag_Processor( $block_content );
// Extract custom border color and width from block attributes.
$custom_border_color = $this->get_custom_border_color( $parsed_block, $rendering_context );
$custom_border_width = $this->get_custom_border_width( $parsed_block );
// Use custom border color if available, otherwise fall back to default.
if ( $custom_border_color ) {
$border_color = $custom_border_color;
} else {
// Get theme styles once to avoid repeated calls.
$email_styles = $rendering_context->get_theme_styles();
$border_color = Html_Processing_Helper::sanitize_color( $parsed_block['email_attrs']['color'] ?? $email_styles['color']['text'] ?? '#000000' );
}
// Track row context for striped styling.
$current_section = ''; // Table sections: thead, tbody, tfoot.
$row_count = 0;
// Process table elements.
while ( $html->next_tag() ) {
$tag_name = $html->get_tag();
if ( 'TABLE' === $tag_name ) {
// Ensure table has proper email attributes.
$html->set_attribute( 'border', '1' );
$html->set_attribute( 'cellpadding', '8' );
$html->set_attribute( 'cellspacing', '0' );
$html->set_attribute( 'role', 'presentation' );
$html->set_attribute( 'width', '100%' );
// Get existing style and add email-specific styles.
$existing_style = (string) ( $html->get_attribute( 'style' ) ?? '' );
// Check for fixed layout class and apply table-layout: fixed.
$class_attr = (string) ( $html->get_attribute( 'class' ) ?? '' );
$table_layout = $this->has_fixed_layout( $class_attr ) ? 'table-layout: fixed; ' : '';
// Use border-collapse: collapse to ensure consistent borders between table and cells.
$email_table_styles = "{$table_layout}border-collapse: collapse; width: 100%;";
$existing_style = rtrim( $existing_style, "; \t\n\r\0\x0B" );
$new_style = $existing_style ? $existing_style . '; ' . $email_table_styles : $email_table_styles;
$html->set_attribute( 'style', $new_style );
// Remove problematic classes from the table but keep has-fixed-layout and alignment classes for editor UI.
$class_attr = Html_Processing_Helper::clean_css_classes( $class_attr );
$html->set_attribute( 'class', $class_attr );
} elseif ( 'THEAD' === $tag_name ) {
$current_section = 'thead';
$row_count = 0;
} elseif ( 'TBODY' === $tag_name ) {
$current_section = 'tbody';
$row_count = 0;
} elseif ( 'TFOOT' === $tag_name ) {
$current_section = 'tfoot';
$row_count = 0;
} elseif ( 'TR' === $tag_name ) {
++$row_count;
} elseif ( 'TD' === $tag_name || 'TH' === $tag_name ) {
// Ensure table cells have proper email attributes with borders and padding.
$html->set_attribute( 'valign', 'top' );
// Get existing style and add email-specific styles with borders and padding.
$existing_style = (string) ( $html->get_attribute( 'style' ) ?? '' );
$existing_style = rtrim( $existing_style, "; \t\n\r\0\x0B" );
$border_width = $custom_border_width ? $custom_border_width : '1px';
$border_style = $this->get_custom_border_style( $parsed_block );
// Extract cell-specific text alignment.
$cell_text_align = $this->get_cell_text_alignment( $html );
$email_cell_styles = "vertical-align: top; border: {$border_width} {$border_style} {$border_color}; padding: 8px; text-align: {$cell_text_align};";
// Add thicker borders for header and footer cells when no custom border is set.
$email_cell_styles = $this->add_header_footer_borders( $html, $email_cell_styles, $border_color, $current_section, $custom_border_width );
// Add striped styling for tbody rows (first row gets background, then alternates).
if ( $is_striped_table && 'tbody' === $current_section && 1 === $row_count % 2 ) {
$email_cell_styles .= ' background-color: #f8f9fa;';
}
$new_cell_style = $existing_style ? $existing_style . '; ' . $email_cell_styles : $email_cell_styles;
$html->set_attribute( 'style', $new_cell_style );
}
}
return $html->get_updated_html();
}
/**
* Get custom border color from block attributes.
*
* @param array $parsed_block Parsed block.
* @param Rendering_Context $rendering_context Rendering context.
* @return string|null Custom border color or null if not set.
*/
private function get_custom_border_color( array $parsed_block, Rendering_Context $rendering_context ): ?string {
$block_attributes = $parsed_block['attrs'] ?? array();
if ( ! empty( $block_attributes['borderColor'] ) ) {
$border_color = $rendering_context->translate_slug_to_color( $block_attributes['borderColor'] );
return Html_Processing_Helper::sanitize_color( $border_color );
}
return null;
}
/**
* Get custom border width from block attributes.
*
* @param array $parsed_block Parsed block.
* @return string|null Custom border width or null if not set.
*/
private function get_custom_border_width( array $parsed_block ): ?string {
$block_attributes = $parsed_block['attrs'] ?? array();
if ( ! empty( $block_attributes['style']['border']['width'] ) ) {
$border_width = $block_attributes['style']['border']['width'];
// Sanitize the border width value.
$border_width = Html_Processing_Helper::sanitize_css_value( $border_width );
if ( empty( $border_width ) ) {
return null;
}
// Ensure the border width has a unit, default to px if not specified.
if ( is_numeric( $border_width ) ) {
return $border_width . 'px';
}
// Validate that the border width contains only valid CSS units and numbers.
if ( preg_match( '/^[0-9]+\.?[0-9]*(px|em|rem|pt|pc|in|cm|mm|ex|ch|vw|vh|vmin|vmax)$/', $border_width ) ) {
return $border_width;
}
// If invalid, return null to use default.
return null;
}
return null;
}
/**
* Get custom border style from block attributes.
*
* @param array $parsed_block Parsed block.
* @return string Custom border style or 'solid' as default.
*/
private function get_custom_border_style( array $parsed_block ): string {
$style = strtolower( (string) ( $parsed_block['attrs']['style']['border']['style'] ?? '' ) );
$allowed = array( 'solid', 'dashed', 'dotted' ); // Email-safe subset.
return in_array( $style, $allowed, true ) ? $style : 'solid';
}
/**
* Add thicker borders for table headers and footers when no custom border is set.
*
* @param \WP_HTML_Tag_Processor $html HTML tag processor.
* @param string $base_styles Base cell styles.
* @param string $border_color Border color.
* @param string $current_section Current table section (thead, tbody, tfoot).
* @param string|null $custom_border_width Custom border width if set.
* @return string Updated cell styles.
*/
private function add_header_footer_borders( \WP_HTML_Tag_Processor $html, string $base_styles, string $border_color, string $current_section = '', ?string $custom_border_width = null ): string {
$tag_name = $html->get_tag();
// Only add thicker borders if no custom border width is set.
if ( $custom_border_width ) {
return $base_styles;
}
// Add thicker bottom border to all TH elements (headers).
if ( 'TH' === $tag_name ) {
$base_styles .= " border-bottom: 3px solid {$border_color};";
}
// Add thicker top border to footer cells (TD elements in tfoot).
if ( 'TD' === $tag_name && 'tfoot' === $current_section ) {
$base_styles .= " border-top: 3px solid {$border_color};";
}
return $base_styles;
}
/**
* Get text alignment for a table cell.
*
* @param \WP_HTML_Tag_Processor $html HTML tag processor.
* @return string Text alignment value (left, center, right).
*/
private function get_cell_text_alignment( \WP_HTML_Tag_Processor $html ): string {
// Check for data-align attribute first.
$data_align = $html->get_attribute( 'data-align' );
if ( $data_align && in_array( $data_align, self::VALID_TEXT_ALIGNMENTS, true ) ) {
return $data_align;
}
// Check for has-text-align-* classes.
$class_attr = (string) ( $html->get_attribute( 'class' ) ?? '' );
if ( false !== strpos( $class_attr, 'has-text-align-center' ) ) {
return 'center';
}
if ( false !== strpos( $class_attr, 'has-text-align-right' ) ) {
return 'right';
}
if ( false !== strpos( $class_attr, 'has-text-align-left' ) ) {
return 'left';
}
// Default to left alignment.
return 'left';
}
/**
* Check if table has fixed layout class.
*
* @param string $class_attr Class attribute string.
* @return bool True if has-fixed-layout class is present.
*/
private function has_fixed_layout( string $class_attr ): bool {
return false !== strpos( $class_attr, 'has-fixed-layout' );
}
/**
* Extract table content and caption from figure wrapper if present.
*
* @param string $block_content Block content.
* @return array Array with 'table' and 'caption' keys.
*/
private function extract_table_and_caption_from_figure( string $block_content ): array {
$dom_helper = new Dom_Document_Helper( $block_content );
// Look for figure element with wp-block-table class.
$figure_tag = $dom_helper->find_element( 'figure' );
if ( ! $figure_tag ) {
// If no figure wrapper found, return original content as table.
return array(
'table' => $block_content,
'caption' => '',
);
}
$figure_class_attr = $dom_helper->get_attribute_value( $figure_tag, 'class' );
$figure_class = (string) ( $figure_class_attr ? $figure_class_attr : '' );
if ( false === strpos( $figure_class, 'wp-block-table' ) ) {
// If figure doesn't have wp-block-table class, return original content as table.
return array(
'table' => $block_content,
'caption' => '',
);
}
// Extract table element from within the matched figure only.
$figure_html = $dom_helper->get_outer_html( $figure_tag );
// Use regex to extract table from within the figure to avoid document conflicts.
if ( ! preg_match( '/<table[^>]*>.*?<\/table>/is', $figure_html, $table_matches ) ) {
return array(
'table' => $block_content,
'caption' => '',
);
}
$table_html = $table_matches[0];
// Extract figcaption if present (scoped to the figure).
$caption = '';
if ( preg_match( '/<figcaption[^>]*>(.*?)<\/figcaption>/is', $figure_html, $figcaption_matches ) ) {
$caption = $figcaption_matches[1];
}
return array(
'table' => $table_html,
'caption' => $caption,
);
}
/**
* Apply CSS styles directly to the table element.
*
* @param string $table_content Table HTML content.
* @param string $styles CSS styles to apply.
* @return string Table content with styles applied.
*/
private function apply_styles_to_table_element( string $table_content, string $styles ): string {
$html = new \WP_HTML_Tag_Processor( $table_content );
if ( $html->next_tag( array( 'tag_name' => 'TABLE' ) ) ) {
$existing_style = (string) ( $html->get_attribute( 'style' ) ?? '' );
$existing_style = rtrim( $existing_style, "; \t\n\r\0\x0B" );
// Add default border widths if individual border colors are present but no widths.
$border_width_styles = $this->get_default_border_widths( $existing_style );
$new_style = $existing_style;
if ( ! empty( $border_width_styles ) ) {
$new_style = $new_style ? $new_style . '; ' . $border_width_styles : $border_width_styles;
}
if ( ! empty( $styles ) ) {
$new_style = $new_style ? $new_style . '; ' . $styles : $styles;
}
$html->set_attribute( 'style', $new_style );
return $html->get_updated_html();
}
return $table_content;
}
/**
* Get default border widths for table element when individual border colors are present.
*
* @param string $existing_style Existing style attribute of the table element.
* @return string CSS border width styles or empty string if not needed.
*/
private function get_default_border_widths( string $existing_style ): string {
// Check if individual border colors are present but no corresponding widths.
$sides = array( 'top', 'right', 'bottom', 'left' );
$border_width_styles = array();
foreach ( $sides as $side ) {
$has_color = strpos( $existing_style, "border-{$side}-color:" ) !== false;
$has_width = strpos( $existing_style, "border-{$side}-width:" ) !== false;
// If border color is present but no width, add default width.
if ( $has_color && ! $has_width ) {
$border_width_styles[] = "border-{$side}-width: 1.5px";
}
}
return implode( '; ', $border_width_styles );
}
/**
* Add a CSS class to the table element.
*
* @param string $table_content Table HTML content.
* @param string $class_name CSS class to add.
* @return string Table content with class added.
*/
private function add_class_to_table_element( string $table_content, string $class_name ): string {
// Validate class name to prevent XSS.
if ( ! preg_match( '/^[a-zA-Z0-9\-_]+$/', $class_name ) ) {
return $table_content;
}
$html = new \WP_HTML_Tag_Processor( $table_content );
if ( $html->next_tag( array( 'tag_name' => 'TABLE' ) ) ) {
$existing_class = (string) ( $html->get_attribute( 'class' ) ?? '' );
$existing_class = trim( $existing_class );
// Only add if not already present.
if ( false === strpos( $existing_class, $class_name ) ) {
$new_class = $existing_class ? $existing_class . ' ' . $class_name : $class_name;
$html->set_attribute( 'class', $new_class );
}
return $html->get_updated_html();
}
return $table_content;
}
/**
* Extract typography styles from CSS string for caption.
*
* @param string $css CSS string to extract typography from.
* @return string Typography CSS for caption.
*/
private function extract_typography_styles_for_caption( string $css ): string {
$typography_properties = Html_Processing_Helper::get_caption_css_properties();
$caption_styles = array();
foreach ( $typography_properties as $property ) {
// Use regex to extract each typography property.
if ( preg_match( '/' . preg_quote( $property, '/' ) . '\s*:\s*([^;]+)/i', $css, $matches ) ) {
$value = trim( $matches[1] );
// Sanitize the CSS value to prevent injection.
$sanitized_value = Html_Processing_Helper::sanitize_css_value( $value );
if ( ! empty( $sanitized_value ) ) {
$caption_styles[] = $property . ': ' . $sanitized_value;
}
}
}
return implode( '; ', $caption_styles );
}
/**
* Check if the table has striped styling.
*
* @param string $block_content Block content.
* @param array $parsed_block Parsed block.
* @return bool True if it's a striped table, false otherwise.
*/
private function is_striped_table( string $block_content, array $parsed_block ): bool {
// Check for is-style-stripes in block attributes.
if ( isset( $parsed_block['attrs']['className'] ) && false !== strpos( $parsed_block['attrs']['className'], 'is-style-stripes' ) ) {
return true;
}
// Check for is-style-stripes in figure classes.
if ( false !== strpos( $block_content, 'is-style-stripes' ) ) {
return true;
}
return false;
}
/**
* Validate if the content is a valid table HTML.
*
* @param string $content The content to validate.
* @return bool True if it's a valid table, false otherwise.
*/
private function is_valid_table_content( string $content ): bool {
// Only assert that a <table> exists; downstream checks handle emptiness and KSES handles sanitization.
return (bool) preg_match( '/<table[^>]*>.*?<\/table>/is', $content );
}
}