Commit c5d3ea31236 for woocommerce
commit c5d3ea312360f18d048d800d9a02edad05460b5d
Author: Yuliyan Slavchev <yuliyan.slavchev@gmail.com>
Date: Mon Jul 13 15:25:55 2026 +0300
Improve email editor layout rendering parity (#66165)
* Improve email editor layout rendering parity
* Adjust inner border radius on image blocks
diff --git a/packages/php/email-editor/changelog/fix-email-editor-layout-rendering-parity b/packages/php/email-editor/changelog/fix-email-editor-layout-rendering-parity
new file mode 100644
index 00000000000..88312ea8afc
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-email-editor-layout-rendering-parity
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Improve email editor layout rendering for better parity with the editor: include padding and borders in inner (e.g. image) width calculations so bordered columns no longer overflow, nest a padded group's root and container padding instead of stacking them on each block, and stop using the global vertical block spacing as a horizontal gap between columns.
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-blocks-width-preprocessor.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-blocks-width-preprocessor.php
index 626bd5285ec..ecdd47394a0 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-blocks-width-preprocessor.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-blocks-width-preprocessor.php
@@ -77,6 +77,12 @@ class Blocks_Width_Preprocessor implements Preprocessor {
$block_padding_left = $suppress_h_padding ? '0px' : Preset_Variable_Resolver::resolve( $block['attrs']['style']['spacing']['padding']['left'] ?? '0px', $variables_map );
$block_padding_right = $suppress_h_padding ? '0px' : Preset_Variable_Resolver::resolve( $block['attrs']['style']['spacing']['padding']['right'] ?? '0px', $variables_map );
+ // Horizontal border reduces the content area available to inner blocks,
+ // just like padding does. It must be subtracted so fixed-width children
+ // (e.g. images, which emit an explicit width attribute) fit inside the
+ // block instead of forcing the surrounding table to expand.
+ $block_border_horizontal = $this->get_block_horizontal_border( $block );
+
$width_input = $block['attrs']['width'] ?? '100%';
// Currently we support only % and px units in case only the number is provided we assume it's %
// because editor saves percent values as a number.
@@ -96,8 +102,12 @@ class Blocks_Width_Preprocessor implements Preprocessor {
}
// Copy layout styles and update width and padding with resolved values.
+ // The content area available to inner blocks is the block width minus its
+ // own horizontal border (padding is subtracted from this in the next level
+ // via $modified_styles).
+ $inner_content_width = max( 0, $width - $block_border_horizontal );
$modified_layout = $layout;
- $modified_layout['contentSize'] = "{$width}px";
+ $modified_layout['contentSize'] = "{$inner_content_width}px";
$modified_styles = $styles;
$modified_styles['spacing']['padding']['left'] = $block_padding_left;
$modified_styles['spacing']['padding']['right'] = $block_padding_right;
@@ -139,6 +149,24 @@ class Blocks_Width_Preprocessor implements Preprocessor {
return (float) str_replace( 'px', '', $value );
}
+ /**
+ * Sum a block's left and right border widths.
+ *
+ * Supports both the shorthand `border.width` and the per-side
+ * `border.left.width` / `border.right.width` formats, matching how the
+ * columns block border is read elsewhere in this class.
+ *
+ * @param array $block Parsed block.
+ * @return float Combined horizontal border width in pixels.
+ */
+ private function get_block_horizontal_border( array $block ): float {
+ $border_width = $block['attrs']['style']['border']['width'] ?? '0px';
+ $left = $block['attrs']['style']['border']['left']['width'] ?? $border_width;
+ $right = $block['attrs']['style']['border']['right']['width'] ?? $border_width;
+
+ return $this->parse_number_from_string_with_pixels( (string) $left ) + $this->parse_number_from_string_with_pixels( (string) $right );
+ }
+
/**
* Add missing column widths
*
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-spacing-preprocessor.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-spacing-preprocessor.php
index 62cb7e6dcdb..4a8269bb065 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-spacing-preprocessor.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Preprocessors/class-spacing-preprocessor.php
@@ -114,22 +114,16 @@ class Spacing_Preprocessor implements Context_Aware_Preprocessor {
* column gaps, and root padding for children of root-level containers.
*
* Root padding is distributed from the outer email wrapper to individual block
- * wrappers. Container blocks (groups, post-content) at the root level delegate
- * padding to their children instead of taking it themselves. This enables
- * alignfull blocks to skip root padding and span the full email width.
+ * wrappers. Plain root-level containers (groups without post-content) delegate
+ * padding to their children instead of taking it themselves, so alignfull
+ * children can skip root padding and span the full email width.
*
- * Container padding works similarly: when a template group wrapping post-content
- * has its own horizontal padding, that padding is distributed per-block alongside
- * root padding. Alignfull blocks skip both padding types and span the full
- * contentSize. The template group gets a suppress-horizontal-padding flag so its
- * renderer omits horizontal padding from its own CSS output.
- *
- * Blocks fall into three categories for root padding:
- * - Zero padding (has_zero_padding): skip root padding entirely — edge-to-edge intent.
- * - Non-zero explicit padding (has_own_padding, !has_zero_padding): receive root padding
- * on top of their own padding. Their own padding is internal content spacing; root
- * padding ensures inset from the email edge. These blocks also stop delegation.
- * - No explicit padding: receive root padding if delegated, or delegate if a container.
+ * A container that wraps post-content and has its own horizontal padding is a
+ * self-contained box: it takes the root padding as an inset itself, and its own
+ * padding is suppressed on the box and distributed to descendants as container
+ * padding (via a suppress-horizontal-padding flag). This lets full-width children
+ * break out of the box, and keeps the two paddings nesting (e.g. 30px outer +
+ * 24px own) instead of stacking on every block.
*
* @param array $parsed_blocks Parsed blocks.
* @param string $gap Gap.
@@ -158,84 +152,71 @@ class Spacing_Preprocessor implements Context_Aware_Preprocessor {
}
// Handle horizontal gap for columns: apply physical padding to column children (except the first).
+ // Only an explicitly defined column blockGap is applied. We intentionally do not
+ // derive it from the global (vertical) block spacing.
if ( 'core/columns' === $parent_block_name && 0 !== $key && null !== $parent_block ) {
- $columns_gap = $this->get_columns_block_gap( $parent_block, $gap );
+ $columns_gap = $this->get_columns_block_gap( $parent_block );
if ( $columns_gap ) {
$block['email_attrs'][ $gap_padding_side ] = $columns_gap;
}
}
- // Distribute root horizontal padding.
- // Container blocks (group, post-content) at root level do NOT get padding;
- // they delegate it to their children. Non-container blocks at root level
- // (e.g., columns, paragraph) get padding directly.
- // Blocks flagged with $apply_root_padding (children of root containers)
- // also get padding, unless they are post-content or a container wrapping
- // post-content (both delegate further down the tree).
- // Blocks that explicitly define their own horizontal padding are managing
- // their own layout and skip root padding entirely. Containers with explicit
- // padding also stop delegation so their children follow the container's padding.
- $is_root_level = null === $parent_block;
- $is_container = in_array( $block_name, self::CONTAINER_BLOCKS, true );
- $alignment = $block['attrs']['align'] ?? null;
- $has_zero_padding = $this->has_zero_horizontal_padding( $block );
- $has_own_padding = $this->has_explicit_horizontal_padding( $block );
- $wraps_post_content = $apply_root_padding && $is_container && $this->contains_post_content( $block );
- $should_apply = $apply_root_padding || ( $is_root_level && ! $is_container ) || ( $is_root_level && $is_container && $has_own_padding );
-
+ // Distribute horizontal padding.
+ //
+ // A container that wraps post-content AND has its own horizontal padding
+ // is a self-contained box: it takes the root padding as an inset itself,
+ // while its own padding is suppressed on the box and distributed to
+ // descendants as container padding (so full-width children can still
+ // break out of it). Because the box is inset, post-content ends up
+ // narrower than contentSize — the signal Content_Renderer uses to drop
+ // root padding for the user blocks in the second pass. Without the inset,
+ // the two paddings would stack on every block (e.g. 30px + 24px = 54px).
+ $is_root_level = null === $parent_block;
+ $is_container = in_array( $block_name, self::CONTAINER_BLOCKS, true );
+ $alignment = $block['attrs']['align'] ?? null;
+ $has_zero_padding = $this->has_zero_horizontal_padding( $block );
+ $has_own_padding = $this->has_explicit_horizontal_padding( $block );
$post_content_block_names = $this->get_post_content_block_names();
- if ( $should_apply && ! $has_zero_padding && 'full' !== $alignment && ! in_array( $block_name, $post_content_block_names, true ) && ! $wraps_post_content && ! empty( $root_padding ) ) {
+ $is_post_content = in_array( $block_name, $post_content_block_names, true );
+ $wraps_post_content = $is_container && $this->contains_post_content( $block );
+ $is_box = $wraps_post_content && $has_own_padding && ! $has_zero_padding;
+
+ // A delegator passes padding down to its children instead of taking it,
+ // so each child is inset on its own and full-width children can break out.
+ $delegates = ! $is_box && (
+ ( $is_root_level && $is_container && ! $has_own_padding )
+ || ( $apply_root_padding && $is_post_content )
+ || $wraps_post_content
+ );
+
+ // Everything else applies the padding to itself, except full-width and
+ // explicitly zero-padded blocks.
+ $is_recipient = ! $delegates && ! $is_post_content && ! $has_zero_padding && 'full' !== $alignment;
+
+ if ( $is_recipient && ( $apply_root_padding || $is_root_level ) && ! empty( $root_padding ) ) {
$block['email_attrs']['root-padding-left'] = $root_padding['left'];
$block['email_attrs']['root-padding-right'] = $root_padding['right'];
}
- // Apply container padding (from template group wrapping post-content).
- // Alignfull blocks skip both root and container padding.
- if ( $should_apply && ! $has_zero_padding && 'full' !== $alignment && ! in_array( $block_name, $post_content_block_names, true ) && ! $wraps_post_content && ! empty( $container_padding ) ) {
+ $applied_container = $is_recipient && ! empty( $container_padding );
+ if ( $applied_container ) {
$block['email_attrs']['container-padding-left'] = $container_padding['left'];
$block['email_attrs']['container-padding-right'] = $container_padding['right'];
}
- // Determine whether children should receive root padding delegation.
- // Root-level containers delegate to their children.
- // Post-content blocks that received delegation also pass it through.
- // Containers wrapping post-content that received delegation also delegate,
- // so that user blocks inside post-content get padding individually.
- // Containers with explicit horizontal padding stop delegation — they
- // manage their own layout.
- $children_apply = false;
- $children_container_pad = $container_padding;
- if ( $is_root_level && $is_container && ! $has_own_padding ) {
- $children_apply = true;
- } elseif ( $apply_root_padding && in_array( $block_name, $post_content_block_names, true ) ) {
- $children_apply = true;
- } elseif ( $wraps_post_content ) {
- $children_apply = true;
-
- // When a container wrapping post-content has its own non-zero
- // horizontal padding, distribute it as container-padding to
- // descendant blocks and suppress the container's own CSS padding.
+ // Pass padding on to the children. Container padding keeps flowing down
+ // until a block applies it, then stops — so a nested block (e.g. an image
+ // inside a column) doesn't get it a second time.
+ $children_container_pad = $applied_container ? array() : $container_padding;
+ if ( $is_box ) {
$block_padding = $this->get_block_horizontal_padding( $block, $variables_map );
if ( ! empty( $block_padding ) ) {
$children_container_pad = $block_padding;
$block['email_attrs']['suppress-horizontal-padding'] = true;
}
- } elseif ( $is_root_level && $is_container && $has_own_padding && ! $has_zero_padding && $this->contains_post_content( $block ) ) {
- // Root-level container with own padding that wraps post-content:
- // distribute its padding as container-padding and suppress its own CSS.
- $children_apply = true;
- $block_padding = $this->get_block_horizontal_padding( $block, $variables_map );
- if ( ! empty( $block_padding ) ) {
- $children_container_pad = $block_padding;
- $block['email_attrs']['suppress-horizontal-padding'] = true;
- }
-
- // This container also should not receive root padding itself
- // (it delegates everything to children).
- unset( $block['email_attrs']['root-padding-left'], $block['email_attrs']['root-padding-right'] );
}
- $block['innerBlocks'] = $this->add_block_gaps( $block['innerBlocks'] ?? array(), $gap, $block, $root_padding, $children_apply, $children_container_pad, $variables_map, $gap_padding_side );
+ $block['innerBlocks'] = $this->add_block_gaps( $block['innerBlocks'] ?? array(), $gap, $block, $root_padding, $delegates, $children_container_pad, $variables_map, $gap_padding_side );
$parsed_blocks[ $key ] = $block;
}
@@ -368,15 +349,18 @@ class Spacing_Preprocessor implements Context_Aware_Preprocessor {
/**
* Extracts the horizontal blockGap from a columns block.
*
- * @param array $columns_block The columns block.
- * @param string $default_gap Default gap value to use if blockGap is not set on the columns block.
- * @return string|null The horizontal gap value (e.g., "30px" or "var:preset|spacing|30") or null if not set.
+ * Only an explicitly defined horizontal gap (blockGap.left) is honored; we do
+ * not fall back to the global block spacing, which is vertical-only in the
+ * editor and would otherwise add a gap that widens the rendered email.
+ *
+ * @param array $columns_block The columns block.
+ * @return string|null The horizontal gap value (e.g., "30px" or "var:preset|spacing|30") or null if not explicitly set.
*/
- private function get_columns_block_gap( array $columns_block, string $default_gap = '' ): ?string {
+ private function get_columns_block_gap( array $columns_block ): ?string {
$block_gap = $columns_block['attrs']['style']['spacing']['blockGap'] ?? null;
// Columns block uses object format: { "top": "...", "left": "..." }.
- // If blockGap.left is explicitly set, use it.
+ // Only apply a horizontal gap when blockGap.left is explicitly set.
if ( is_array( $block_gap ) && isset( $block_gap['left'] ) && is_string( $block_gap['left'] ) ) {
$gap_value = $block_gap['left'];
@@ -389,11 +373,6 @@ class Spacing_Preprocessor implements Context_Aware_Preprocessor {
return $gap_value;
}
- // If blockGap.left is not set, use the default gap value if provided.
- if ( $default_gap ) {
- return $default_gap;
- }
-
return null;
}
}
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
index cc190c8018b..0e7464f2748 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/class-content-renderer.php
@@ -291,6 +291,10 @@ class Content_Renderer {
// calculations involve round() and division that may produce imprecision.
if ( $post_content_num < $content_size_num - 0.01 ) {
unset( $styles['spacing']['padding']['left'], $styles['spacing']['padding']['right'] );
+
+ // Constrain user blocks to the (inset) post-content width so they
+ // fit inside it instead of overflowing at the full contentSize.
+ $layout['contentSize'] = $this->post_content_width;
}
// Pass container padding from the first pass so the
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
index d0b515dbcae..1a0e079113b 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
@@ -181,11 +181,23 @@ class Image extends Abstract_Block_Renderer {
'class_name' => 'email-image-border-cell',
);
$content_with_border_styles = $this->add_style_to_element( $block_content, $border_element_tag, \WP_Style_Engine::compile_css( $border_styles, '' ) );
- // Remove border styles from the image HTML tag.
+ // Remove border styles from the image HTML tag. The border is applied on the wrapper cell.
$content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-style' );
$content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-width' );
$content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-color' );
$content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-radius' );
+ $content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-top-left-radius' );
+ $content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-top-right-radius' );
+ $content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-bottom-left-radius' );
+ $content_with_border_styles = $this->remove_style_attribute_from_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-bottom-right-radius' );
+
+ // The border sits on the wrapper cell, so the image needs a smaller radius to stay flush
+ // inside it. With equal radii the border width would leave a gap in the corners, so we
+ // subtract the border width from the image radius (clamped to 0).
+ $inner_radius = $this->get_inner_image_border_radius( $parsed_block );
+ if ( '' !== $inner_radius ) {
+ $content_with_border_styles = $this->add_style_to_element( $content_with_border_styles, array( 'tag_name' => 'img' ), 'border-radius:' . $inner_radius . ';' );
+ }
// Add Border related classes to proper element. This is required for inlined border-color styles when defined via class.
$border_classes = array_filter(
explode( ' ', $class_name ),
@@ -202,6 +214,81 @@ class Image extends Abstract_Block_Renderer {
return $html->get_updated_html();
}
+ /**
+ * Get the border radius to apply to the image so it sits flush inside the bordered wrapper cell.
+ *
+ * The border is rendered on the wrapper cell, so the image radius is reduced by the border
+ * width (clamped to 0). Returns a CSS value (single radius or a four-value shorthand), or an
+ * empty string when there's no radius to apply.
+ *
+ * @param array $parsed_block Parsed block.
+ */
+ private function get_inner_image_border_radius( array $parsed_block ): string {
+ $border = $parsed_block['attrs']['style']['border'] ?? array();
+ $class_name = $parsed_block['attrs']['className'] ?? '';
+ $is_rounded = strpos( $class_name, 'is-style-rounded' ) !== false;
+
+ $radius = $border['radius'] ?? null;
+ if ( $is_rounded ) {
+ $radius = '9999px'; // Matches the circle radius applied by apply_rounded_style().
+ }
+
+ if ( empty( $radius ) ) {
+ return '';
+ }
+
+ // Use the widest border side so the image never pokes out past the border on any corner.
+ $border_width = $this->get_max_border_width( $border );
+
+ if ( is_array( $radius ) ) {
+ $top_left = $this->reduce_radius_by_border_width( $radius['topLeft'] ?? null, $border_width );
+ $top_right = $this->reduce_radius_by_border_width( $radius['topRight'] ?? null, $border_width );
+ $bottom_right = $this->reduce_radius_by_border_width( $radius['bottomRight'] ?? null, $border_width );
+ $bottom_left = $this->reduce_radius_by_border_width( $radius['bottomLeft'] ?? null, $border_width );
+
+ if ( $top_left === $top_right && $top_right === $bottom_right && $bottom_right === $bottom_left ) {
+ return $top_left;
+ }
+ return "{$top_left} {$top_right} {$bottom_right} {$bottom_left}";
+ }
+
+ return $this->reduce_radius_by_border_width( $radius, $border_width );
+ }
+
+ /**
+ * Get the largest border width (in px) across all sides.
+ *
+ * @param array $border Border style attributes.
+ */
+ private function get_max_border_width( array $border ): float {
+ $max = isset( $border['width'] ) ? Styles_Helper::parse_value( (string) $border['width'] ) : 0.0;
+ foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) {
+ if ( isset( $border[ $side ]['width'] ) ) {
+ $max = max( $max, Styles_Helper::parse_value( (string) $border[ $side ]['width'] ) );
+ }
+ }
+ return $max;
+ }
+
+ /**
+ * Reduce a single border radius value by the border width, clamped to 0.
+ *
+ * Only px (or unitless) values can be combined with the px border width; other units are
+ * returned unchanged.
+ *
+ * @param mixed $radius Border radius value (e.g. "20px"). Non-scalar values are treated as 0.
+ * @param float $border_width Border width in px.
+ */
+ private function reduce_radius_by_border_width( $radius, float $border_width ): string {
+ $radius = is_scalar( $radius ) ? trim( (string) $radius ) : '';
+ // Non-px units can't be combined with a px border width, so keep them unchanged.
+ if ( '' !== $radius && ! str_ends_with( $radius, 'px' ) && ! is_numeric( $radius ) ) {
+ return $radius;
+ }
+ $inner = max( 0.0, Styles_Helper::parse_value( $radius ) - $border_width );
+ return $inner . 'px';
+ }
+
/**
* Settings width and height attributes for images is important for MS Outlook.
*
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
index 1589740fbdf..fdcc34b1835 100644
--- a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Content_Renderer_Test.php
@@ -321,14 +321,14 @@ class Content_Renderer_Test extends \Email_Editor_Integration_Test_Case {
}
/**
- * Test preprocess_parsed_blocks distributes both root and container padding
- * in second pass when a container above post-content has own padding
- * (WooCommerce template pattern).
+ * Test preprocess_parsed_blocks treats a group with its own padding wrapping
+ * post-content as a box (WooCommerce template pattern): the box takes the root
+ * inset itself and distributes its own padding to the user blocks as container
+ * padding in the second pass — so root and container padding nest (30 outer +
+ * 20 own) instead of stacking to 50 on every block.
*/
- public function testItDistributesBothPaddingsInSecondPassWhenContainerHasPadding(): void {
- // First pass: template blocks with a group wrapping post-content.
- // The group has own padding — it distributes both root padding and
- // container padding per-block to user blocks.
+ public function testItTreatsGroupWithPaddingWrappingPostContentAsBoxAcrossPasses(): void {
+ // First pass: template blocks with a group (own padding) wrapping post-content.
$template_blocks = array(
array(
'blockName' => 'core/group',
@@ -353,39 +353,52 @@ class Content_Renderer_Test extends \Email_Editor_Integration_Test_Case {
);
$first_result = $this->renderer->preprocess_parsed_blocks( $template_blocks );
-
- // Root group should have suppress-horizontal-padding flag.
- $root_group = $first_result[0];
- $this->assertTrue( $root_group['email_attrs']['suppress-horizontal-padding'] );
-
- // post-content should have full contentSize width (padding is distributed
- // per-block, not subtracted from the group).
- $post_content = $root_group['innerBlocks'][0];
- $this->assertArrayHasKey( 'width', $post_content['email_attrs'] );
-
- // Second pass: user blocks (simulating post-content rendering).
- $user_blocks = array(
+ $box_group = $first_result[0];
+
+ // The box suppresses its own padding and takes the root inset itself.
+ $this->assertTrue( $box_group['email_attrs']['suppress-horizontal-padding'] );
+ $this->assertArrayHasKey( 'root-padding-left', $box_group['email_attrs'] );
+
+ // Because the box is inset, post-content is narrower than contentSize —
+ // the signal the second pass uses to drop root padding for user blocks.
+ $post_content = $box_group['innerBlocks'][0];
+ $post_content_num = (float) str_replace( 'px', '', $post_content['email_attrs']['width'] );
+ $theme_controller = $this->di_container->get( \Automattic\WooCommerce\EmailEditor\Engine\Theme_Controller::class );
+ $content_size_num = (float) str_replace( 'px', '', $theme_controller->get_layout_settings()['contentSize'] );
+ $this->assertLessThan( $content_size_num, $post_content_num );
+
+ // Second pass: user blocks (simulating post-content rendering) — a normal
+ // block and a full-width block.
+ $user_blocks = array(
array(
'blockName' => 'core/paragraph',
'attrs' => array(),
'innerBlocks' => array(),
),
+ array(
+ 'blockName' => 'core/group',
+ 'attrs' => array( 'align' => 'full' ),
+ 'innerBlocks' => array(),
+ ),
);
-
$second_result = $this->renderer->preprocess_parsed_blocks( $user_blocks );
- // User blocks should have root padding (delegated, not absorbed).
- $this->assertArrayHasKey( 'root-padding-left', $second_result[0]['email_attrs'] );
- $this->assertArrayHasKey( 'root-padding-right', $second_result[0]['email_attrs'] );
-
- // User blocks should also have container padding from the template group.
- $this->assertArrayHasKey( 'container-padding-left', $second_result[0]['email_attrs'] );
- $this->assertArrayHasKey( 'container-padding-right', $second_result[0]['email_attrs'] );
+ // Normal block gets only the container padding (not root), so it does not
+ // stack on top of the box's root inset.
+ $this->assertArrayNotHasKey( 'root-padding-left', $second_result[0]['email_attrs'] );
$this->assertEquals( '20px', $second_result[0]['email_attrs']['container-padding-left'] );
$this->assertEquals( '20px', $second_result[0]['email_attrs']['container-padding-right'] );
- // Width should account for both root and container padding.
- $this->assertArrayHasKey( 'width', $second_result[0]['email_attrs'] );
+ // Normal block width is the box content width minus the distributed
+ // container padding (20px + 20px), so it fits inside the box's inner padding.
+ $normal_width_num = (float) str_replace( 'px', '', $second_result[0]['email_attrs']['width'] );
+ $this->assertEqualsWithDelta( $post_content_num - 40, $normal_width_num, 1.0 );
+
+ // Full-width block skips the container padding and spans the full box width,
+ // so it breaks out of the box's inner padding (full-width still works).
+ $this->assertArrayNotHasKey( 'container-padding-left', $second_result[1]['email_attrs'] );
+ $full_width_num = (float) str_replace( 'px', '', $second_result[1]['email_attrs']['width'] );
+ $this->assertEqualsWithDelta( $post_content_num, $full_width_num, 1.0 );
}
/**
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
index b76e118fbbf..a403e5eb59c 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
@@ -209,7 +209,44 @@ class Image_Test extends \Email_Editor_Integration_Test_Case {
$html->next_tag( array( 'tag_name' => 'img' ) );
$img_style = $html->get_attribute( 'style' );
$this->assertIsString( $img_style );
- $this->assertStringNotContainsString( 'border', $img_style );
+ // The image keeps a rounded corner reduced by the border width so it stays flush inside the border.
+ $this->assertStringContainsString( 'border-radius:10px;', $img_style );
+ $this->assertStringNotContainsString( 'border-style', $img_style );
+ $this->assertStringNotContainsString( 'border-width', $img_style );
+ $this->assertStringNotContainsString( 'border-color', $img_style );
+ }
+
+ /**
+ * Test the image radius is clamped to 0 when the border width exceeds the radius
+ */
+ public function testItClampsImageBorderRadiusToZero(): void {
+ $image_content = '
+ <figure class="wp-block-image alignleft size-full is-style-default">
+ <img src="https://test.com/wp-content/uploads/2023/05/image.jpg" alt="" style="border-width:10px;border-color:#000001;border-radius:5px;height:auto;" srcset="https://test.com/wp-content/uploads/2023/05/image.jpg 1000w"/>
+ </figure>
+ ';
+ $parsed_image = $this->parsed_image;
+ $parsed_image['attrs']['style']['border'] = array(
+ 'width' => '10px',
+ 'color' => '#000001',
+ 'radius' => '5px',
+ );
+
+ $rendered = $this->image_renderer->render( $image_content, $parsed_image, $this->rendering_context );
+ $html = new \WP_HTML_Tag_Processor( $rendered );
+ $html->next_tag(
+ array(
+ 'tag_name' => 'td',
+ 'class_name' => 'email-image-border-cell',
+ )
+ );
+ $table_cell_style = $html->get_attribute( 'style' );
+ $this->assertIsString( $table_cell_style );
+ $this->assertStringContainsString( 'border-radius:5px', $table_cell_style );
+ $html->next_tag( array( 'tag_name' => 'img' ) );
+ $img_style = $html->get_attribute( 'style' );
+ $this->assertIsString( $img_style );
+ $this->assertStringContainsString( 'border-radius:0px;', $img_style );
}
/**
diff --git a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Blocks_Width_Preprocessor_Test.php b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Blocks_Width_Preprocessor_Test.php
index d02a93d3405..b00856072fa 100644
--- a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Blocks_Width_Preprocessor_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Blocks_Width_Preprocessor_Test.php
@@ -577,9 +577,9 @@ class Blocks_Width_Preprocessor_Test extends \Email_Editor_Unit_Test {
$this->assertEquals( '195px', $result[0]['innerBlocks'][1]['email_attrs']['width'] );
$this->assertEquals( '265px', $result[0]['innerBlocks'][2]['email_attrs']['width'] );
$image_block = $result[0]['innerBlocks'][1]['innerBlocks'][0];
- $this->assertEquals( '195px', $image_block['email_attrs']['width'] );
+ $this->assertEquals( '195px', $image_block['email_attrs']['width'] ); // 195 column width, no column border or padding.
$image_block = $result[0]['innerBlocks'][2]['innerBlocks'][0];
- $this->assertEquals( '225px', $image_block['email_attrs']['width'] );
+ $this->assertEquals( '195px', $image_block['email_attrs']['width'] ); // 265 - 30 (15px border each side) - 40 (20px padding each side).
}
/**
diff --git a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Spacing_Preprocessor_Test.php b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Spacing_Preprocessor_Test.php
index c291b811e9c..9fbf72e83a4 100644
--- a/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Spacing_Preprocessor_Test.php
+++ b/packages/php/email-editor/tests/unit/Engine/Renderer/ContentRenderer/Preprocessors/Spacing_Preprocessor_Test.php
@@ -223,9 +223,13 @@ class Spacing_Preprocessor_Test extends \Email_Editor_Unit_Test {
}
/**
- * Test it adds default padding-left when columns has no blockGap.left
+ * Test it does not add a horizontal column gap when blockGap.left is not explicitly set.
+ *
+ * The global (vertical) block spacing must not leak in as a horizontal column
+ * gap, since that spacing is not shown between columns in the editor and would
+ * widen the rendered email.
*/
- public function testItAddsDefaultPaddingLeftWithoutBlockGapLeft(): void {
+ public function testItDoesNotAddPaddingLeftWithoutBlockGapLeft(): void {
$blocks = array(
array(
'blockName' => 'core/columns',
@@ -257,9 +261,8 @@ class Spacing_Preprocessor_Test extends \Email_Editor_Unit_Test {
$result = $this->preprocessor->preprocess( $blocks, $this->layout, $this->styles );
$second_column = $result[0]['innerBlocks'][1];
- // Should have padding-left with default gap value since blockGap.left is not set.
- $this->assertArrayHasKey( 'padding-left', $second_column['email_attrs'] );
- $this->assertEquals( '10px', $second_column['email_attrs']['padding-left'] );
+ // Without an explicit blockGap.left, no horizontal gap should be applied.
+ $this->assertArrayNotHasKey( 'padding-left', $second_column['email_attrs'] );
}
/**
@@ -720,7 +723,9 @@ class Spacing_Preprocessor_Test extends \Email_Editor_Unit_Test {
}
/**
- * Test root-level group with own padding wrapping post-content distributes container padding
+ * Test a root-level group with its own padding wrapping post-content is a box:
+ * it takes the root inset itself and distributes its own padding to descendants
+ * as container padding (so the two paddings nest instead of stacking).
*/
public function testItDistributesContainerPaddingFromRootGroupWrappingPostContent(): void {
$blocks = array(
@@ -765,30 +770,133 @@ class Spacing_Preprocessor_Test extends \Email_Editor_Unit_Test {
$paragraph = $post_content['innerBlocks'][0];
$alignfull = $post_content['innerBlocks'][1];
- // Root group should have suppress-horizontal-padding flag.
+ // The box suppresses its own padding and takes the root inset itself.
$this->assertTrue( $root_group['email_attrs']['suppress-horizontal-padding'] );
+ $this->assertEquals( '10px', $root_group['email_attrs']['root-padding-left'] );
+ $this->assertEquals( '10px', $root_group['email_attrs']['root-padding-right'] );
- // Root group should NOT have root padding (delegates everything).
- $this->assertArrayNotHasKey( 'root-padding-left', $root_group['email_attrs'] );
- $this->assertArrayNotHasKey( 'root-padding-right', $root_group['email_attrs'] );
-
- // Post-content should not get container padding (it's a pass-through).
+ // Post-content is a pass-through and gets no container padding of its own.
$this->assertArrayNotHasKey( 'container-padding-left', $post_content['email_attrs'] );
$this->assertArrayNotHasKey( 'container-padding-right', $post_content['email_attrs'] );
- // Normal paragraph should get both root and container padding.
- $this->assertEquals( '10px', $paragraph['email_attrs']['root-padding-left'] );
- $this->assertEquals( '10px', $paragraph['email_attrs']['root-padding-right'] );
+ // Normal paragraph gets only the container padding (the box carries the root
+ // inset), so the two paddings nest instead of stacking to 30px on the block.
+ $this->assertArrayNotHasKey( 'root-padding-left', $paragraph['email_attrs'] );
$this->assertEquals( '20px', $paragraph['email_attrs']['container-padding-left'] );
$this->assertEquals( '20px', $paragraph['email_attrs']['container-padding-right'] );
- // Alignfull block should skip BOTH root and container padding.
+ // Alignfull block skips container padding and spans the box width.
$this->assertArrayNotHasKey( 'root-padding-left', $alignfull['email_attrs'] );
- $this->assertArrayNotHasKey( 'root-padding-right', $alignfull['email_attrs'] );
$this->assertArrayNotHasKey( 'container-padding-left', $alignfull['email_attrs'] );
$this->assertArrayNotHasKey( 'container-padding-right', $alignfull['email_attrs'] );
}
+ /**
+ * Test the box behavior is driven by the group's own padding, not its
+ * background: a backgrounded group with padding wrapping post-content still
+ * takes the root inset and distributes its own padding as container padding.
+ */
+ public function testItTreatsBackgroundedGroupWithPaddingAsBox(): void {
+ $blocks = array(
+ array(
+ 'blockName' => 'core/group',
+ 'attrs' => array(
+ 'backgroundColor' => 'white',
+ 'style' => array(
+ 'spacing' => array(
+ 'padding' => array(
+ 'left' => '20px',
+ 'right' => '20px',
+ 'top' => '15px',
+ 'bottom' => '15px',
+ ),
+ ),
+ ),
+ ),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'core/post-content',
+ 'attrs' => array(),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'core/paragraph',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $result = $this->preprocessor->preprocess( $blocks, $this->layout, $this->styles );
+ $box_group = $result[0];
+ $paragraph = $box_group['innerBlocks'][0]['innerBlocks'][0];
+
+ // The box suppresses its own padding and takes the root inset itself.
+ $this->assertTrue( $box_group['email_attrs']['suppress-horizontal-padding'] );
+ $this->assertEquals( '10px', $box_group['email_attrs']['root-padding-left'] );
+
+ // The paragraph gets only the distributed container padding, not root padding.
+ $this->assertArrayNotHasKey( 'root-padding-left', $paragraph['email_attrs'] );
+ $this->assertEquals( '20px', $paragraph['email_attrs']['container-padding-left'] );
+ }
+
+ /**
+ * Test a box (group with padding) nested inside a transparent root group that
+ * delegates: the box takes the root inset and distributes its own padding to
+ * descendants as container padding.
+ */
+ public function testItInsetsBoxNestedInTransparentRootGroup(): void {
+ $blocks = array(
+ array(
+ 'blockName' => 'core/group',
+ 'attrs' => array(),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'core/group',
+ 'attrs' => array(
+ 'backgroundColor' => 'white',
+ 'style' => array(
+ 'spacing' => array(
+ 'padding' => array(
+ 'left' => '20px',
+ 'right' => '20px',
+ ),
+ ),
+ ),
+ ),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'core/post-content',
+ 'attrs' => array(),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'core/paragraph',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $result = $this->preprocessor->preprocess( $blocks, $this->layout, $this->styles );
+ $box_group = $result[0]['innerBlocks'][0];
+ $paragraph = $box_group['innerBlocks'][0]['innerBlocks'][0];
+
+ // The box suppresses its own padding and is inset by root padding.
+ $this->assertTrue( $box_group['email_attrs']['suppress-horizontal-padding'] );
+ $this->assertEquals( '10px', $box_group['email_attrs']['root-padding-left'] );
+
+ // Descendants get the distributed container padding but not root padding.
+ $this->assertArrayNotHasKey( 'root-padding-left', $paragraph['email_attrs'] );
+ $this->assertEquals( '20px', $paragraph['email_attrs']['container-padding-left'] );
+ }
+
/**
* Test container padding is distributed from nested group wrapping post-content
*/
@@ -878,6 +986,62 @@ class Spacing_Preprocessor_Test extends \Email_Editor_Unit_Test {
$this->assertArrayNotHasKey( 'container-padding-right', $alignfull['email_attrs'] );
}
+ /**
+ * Test container padding is applied once at the outermost block and not
+ * propagated to nested descendants: a columns block gets it, but the image
+ * inside a padded column does not receive it again.
+ */
+ public function testItDoesNotPropagateContainerPaddingToNestedBlocks(): void {
+ $styles = $this->styles;
+ $styles['__container_padding'] = array(
+ 'left' => '24px',
+ 'right' => '24px',
+ );
+
+ $blocks = array(
+ array(
+ 'blockName' => 'core/columns',
+ 'attrs' => array(),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'core/column',
+ 'attrs' => array(
+ 'style' => array(
+ 'spacing' => array(
+ 'padding' => array(
+ 'left' => '20px',
+ 'right' => '20px',
+ ),
+ ),
+ ),
+ ),
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'core/image',
+ 'attrs' => array(),
+ 'innerBlocks' => array(),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ $result = $this->preprocessor->preprocess( $blocks, $this->layout, $styles );
+ $columns = $result[0];
+ $column = $columns['innerBlocks'][0];
+ $image = $column['innerBlocks'][0];
+
+ // The outermost columns block receives the container padding.
+ $this->assertEquals( '24px', $columns['email_attrs']['container-padding-left'] );
+ $this->assertEquals( '24px', $columns['email_attrs']['container-padding-right'] );
+
+ // Nested blocks must not receive it again.
+ $this->assertArrayNotHasKey( 'container-padding-left', $column['email_attrs'] );
+ $this->assertArrayNotHasKey( 'container-padding-left', $image['email_attrs'] );
+ $this->assertArrayNotHasKey( 'container-padding-right', $image['email_attrs'] );
+ }
+
/**
* Test template group without own padding does NOT set container padding
*/