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
 	 */