class-border-style-postprocessor.php
<?php
/**
* This file is part of the WooCommerce Email Editor package
*
* @package Automattic\WooCommerce\EmailEditor
*/
declare(strict_types = 1);
namespace Automattic\WooCommerce\EmailEditor\Engine\Renderer\ContentRenderer\Postprocessors;
/**
* Postprocessor that handles border-style declarations to ensure consistent rendering across email clients.
*
* This postprocessor addresses two main issues:
*
* 1. Normalize border-style declarations:
* When using a uniform border-style declaration with non-uniform border-widths,
* some email clients (like Outlook) will incorrectly display borders on all sides
* even when the width is 0. For example:
* `border-color: #000000; border-style: solid; border-width: 0 1px 0 0;`
* would render borders on all sides in Outlook. This postprocessor normalizes
* the border-style declarations to only set styles for sides where border-width > 0:
* `border-color: currentColor; border-width: 0 1px 0 0; border-right-style: solid;`
*
* 2. Add fallback border styles:
* The block editor provides a default solid style for borders that have a width
* but no style specified. This postprocessor adds the same `border-style: solid`
* fallback to ensure the email rendering matches what users see in the editor.
*
* The postprocessor handles all border cases including:
* - Shorthand border declarations (border: 1px solid black)
* - Individual side declarations (border-top, border-right, etc.)
* - Individual property declarations (border-width, border-style, etc.)
* - Mixed combinations of the above
*/
class Border_Style_Postprocessor implements Postprocessor {
/**
* Postprocess the HTML.
*
* @param string $html HTML to postprocess.
* @return string
*/
public function postprocess( string $html ): string {
$html = (string) preg_replace_callback(
'/style="(.*?)"/i',
function ( $matches ) {
return 'style="' . esc_attr( $this->process_style( $matches[1] ) ) . '"';
},
$html
);
return $html;
}
/**
* Processes a style string to ensure border-style is set for borders with width > 0 and removes extra border-style properties.
*
* @param string $style The style attribute value.
* @return string
*/
private function process_style( string $style ): string {
// Parse style into associative array.
$styles = array();
foreach ( explode( ';', $style ) as $declaration ) {
if ( strpos( $declaration, ':' ) !== false ) {
list( $prop, $value ) = array_map( 'trim', explode( ':', $declaration, 2 ) );
$styles[ strtolower( $prop ) ] = $value;
}
}
$should_update_style = false;
// Collect border-widths and styles.
$border_widths = array();
$border_styles = array();
foreach ( $styles as $prop => $value ) {
if ( 'border' === $prop ) {
$border_width = $this->extract_width_from_shorthand_value( $value );
if ( $border_width ) {
$border_widths['top'] = $border_width;
$border_widths['right'] = $border_width;
$border_widths['bottom'] = $border_width;
$border_widths['left'] = $border_width;
}
$border_style = $this->extract_style_from_shorthand_value( $value );
if ( $border_style ) {
$border_styles['top'] = $border_style;
$border_styles['right'] = $border_style;
$border_styles['bottom'] = $border_style;
$border_styles['left'] = $border_style;
}
}
if ( preg_match( '/^border-(top|right|bottom|left)$/', $prop, $matches ) ) {
$border_width = $this->extract_width_from_shorthand_value( $value );
if ( $border_width ) {
$border_widths[ $matches[1] ] = $border_width;
}
$border_style = $this->extract_style_from_shorthand_value( $value );
if ( $border_style ) {
$border_styles[ $matches[1] ] = $border_style;
}
}
if ( 'border-width' === $prop ) {
$border_widths = array_merge( $border_widths, $this->expand_shorthand_value( $value ) );
}
if ( preg_match( '/^border-(top|right|bottom|left)-width$/', $prop, $matches ) ) {
$border_widths[ $matches[1] ] = $value;
}
if ( 'border-style' === $prop ) {
$border_styles = array_merge( $border_styles, $this->expand_shorthand_value( $value ) );
// Remove the original border style declaration, as it will be added later.
unset( $styles[ $prop ] );
$should_update_style = true;
}
if ( preg_match( '/^border-(top|right|bottom|left)-style$/', $prop, $matches ) ) {
$border_styles[ $matches[1] ] = $value;
// Remove the original border style declaration, as it will be added later.
unset( $styles[ $prop ] );
$should_update_style = true;
}
}
if ( array_diff( array_keys( $border_widths ), array_keys( $border_styles ) ) ) {
$should_update_style = true;
}
if ( ! $should_update_style ) {
return $style;
}
$border_styles_declarations = array();
foreach ( $border_widths as $side => $value ) {
if ( $this->is_nonzero_width( $value ) ) {
$border_styles_declarations[ "border-$side-style" ] = isset( $border_styles[ $side ] ) ? $border_styles[ $side ] : 'solid';
}
}
// If border style declarations for all sides are present and have the same value, use shorthand syntax.
if ( 4 === count( $border_styles_declarations ) && 1 === count( array_unique( $border_styles_declarations ) ) ) {
$border_styles_declarations = array( 'border-style' => array_values( $border_styles_declarations )[0] );
}
$merged_styles = array_merge( $styles, $border_styles_declarations );
$updated_style = '';
foreach ( $merged_styles as $prop => $value ) {
if ( '' !== $value ) {
$updated_style .= $prop . ': ' . $value . '; ';
}
}
return trim( $updated_style );
}
/**
* Expands shorthand border width and style values into individual properties.
*
* @param string $value The shorthand border value.
* @return array<string, string> The expanded border values.
*/
private function expand_shorthand_value( string $value ): array {
$values = preg_split( '/\s+/', trim( $value ) );
if ( ! is_array( $values ) ) {
return array();
}
$count = count( $values );
if ( 4 === $count ) {
return array(
'top' => $values[0] ?? '',
'right' => $values[1] ?? '',
'bottom' => $values[2] ?? '',
'left' => $values[3] ?? '',
);
}
if ( 3 === $count ) {
return array(
'top' => $values[0] ?? '',
'right' => $values[1] ?? '',
'bottom' => $values[2] ?? '',
'left' => $values[1] ?? '',
);
}
if ( 2 === $count ) {
return array(
'top' => $values[0] ?? '',
'right' => $values[1] ?? '',
'bottom' => $values[0] ?? '',
'left' => $values[1] ?? '',
);
}
if ( 1 === $count ) {
return array(
'top' => $values[0] ?? '',
'right' => $values[0] ?? '',
'bottom' => $values[0] ?? '',
'left' => $values[0] ?? '',
);
}
return array();
}
/**
* Extracts the width from a shorthand value.
*
* @param string $value The shorthand value.
* @return string|null The extracted width or null if no width is found.
*/
private function extract_width_from_shorthand_value( string $value ): ?string {
$parts = preg_split( '/\s+/', trim( $value ) );
if ( ! is_array( $parts ) ) {
return null;
}
foreach ( $parts as $part ) {
if ( preg_match( '/^\d+([a-z%]+)?$/', $part ) ) {
return $part;
}
}
return null;
}
/**
* Extracts the style from a shorthand value.
*
* @param string $value The shorthand value.
* @return string|null The extracted style or null if no style is found.
*/
private function extract_style_from_shorthand_value( string $value ): ?string {
$parts = preg_split( '/\s+/', trim( $value ) );
if ( ! is_array( $parts ) ) {
return null;
}
foreach ( $parts as $part ) {
if ( in_array( $part, array( 'none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset' ), true ) ) {
return $part;
}
}
return null;
}
/**
* Checks if a border width is nonzero.
*
* @param string $width The width value.
* @return bool
*/
private function is_nonzero_width( string $width ): bool {
return preg_match( '/^0([a-z%]+)?$/', trim( $width ) ) ? false : true;
}
}