From ea88b1c938b64d545542a26800306394facd878c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 9 Nov 2026 13:50:54 -0500 Subject: [PATCH] Keep a bare img subject's own corner radius through the image bridge --- .../Style/AuthorStylesheetProjector.php | 44 +++++++++++++++++-- .../artifact-author-stylesheet-projection.php | 29 ++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php index c26aeb86a..8183321ca 100644 --- a/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php +++ b/php-transformer/src/HtmlToBlocks/Style/AuthorStylesheetProjector.php @@ -425,7 +425,7 @@ private function emitProjectedStyleRule(string $prelude, string $body, AuthorSty $svgImagePrelude = $this->projectAuthorImageSelectorPrelude($prelude, $context, 'svg', $declarations); $imageRule = '' === $imagePrelude ? '' - : $imagePrelude . '{' . $this->imageProjectionBridgeDeclarations($declarations) . '}'; + : $this->imageProjectionBridgeRule($prelude, $imagePrelude, $declarations, $context); if ('' !== $imagePrelude) { // The generic Image bridge introduces block display and responsive // shrinkage. An inline source image in an auto table must instead @@ -3335,8 +3335,44 @@ private function lineImageFigureRule(string $imagePrelude, array $declarations, . ( isset($declarations['vertical-align']) ? ';vertical-align:' . $declarations['vertical-align'] : '' ) . '}'; } + /** + * The bridge lets the native image follow the figure that now carries the + * source rule. A selector whose subject is a bare `img` has nothing that + * moves to the figure, so the source rule still paints the image itself: + * inheriting the figure's corners there would replace the image's own + * radius at the same specificity, later in the cascade. + * + * @param array $declarations + */ + private function imageProjectionBridgeRule(string $prelude, string $imagePrelude, array $declarations, AuthorStylesheetProjectionContext $context): string + { + $selectors = CssStylesheetTransformer::splitSelectorList($prelude) ?? array(); + $moved = array_values(array_filter($selectors, fn (string $selector): bool => ! $this->subjectStaysOnImage($selector, $context))); + if ( count($moved) === count($selectors) ) { + return $imagePrelude . '{' . $this->imageProjectionBridgeDeclarations($declarations) . '}'; + } + $rule = $imagePrelude . '{' . $this->imageProjectionBridgeDeclarations($declarations, false, false) . '}'; + $movedPrelude = array() === $moved ? '' : $this->projectAuthorImageSelectorPrelude(implode(',', $moved), $context); + + return '' === $movedPrelude ? $rule : $rule . $movedPrelude . '{border-radius:inherit}'; + } + + private function subjectStaysOnImage(string $selector, AuthorStylesheetProjectionContext $context): bool + { + $parsed = $context->sourceStyles->parsedSelector($selector); + if ( ! $parsed['supported'] ) { + return false; + } + $subject = CssSelectorCompoundInspector::rightmost($parsed['compounds']); + + return 'img' === strtolower((string) ($subject['type'] ?? '')) + && array() === ($subject['classes'] ?? array()) + && array() === ($subject['ids'] ?? array()) + && array() === ($subject['attributes'] ?? array()); + } + /** @param array $declarations */ - private function imageProjectionBridgeDeclarations(array $declarations, bool $preserveObjectFit = false): string + private function imageProjectionBridgeDeclarations(array $declarations, bool $preserveObjectFit = false, bool $inheritRadius = true): string { $bridge = array( 'display:block' ); $position = strtolower(trim((string) ($declarations['position'] ?? ''))); @@ -3362,7 +3398,9 @@ private function imageProjectionBridgeDeclarations(array $declarations, bool $pr $objectFit = $this->explicitObjectFit($declarations); $bridge[] = 'object-fit:' . ($preserveObjectFit && '' !== $objectFit ? $objectFit : 'inherit'); $bridge[] = 'object-position:inherit'; - $bridge[] = 'border-radius:inherit'; + if ( $inheritRadius ) { + $bridge[] = 'border-radius:inherit'; + } return implode(';', $bridge); } diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php index def8b1ce7..4993e3e24 100644 --- a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -553,6 +553,35 @@ $assert(str_contains($positionedImageCss, 'object-fit:fill !important;object-position:-197.702px -102.702px !important'), 'image presentation carrier preserves source object fit and position for the nested image bridge'); $assert(str_contains($positionedImageCss, '.map.wp-block-image > img,.map.wp-block-image > a > img{display:block;width:100%;height:100%;max-width:100%;object-fit:inherit;object-position:inherit;border-radius:inherit}'), 'nested image fills a wrapper with explicitly owned width and height, linked or not'); +// Corner radius follows whichever box the source rule paints after projection: +// the figure when the image's class moved onto it, the image itself when the +// rule's subject is a bare `img`. +foreach (array( + 'class moved to figure' => array('a', '.photo{width:120px;height:120px;border-radius:50%}'), + 'descendant img subject' => array('
a
', '.avatar img{width:120px;height:120px;border-radius:50%}'), + 'later radius-less breakpoint rule' => array('
a
', '.avatar img{width:120px;height:120px;border-radius:50%}@media (min-width:1px){.avatar img{width:100px;height:100px}}'), + 'mixed selector list' => array('a
b
', '.photo,.avatar img{width:120px;height:120px;border-radius:50%}'), +) as $case => [$radiusHtml, $radiusCss]) { + $radius = ( new ArtifactCompiler() )->compile(array( + 'files' => array( + array( 'path' => 'index.html', 'kind' => 'html', 'content' => '' . $radiusHtml ), + array( 'path' => 'r.css', 'kind' => 'css', 'content_base64' => base64_encode($radiusCss) ), + array( 'path' => 'p.jpg', 'kind' => 'image', 'content' => 'image-bytes' ), + ), + ) )->toArray(); + $radiusDocument = new DOMDocument(); + @$radiusDocument->loadHTML('' . preg_replace('//s', '', (string) ($radius['serialized_blocks'] ?? '')) . ''); + $radiusCascade = new StaticCssCascade($radiusDocument, implode("\n", array_column($radius['assets'] ?? array(), 'content'))); + $radii = array(); + foreach ( $radiusDocument->getElementsByTagName('img') as $radiusImage ) { + $value = $radiusCascade->resolve($radiusImage, array( 'border-radius' ), array())['border-radius'] ?? ''; + $radii[] = 'inherit' === $value && $radiusImage->parentNode instanceof DOMElement + ? ($radiusCascade->resolve($radiusImage->parentNode, array( 'border-radius' ), array())['border-radius'] ?? '') + : $value; + } + $assert(array() !== $radii && array( '50%' ) === array_values(array_unique($radii)), 'projected image keeps its source corner radius: ' . $case . ' (got ' . implode(', ', $radii) . ')'); +} + $multiPage = ( new ArtifactCompiler() )->compile(array( 'entrypoint' => 'index.html', 'files' => array(