Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion php-transformer/src/HtmlToBlocks/BlockFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -467,14 +467,24 @@ 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'];
}

return implode(';', $style);
}

private function isPercentageWidth(string $width): bool
{
return 1 === preg_match('/%\s*$/', trim($width));
}

/**
* @param array<string, mixed> $attrs
*/
Expand Down
268 changes: 248 additions & 20 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand All @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,26 @@ public function match(DOMElement $element, PatternContext $context): ?array
$assert(str_contains($largeCssSizedInlineSvgArtworkMarkup, '<!-- wp:image'), 'large CSS-sized inline SVG artwork materializes as native image without a data URI budget');
$assert(! str_contains($largeCssSizedInlineSvgArtworkMarkup, '<svg class="hero-cover" viewBox="0 0 500 500" role="img" aria-label="Hero cover" width="500" height="500"'), 'large CSS-sized inline SVG artwork does not inject intrinsic SVG dimensions over source CSS sizing');

$percentageWidthSvg = ( new HtmlTransformer() )->transform('<main><svg width="100%" viewBox="0 0 620 380" role="img" aria-label="Responsive map"><rect width="620" height="380" fill="#111"/></svg></main>')->toArray();
$percentageWidthSvgMarkup = (string) ($percentageWidthSvg['serialized_blocks'] ?? '');
$assert(str_contains($percentageWidthSvgMarkup, 'style="width:100%"') && ! str_contains($percentageWidthSvgMarkup, 'height:auto') && ! str_contains($percentageWidthSvgMarkup, 'height:380px') && str_contains((string) ($percentageWidthSvg['assets'][0]['content'] ?? ''), 'viewBox="0 0 620 380"'), 'percentage-width inline SVG core/image uses canonical width-only markup while the SVG viewBox supplies its intrinsic aspect ratio');

$fractionalPercentageWidthSvg = ( new HtmlTransformer() )->transform('<main><svg width=".5%" viewBox="0 0 620 380" role="img" aria-label="Fractional responsive map"><rect width="620" height="380" fill="#111"/></svg></main>')->toArray();
$fractionalPercentageWidthSvgMarkup = (string) ($fractionalPercentageWidthSvg['serialized_blocks'] ?? '');
$assert(str_contains($fractionalPercentageWidthSvgMarkup, 'style="width:.5%"') && ! str_contains($fractionalPercentageWidthSvgMarkup, 'height:auto') && ! str_contains($fractionalPercentageWidthSvgMarkup, 'height:380px'), 'fractional percentage-width inline SVG core/image uses canonical width-only responsive markup');

$signedPercentageWidthSvg = ( new HtmlTransformer() )->transform('<main><svg width="+.5%" viewBox="0 0 620 380" role="img" aria-label="Signed responsive map"><rect width="620" height="380" fill="#111"/></svg></main>')->toArray();
$signedPercentageWidthSvgMarkup = (string) ($signedPercentageWidthSvg['serialized_blocks'] ?? '');
$assert(str_contains($signedPercentageWidthSvgMarkup, 'style="width:+.5%"') && ! str_contains($signedPercentageWidthSvgMarkup, 'height:auto') && ! str_contains($signedPercentageWidthSvgMarkup, 'height:380px'), 'signed fractional percentage-width inline SVG core/image uses canonical width-only responsive markup');

$exponentPercentageWidthSvg = ( new HtmlTransformer() )->transform('<main><svg width="1e2%" viewBox="0 0 620 380" role="img" aria-label="Exponent responsive map"><rect width="620" height="380" fill="#111"/></svg></main>')->toArray();
$exponentPercentageWidthSvgMarkup = (string) ($exponentPercentageWidthSvg['serialized_blocks'] ?? '');
$assert(str_contains($exponentPercentageWidthSvgMarkup, 'style="width:1e2%"') && ! str_contains($exponentPercentageWidthSvgMarkup, 'height:auto') && ! str_contains($exponentPercentageWidthSvgMarkup, 'height:380px'), 'exponent percentage-width inline SVG core/image uses canonical width-only responsive markup');

$negativePercentageWidthSvg = ( new HtmlTransformer() )->transform('<main><svg width="-1%" viewBox="0 0 620 380" role="img" aria-label="Invalid negative responsive map"><rect width="620" height="380" fill="#111"/></svg></main>')->toArray();
$negativePercentageWidthSvgMarkup = (string) ($negativePercentageWidthSvg['serialized_blocks'] ?? '');
$assert(! str_contains($negativePercentageWidthSvgMarkup, 'width:-1%') && str_contains($negativePercentageWidthSvgMarkup, 'height:380px'), 'negative SVG percentage width is rejected as invalid non-negative SVG geometry');

$fixedBackgroundLayer = ( new HtmlTransformer() )->transform(
'<style>.page-bg{position:fixed;inset:0;z-index:-1;background:linear-gradient(180deg,#211,#000)}</style><main><div class="page-bg" aria-hidden="true"></div><section class="hero"><h1>Hero</h1></section></main>'
)->toArray();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/html-author-selector-provenance.json",
"notes": "Source p type selectors use a zero-specificity provenance marker; standalone spans remain ordinary canonical blocks."
"notes": "Source p type selectors use a zero-specificity provenance marker; ordinary inline spans remain canonical paragraph content."
},
"legacy_comparison": {
"skip": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/html-richtext-partial-span-visual-mark.json",
"notes": "Generic RichText fidelity coverage for headings with styled line segments where class hooks are not safe to keep in RichText content."
"notes": "Generic RichText fidelity coverage for headings with styled line segments carried by valid mark formatting and a selector projection marker."
},
"legacy_comparison": {
"skip": true,
Expand All @@ -22,7 +22,7 @@
"expect": [
{ "path": "status", "assert": "equals", "value": "success" },
{ "path": "source_reports.wp_block_validity.status", "assert": "equals", "value": "pass" },
{ "path": "serialized_blocks", "assert": "contains", "value": "<mark style=\"display:block;color:#b8893a;background-color:transparent\">Roasters</mark>" },
{ "path": "serialized_blocks", "assert": "contains", "value": "<mark style=\"display:block;color:#b8893a;--blocks-engine-richtext-marker:blocks-engine-richtext-aa1434d6fce3-3;background-color:transparent\">Roasters</mark>" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "class=\"accent\"" },
{ "path": "fallbacks", "assert": "count", "count": 0 }
]
Expand Down
44 changes: 44 additions & 0 deletions php-transformer/tests/unit/author-selector-semantics.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,50 @@
$wrapperButton = $wrapper['blocks'][0]['innerBlocks'][0] ?? array();
$assert('core/button' === ($wrapperButton['blockName'] ?? '') && str_contains((string) ($wrapperButton['attrs']['className'] ?? ''), 'blocks-engine-control-') && 2 === substr_count($wrapperCss, '> :where(.wp-block-button__link)') && str_contains($wrapperCss, ':hover') && str_contains($wrapperCss, ':focus') && 'pass' === ($wrapper['source_reports']['wp_block_validity']['status'] ?? ''), 'wrapper-driven role=button promotion retains logical anchor selectors, presentation wrapper attributes, and valid blocks');

$navCta = $transform('<style>a.btn.btn-primary.nav-cta{display:inline-flex;align-items:center;gap:8px;font-family:monospace;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;padding:9px 20px;border-radius:6px;background:#e8a020;color:#050d1a}.nav-cta:hover{background:#f0ac22}.nav-cta:focus{outline:2px solid #fff}</style><main><a class="btn btn-primary nav-cta" href="#cta">Get Early Access</a></main>');
$navCtaMarkup = (string) ($navCta['serialized_blocks'] ?? '');
$navCtaCss = $css($navCta);
$assert(str_contains($navCtaMarkup, 'blocks-engine-control-') && str_contains($navCtaCss, '> :where(.wp-block-button__link){display:inline-flex') && str_contains($navCtaCss, 'font-family:monospace') && str_contains($navCtaCss, 'padding:9px 20px') && str_contains($navCtaCss, 'background:#e8a020') && str_contains($navCtaCss, ':hover{background:#f0ac22}') && str_contains($navCtaCss, ':focus{outline:2px solid #fff}') && 'pass' === ($navCta['source_reports']['wp_block_validity']['status'] ?? ''), 'class-bearing source anchors project compound, typography, paint, and pseudo-state selectors onto valid core/button links');

$inlineLeaves = $transform('<style>.meta{display:flex;gap:10px}.eyebrow{display:flex;gap:10px}.meta span{font:10px monospace;border:1px solid #999;padding:2px 8px}.eyebrow span{font-size:11px;letter-spacing:.1em}</style><div class="eyebrow"><span>Beta</span></div><div class="meta"><span>One</span><span>Two</span></div>');
$inlineMarkup = (string) ($inlineLeaves['serialized_blocks'] ?? '');
$inlineCss = $css($inlineLeaves);
$assert(3 === substr_count($inlineMarkup, '<div class="wp-block-group blocks-engine-semantic-') && 3 === substr_count($inlineCss, ':where(.blocks-engine-semantic-') && 'pass' === ($inlineLeaves['source_reports']['wp_block_validity']['status'] ?? ''), 'CSS-addressed sibling spans retain independent native wrapper identities and projected selector paths without HTML fallback');

$repeatedParents = $transform('<style>.row{display:flex}.row .pill{padding:2px 8px;border:1px solid #999}.other .pill{color:red}</style><div class="row"><span class="pill">First</span></div><div class="row"><span class="pill">Second</span></div><div class="other"><span class="pill">Third</span></div>');
$repeatedMarkup = (string) ($repeatedParents['serialized_blocks'] ?? '');
$repeatedCss = $css($repeatedParents);
preg_match_all('/blocks-engine-semantic-[a-f0-9]+-\d+/', $repeatedMarkup . "\n" . $repeatedCss, $repeatedMarkers);
$assert(2 === count(array_unique($repeatedMarkers[0] ?? array())) && 2 === substr_count($repeatedCss, ':where(.blocks-engine-semantic-') && str_contains($repeatedMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($repeatedCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-'), 'repeated structural parents allocate unique source-path markers without leaking their box styles into an unrelated inline sibling');

$richTextPill = $transform('<style>p .pill{padding:2px 8px;border:1px solid #999}</style><p>Read <span class="pill">more</span>.</p>');
$richTextPillMarkup = (string) ($richTextPill['serialized_blocks'] ?? '');
$richTextPillCss = $css($richTextPill);
$assert(str_contains($richTextPillMarkup, '<mark style="') && str_contains($richTextPillMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($richTextPillCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && 'pass' === ($richTextPill['source_reports']['wp_block_validity']['status'] ?? ''), 'RichText-contained selector hooks survive through valid mark formatting and projected CSS');

$richTextStates = $transform('<style>p .pill:hover{color:red}p .pill:focus{color:blue}p .pill:active{color:green}p .pill:visited{color:purple}</style><p>Read <span class="pill">more</span>.</p>');
$richTextStatesMarkup = (string) ($richTextStates['serialized_blocks'] ?? '');
$richTextStatesCss = $css($richTextStates);
$assert(str_contains($richTextStatesMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && 4 === substr_count($richTextStatesCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($richTextStatesCss, ':hover{color:red}') && str_contains($richTextStatesCss, ':focus{color:blue}') && str_contains($richTextStatesCss, ':active{color:green}') && str_contains($richTextStatesCss, ':visited{color:purple}') && ! str_contains($richTextStatesMarkup, ':hover') && ! str_contains($richTextStatesMarkup, ':focus') && ! str_contains($richTextStatesMarkup, ':active') && ! str_contains($richTextStatesMarkup, ':visited'), 'RichText marker projections retain dynamic pseudo-state suffixes without inlining a permanent state');

$nestedLeaf = $transform('<style>.meta{display:flex}.pill{padding:2px 8px;border:1px solid #999}.meta > .item .pill{color:red}</style><div class="meta"><div class="item"><span class="pill">Nested</span></div></div>');
$nestedLeafMarkup = (string) ($nestedLeaf['serialized_blocks'] ?? '');
$nestedLeafCss = $css($nestedLeaf);
$assert(! str_contains($nestedLeafMarkup, 'blocks-engine-semantic-') && str_contains($nestedLeafMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($nestedLeafCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && 'pass' === ($nestedLeaf['source_reports']['wp_block_validity']['status'] ?? ''), 'nested selector-addressable leaves retain a valid inline marker instead of becoming a structural group');

$proseBadge = $transform('<style>.card{display:flex}.badge{padding:2px 8px;border:1px solid #999}.card .badge{color:red}</style><div class="card"><div class="copy">Read <span class="badge">new</span> notes.</div></div>');
$proseBadgeMarkup = (string) ($proseBadge['serialized_blocks'] ?? '');
$proseBadgeCss = $css($proseBadge);
$assert(! str_contains($proseBadgeMarkup, 'blocks-engine-semantic-') && str_contains($proseBadgeMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($proseBadgeCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && 'pass' === ($proseBadge['source_reports']['wp_block_validity']['status'] ?? ''), 'a padded badge inside prose in a flex card remains an inline RichText marker');

$ordinaryInline = $transform('<style>span{color:red}</style><p>Read <span>this</span> now.</p>');
$ordinaryInlineMarkup = (string) ($ordinaryInline['serialized_blocks'] ?? '');
$assert(! str_contains($ordinaryInlineMarkup, 'blocks-engine-semantic-') && 'core/paragraph' === ($ordinaryInline['blocks'][0]['blockName'] ?? '') && 'pass' === ($ordinaryInline['source_reports']['wp_block_validity']['status'] ?? ''), 'ordinary inline span styling remains RichText flow rather than becoming a group wrapper');

$structural = $transform('<style>.product-layout{display:grid;grid-template-columns:1fr 20rem;gap:3rem}.product-layout > .detail-pane{min-width:0}</style><div class="product-layout"><div>Primary</div><aside class="detail-pane">Secondary</aside></div>');
$structuralMarkup = (string) ($structural['serialized_blocks'] ?? '');
$assert(str_contains($structuralMarkup, 'product-layout') && str_contains($structuralMarkup, 'detail-pane') && str_contains($css($structural), '.product-layout > .detail-pane') && 'pass' === ($structural['source_reports']['wp_block_validity']['status'] ?? ''), 'CSS-significant structural group and child classes survive native grid materialization');

$instance = new HtmlTransformer();
$first = $instance->transform('<style>p{color:red}</style><p>First</p>')->toArray();
$second = $instance->transform('<style>.cta:hover{padding:1rem}</style><a class="cta" href="/go" style="padding:1px;background:#000">Go</a>')->toArray();
Expand Down
Loading