diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php
index 59fdb58b..891919af 100644
--- a/php-transformer/src/HtmlToBlocks/BlockFactory.php
+++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php
@@ -467,7 +467,12 @@ private function imageDimensionStyle(array $attrs): string
}
if ( ! array_key_exists('height', $attrs) || null === $attrs['height'] ) {
- $style[] = 'height:auto';
+ // Gutenberg's image save shape keeps percentage widths as width-only
+ // styles. The image's intrinsic dimensions (including an SVG viewBox)
+ // provide the automatic aspect ratio without serializing height:auto.
+ if ( ! $this->isPercentageWidth((string) ($attrs['width'] ?? '')) ) {
+ $style[] = 'height:auto';
+ }
} else {
$style[] = 'height:' . (string) $attrs['height'];
}
@@ -475,6 +480,11 @@ private function imageDimensionStyle(array $attrs): string
return implode(';', $style);
}
+ private function isPercentageWidth(string $width): bool
+ {
+ return 1 === preg_match('/%\s*$/', trim($width));
+ }
+
/**
* @param array $attrs
*/
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index 1e4970e9..eb1432b9 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -343,6 +343,12 @@ final class HtmlTransformer
/** @var array Source controls that need selector projection. */
private array $sourceControlPaths = array();
+ /** @var array CSS-addressed inline leaves keyed by stable source DOM path. */
+ private array $sourceSemanticMarkers = array();
+
+ /** @var array CSS-addressed RichText spans keyed by stable source DOM path. */
+ private array $sourceRichTextSemanticMarkers = array();
+
private string $combinedAuthorCss = '';
private ?DOMElement $authorStyleSourceBody = null;
@@ -422,6 +428,8 @@ public function transform(string $html, array $options = array()): TransformerRe
$this->sourceTagMarkers = array();
$this->sourceControlMarkers = array();
$this->sourceControlPaths = array();
+ $this->sourceSemanticMarkers = array();
+ $this->sourceRichTextSemanticMarkers = array();
$this->combinedAuthorCss = '';
$this->authorStyleSourceBody = null;
$this->authorMarkerSeed = '';
@@ -504,7 +512,7 @@ public function transform(string $html, array $options = array()): TransformerRe
);
}
- $this->prepareAuthorSelectorSemantics($html, (string) ($options['static_css'] ?? ''), $normalizedHtml, $options);
+ $this->prepareAuthorSelectorSemantics($html, (string) ($options['static_css'] ?? ''), $body, $options);
$fallbacks = array();
$interactionCandidates = $this->interactionCandidates($body);
@@ -647,7 +655,7 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo
}
/** @param array $options */
- private function prepareAuthorSelectorSemantics(string $html, string $staticCss, string $normalizedHtml, array $options): void
+ private function prepareAuthorSelectorSemantics(string $html, string $staticCss, DOMElement $sourceBody, array $options): void
{
$this->authorStylesheetAssets = $this->authorStylesheetAssetsFromOptions($options);
$this->combinedAuthorCss = array() === $this->authorStylesheetAssets
@@ -666,15 +674,7 @@ private function prepareAuthorSelectorSemantics(string $html, string $staticCss,
return;
}
- $document = new DOMDocument();
- $previous = libxml_use_internal_errors(true);
- $loaded = $document->loadHTML('' . $normalizedHtml . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
- libxml_clear_errors();
- libxml_use_internal_errors($previous);
- if ( ! $loaded || ! ($document->getElementsByTagName('body')->item(0) instanceof DOMElement) ) {
- return;
- }
- $this->authorStyleSourceBody = $document->getElementsByTagName('body')->item(0);
+ $this->authorStyleSourceBody = $sourceBody;
$hasParagraphTypeSelector = false;
( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, static function (string $prelude) use (&$hasParagraphTypeSelector): string {
@@ -693,6 +693,7 @@ private function prepareAuthorSelectorSemantics(string $html, string $staticCss,
$this->sourceTagMarkers['p'] = $this->allocateAuthorMarker('source-p');
}
$this->discoverAuthorControlPaths();
+ $this->discoverAuthorInlineSemanticPaths();
}
private function discoverAuthorControlPaths(): void
@@ -719,6 +720,38 @@ private function discoverAuthorControlPaths(): void
});
}
+ private function discoverAuthorInlineSemanticPaths(): void
+ {
+ ( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, function (string $prelude): string {
+ foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) {
+ $parsed = CssSelectorMatcher::parse($selector);
+ if ( ! $parsed['supported'] ) {
+ continue;
+ }
+ foreach ( $this->matchingAuthorSourceElements($parsed) as $element ) {
+ if ( 'span' !== strtolower($element->tagName) ) {
+ continue;
+ }
+ $path = $this->sourceElementIdentity($element);
+ if ( '' === $path ) {
+ continue;
+ }
+ if ( $this->requiresIndependentSemanticWrapper($element) ) {
+ if ( '' !== $path ) {
+ $this->sourceSemanticMarkers[$path] ??= $this->allocateAuthorMarker('semantic');
+ }
+ } elseif ( $this->richTextSelectorNeedsHook($parsed) ) {
+ $marker = $this->sourceRichTextSemanticMarkers[$path] ??= $this->allocateAuthorMarker('richtext');
+ // Carry the generated identity through intermediate
+ // wrapper conversions before RichText normalizes spans.
+ $element->setAttribute('data-blocks-engine-richtext-marker', $marker);
+ }
+ }
+ }
+ return $prelude;
+ });
+ }
+
private function combinedAuthorStylesheet(string $html, string $staticCss): string
{
$cssParts = array();
@@ -805,27 +838,42 @@ private function rewriteAuthorSelectorPrelude(string $prelude): string
}
$controls = array();
- $hasNonControl = false;
+ $semanticLeaves = array();
+ $richTextLeaves = array();
+ $hasNonProjected = false;
foreach ( $matches as $element ) {
$path = $element->getNodePath() ?? '';
if ( isset($this->sourceControlMarkers[$path]) ) {
$controls[] = $this->sourceControlMarkers[$path];
+ } elseif ( isset($this->sourceSemanticMarkers[$this->sourceElementIdentity($element)]) ) {
+ $semanticLeaves[] = $this->sourceSemanticMarkers[$this->sourceElementIdentity($element)];
+ } elseif ( isset($this->sourceRichTextSemanticMarkers[$this->sourceElementIdentity($element)]) ) {
+ $richTextLeaves[] = $this->sourceRichTextSemanticMarkers[$this->sourceElementIdentity($element)];
} else {
- $hasNonControl = true;
+ $hasNonProjected = true;
}
}
$controls = array_values(array_unique($controls));
- if ( array() === $controls ) {
+ $semanticLeaves = array_values(array_unique($semanticLeaves));
+ $richTextLeaves = array_values(array_unique($richTextLeaves));
+ if ( array() === $controls && array() === $semanticLeaves && array() === $richTextLeaves ) {
$rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed);
continue;
}
- if ( $hasNonControl ) {
- $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed, ':not(:where(.' . implode(',.', $controls) . '))');
+ $projectedMarkers = array_merge($controls, $semanticLeaves, $richTextLeaves);
+ if ( $hasNonProjected ) {
+ $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed, ':not(:where(.' . implode(',.', $projectedMarkers) . '))');
}
foreach ( $controls as $marker ) {
$rewritten[] = $this->projectControlSelector($selector, $parsed, $marker);
}
+ foreach ( $semanticLeaves as $marker ) {
+ $rewritten[] = $this->projectSemanticLeafSelector($selector, $parsed, $marker);
+ }
+ foreach ( $richTextLeaves as $marker ) {
+ $rewritten[] = $this->projectRichTextSemanticSelector($selector, $parsed, $marker);
+ }
}
return implode(',', $rewritten);
}
@@ -867,6 +915,20 @@ private function projectControlSelector(string $selector, array $parsed, string
return ':where(.' . $marker . ')' . $this->selectorSpecificityShims($parsed) . '> :where(.wp-block-button__link)' . $suffix;
}
+ /** @param array $parsed */
+ private function projectSemanticLeafSelector(string $selector, array $parsed, string $marker): string
+ {
+ $suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']);
+ return ':where(.' . $marker . ')' . $this->selectorSpecificityShims($parsed) . $suffix;
+ }
+
+ /** @param array $parsed */
+ private function projectRichTextSemanticSelector(string $selector, array $parsed, string $marker): string
+ {
+ $suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']);
+ return 'mark[style*="--blocks-engine-richtext-marker:' . $marker . '"]' . $this->selectorSpecificityShims($parsed) . $suffix;
+ }
+
/** @param array $parsed */
private function rightmostTypeIsControl(array $parsed): bool
{
@@ -1302,6 +1364,25 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
return $inlineSvgTextGroup;
}
+ if ( $this->hasAuthorSemanticMarker($element) ) {
+ $content = $this->innerHtml($element);
+ if ( '' !== trim($this->runtime->stripAllTags($content)) ) {
+ return $this->createBlock('core/group', $this->presentationAttributes($element), array(
+ $this->createBlock('core/paragraph', array( 'content' => $content )),
+ ), $element);
+ }
+ }
+
+ $richTextMarker = $this->richTextMarkerForElement($element);
+ if ( '' !== $richTextMarker ) {
+ $content = $this->innerHtml($element);
+ if ( '' !== trim($this->runtime->stripAllTags($content)) ) {
+ return $this->createBlock('core/paragraph', array(
+ 'content' => '' . $content . '',
+ ), array(), $element);
+ }
+ }
+
$dynamicText = $this->dynamicTextContent($element);
if ( null !== $dynamicText ) {
return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $this->runtime->escapeHtml($dynamicText) )), array(), $element);
@@ -1979,8 +2060,12 @@ private function createBlock(string $name, array $attrs = array(), array $innerB
if ( isset($this->sourceTagMarkers[$sourceTagName]) ) {
$attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), $this->sourceTagMarkers[$sourceTagName]);
}
+ $semanticMarkers = $this->authorSemanticMarkersForElement($sourceElement);
+ if ( array() !== $semanticMarkers ) {
+ $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), ...$semanticMarkers);
+ }
$logicalControl = $logicalSourceElement ?? $sourceElement;
- if ( 'core/button' === $name && in_array(strtolower($logicalControl->tagName), array( 'a', 'button' ), true) && isset($this->sourceControlPaths[$logicalControl->getNodePath() ?? '']) ) {
+ if ( 'core/button' === $name && in_array(strtolower($logicalControl->tagName), array( 'a', 'button' ), true) && ( isset($this->sourceControlPaths[$logicalControl->getNodePath() ?? '']) || ( '' !== $this->combinedAuthorCss && 'a' === strtolower($logicalControl->tagName) && ( '' !== trim($this->attr($logicalControl, 'class')) || '' !== trim($this->attr($logicalControl, 'id')) ) ) ) ) {
$path = $logicalControl->getNodePath() ?? '';
if ( '' !== $path && ! isset($this->sourceControlMarkers[$path]) ) {
$this->sourceControlMarkers[$path] = $this->allocateAuthorMarker('control');
@@ -2016,6 +2101,106 @@ private function createBlock(string $name, array $attrs = array(), array $innerB
return $block;
}
+ private function hasAuthorSemanticMarker(DOMElement $element): bool
+ {
+ return array() !== $this->authorSemanticMarkersForElement($element);
+ }
+
+ /** @return list */
+ private function authorSemanticMarkersForElement(DOMElement $element): array
+ {
+ if ( 'span' !== strtolower($element->tagName) ) {
+ return array();
+ }
+
+ $marker = $this->sourceSemanticMarkers[$this->sourceElementIdentity($element)] ?? '';
+ return '' === $marker ? array() : array( $marker );
+ }
+
+ private function requiresIndependentSemanticWrapper(DOMElement $element): bool
+ {
+ if ( 'span' !== strtolower($element->tagName) || $this->isRichTextInlineContext($element) ) {
+ return false;
+ }
+
+ $parent = $element->parentNode instanceof DOMElement ? $element->parentNode : null;
+ if ( ! $parent instanceof DOMElement || ! $this->isStructuralLayoutElement($parent) ) {
+ return false;
+ }
+
+ $declarations = array_merge($this->presentationDeclarations($element), $this->authorSemanticDeclarations($element));
+ $display = strtolower(trim((string) ($declarations['display'] ?? 'inline')));
+ if ( ! in_array($display, array( '', 'inline', 'inherit', 'initial', 'unset' ), true) ) {
+ return true;
+ }
+
+ foreach ( array( 'font-size', 'line-height', 'letter-spacing', 'text-transform', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border', 'border-width', 'border-color', 'border-radius', 'margin', 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height' ) as $property ) {
+ if ( '' !== trim((string) ($declarations[$property] ?? '')) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function isStructuralLayoutElement(DOMElement $element): bool
+ {
+ $declarations = array_merge($this->presentationDeclarations($element), $this->authorSemanticDeclarations($element));
+ return in_array(strtolower(trim((string) ($declarations['display'] ?? ''))), array( 'flex', 'inline-flex', 'grid', 'inline-grid' ), true);
+ }
+
+ /** @param array $parsed */
+ private function richTextSelectorNeedsHook(array $parsed): bool
+ {
+ foreach ( $parsed['compounds'] as $compound ) {
+ if ( array() !== $compound['classes'] || array() !== $compound['ids'] || array() !== $compound['attributes'] ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /** @return array */
+ private function authorSemanticDeclarations(DOMElement $element): array
+ {
+ $declarations = array();
+ foreach ( $this->staticStyleRules as $rule ) {
+ $parsed = CssSelectorMatcher::parse($rule['selector']);
+ if ( $parsed['supported'] && CssSelectorMatcher::matches($element, $parsed, true)['matches'] ) {
+ $declarations = array_merge($declarations, $rule['declarations']);
+ }
+ }
+
+ return $declarations;
+ }
+
+ private function isRichTextInlineContext(DOMElement $element): bool
+ {
+ for ( $parent = $element->parentNode; $parent instanceof DOMElement; $parent = $parent->parentNode ) {
+ if ( in_array(strtolower($parent->tagName), array( 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li' ), true) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function sourceElementIdentity(DOMElement $element): string
+ {
+ return $element->getNodePath() ?? '';
+ }
+
+ private function richTextMarkerForElement(DOMElement $element): string
+ {
+ $marker = trim($this->attr($element, 'data-blocks-engine-richtext-marker'));
+ if ( '' !== $marker ) {
+ return $marker;
+ }
+
+ return $this->sourceRichTextSemanticMarkers[$this->sourceElementIdentity($element)] ?? '';
+ }
+
/**
* Lift class/style styling hooks out of a paragraph/heading/list-item's
* RichText `content` so the stored block round-trips through RichText
@@ -2192,7 +2377,7 @@ private function isStylingHookSpan(DOMElement $element): bool
$hasStyling = false;
foreach ( $element->attributes ?? array() as $attribute ) {
$attributeName = strtolower($attribute->nodeName);
- if ( 'class' !== $attributeName && 'style' !== $attributeName ) {
+ if ( ! in_array($attributeName, array( 'class', 'style', 'data-blocks-engine-richtext-marker' ), true) ) {
return false;
}
if ( '' !== trim($attribute->nodeValue ?? '') ) {
@@ -2327,6 +2512,10 @@ private function richTextContentWithMaterializedInlineStyles(DOMElement $element
}
$inline = $this->richTextInlineVisualDeclarations($sourceInline);
+ $marker = $this->richTextMarkerForElement($sourceInline);
+ if ( '' !== $marker ) {
+ $inline['--blocks-engine-richtext-marker'] = $marker;
+ }
if ( array() === $inline ) {
continue;
}
@@ -2375,10 +2564,16 @@ private function replaceRichTextStylingHookWithMark(DOMElement $element): bool
}
$declarations = $this->richTextInlineVisualDeclarations($element);
- if ( array() === $declarations ) {
+ $existingDeclarations = $this->cssDeclarations($this->attr($element, 'style'));
+ $marker = trim((string) ($existingDeclarations['--blocks-engine-richtext-marker'] ?? ''));
+ if ( '' === $marker && array() === $declarations ) {
return false;
}
+ if ( '' !== $marker ) {
+ $declarations['--blocks-engine-richtext-marker'] = $marker;
+ }
+
if ( ! isset($declarations['background-color']) ) {
$declarations['background-color'] = 'transparent';
}
@@ -2980,6 +3175,10 @@ private function visualTextWrapperBlockFromElement(DOMElement $element): ?array
return null;
}
+ if ( $this->hasAuthorSemanticMarkedChild($element) || $this->hasRichTextMarkedDescendant($element) ) {
+ return null;
+ }
+
$content = $this->richTextContentWithMaterializedInlineStyles($element);
if ( '' === trim($this->runtime->stripAllTags($content)) || $this->richTextRequiresHtmlFallback($content) ) {
return null;
@@ -3028,12 +3227,19 @@ private function paragraphBlockFromInlineContentWrapper(DOMElement $element): ?a
return null;
}
+ // A CSS-addressed inline leaf needs an independent native wrapper. Do
+ // not absorb it into this parent RichText paragraph, where its selector
+ // path and flex/grid item geometry would be lost.
+ if ( $this->hasAuthorSemanticMarkedChild($element) || $this->hasRichTextMarkedDescendant($element) ) {
+ return null;
+ }
+
$structuredInlineItems = $this->structuredInlineItemBlocks($element);
if ( null !== $structuredInlineItems ) {
return $this->createBlock('core/group', $this->presentationAttributes($element), $structuredInlineItems, $element);
}
- $content = $this->innerHtml($element);
+ $content = $this->richTextContentWithMaterializedInlineStyles($element);
if ( '' === trim($this->runtime->stripAllTags($content)) ) {
return null;
}
@@ -3041,6 +3247,28 @@ private function paragraphBlockFromInlineContentWrapper(DOMElement $element): ?a
return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $content )), array(), $element);
}
+ private function hasAuthorSemanticMarkedChild(DOMElement $element): bool
+ {
+ foreach ( $element->childNodes as $child ) {
+ if ( $child instanceof DOMElement && $this->hasAuthorSemanticMarker($child) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function hasRichTextMarkedDescendant(DOMElement $element): bool
+ {
+ foreach ( $element->getElementsByTagName('span') as $span ) {
+ if ( $span instanceof DOMElement && '' !== $this->richTextMarkerForElement($span) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private function hasOnlyPhrasingChildren(DOMElement $element): bool
{
$nonAnchorText = false;
diff --git a/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php b/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php
index ac08bc96..33dbfee9 100644
--- a/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php
+++ b/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php
@@ -266,7 +266,15 @@ private function isNativeImageCompatibleSvg(DOMElement $element, string $html):
*/
private function svgImageDimensions(DOMElement $element, string $html): array
{
- $width = $this->svgLengthAttributeForImage($this->attr($element, 'width'));
+ $sourceWidth = trim($this->attr($element, 'width'));
+ // A percentage SVG width has a used size from its containing block. Keep
+ // that responsive width on the native image and let its viewBox provide
+ // the intrinsic aspect ratio instead of pinning a viewBox-height value.
+ if ( null !== $this->svgPercentageWidth($sourceWidth) ) {
+ return array( 'width' => $sourceWidth );
+ }
+
+ $width = $this->svgLengthAttributeForImage($sourceWidth);
$height = $this->svgLengthAttributeForImage($this->attr($element, 'height'));
if ( '' !== $width && '' !== $height ) {
return array( 'width' => $width, 'height' => $height );
@@ -285,6 +293,19 @@ private function svgImageDimensions(DOMElement $element, string $html): array
return array_filter(array( 'width' => $width, 'height' => $height ), static fn (string $value): bool => '' !== $value);
}
+ private function svgPercentageWidth(string $value): ?float
+ {
+ if ( 1 !== preg_match('/^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?%$/', $value) ) {
+ return null;
+ }
+
+ $number = (float) substr($value, 0, -1);
+ // SVG width is a non-negative length. Keep valid signed/exponent CSS
+ // numbers when usable, and fall back to intrinsic dimensions for a
+ // negative used width rather than emitting invalid image geometry.
+ return is_finite($number) && $number >= 0 ? $number : null;
+ }
+
private function svgLengthAttributeForImage(string $value): string
{
$value = trim($value);
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index ddfd400c..5457e452 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -566,6 +566,26 @@ public function match(DOMElement $element, PatternContext $context): ?array
$assert(str_contains($largeCssSizedInlineSvgArtworkMarkup, '