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
111 changes: 111 additions & 0 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,11 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
return $this->createBlock('core/html', array( 'content' => $this->outerHtml($element) ), array(), $element);
}

$inlineSvgTextGroup = $this->inlineSvgTextGroupBlockFromElement($element);
if ( null !== $inlineSvgTextGroup ) {
return $inlineSvgTextGroup;
}

$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);
Expand Down Expand Up @@ -2772,6 +2777,106 @@ private function richTextContentWithoutDecorativeSvg(DOMElement $element): strin
return $this->stripDecorativeSvgFromRichText($this->innerHtml($element));
}

/**
* Convert an inline text token with a passive SVG into native sibling blocks.
* RichText cannot retain SVG markup, but a materialized core/image can retain
* the artwork while the wrapper class remains available to the stylesheet.
*
* @return array<string, mixed>|null
*/
private function inlineSvgTextGroupBlockFromElement(DOMElement $element): ?array
{
if ( 'span' !== strtolower($element->tagName) || '' === trim($this->attr($element, 'class')) || 0 === $element->getElementsByTagName('svg')->length ) {
return null;
}

foreach ( $element->childNodes as $child ) {
if ( XML_TEXT_NODE === $child->nodeType ) {
continue;
}

if ( ! $child instanceof DOMElement ) {
return null;
}

$tagName = strtolower($child->tagName);
if ( 'svg' === $tagName ) {
continue;
}

if ( 'a' !== $tagName && 'br' !== $tagName && ! $this->isInlineContentElement($tagName) ) {
return null;
}

foreach ( $child->getElementsByTagName('*') as $descendant ) {
if ( ! $descendant instanceof DOMElement ) {
continue;
}

$descendantTagName = strtolower($descendant->tagName);
if ( 'a' !== $descendantTagName && 'br' !== $descendantTagName && ! $this->isInlineContentElement($descendantTagName) ) {
return null;
}
}
}

$blocks = array();
$textRun = '';
$hasTextRun = false;
$flushTextRun = function () use ( &$blocks, &$textRun, &$hasTextRun ): bool {
$content = trim($textRun);
$textRun = '';
if ( '' === trim($this->runtime->stripAllTags($content)) ) {
return true;
}
if ( $this->richTextRequiresHtmlFallback($content) ) {
return false;
}

$blocks[] = $this->createBlock('core/paragraph', array( 'content' => $content ));
$hasTextRun = true;
return true;
};

$generatedAssets = $this->generatedAssets;
foreach ( $element->childNodes as $child ) {
if ( XML_TEXT_NODE === $child->nodeType ) {
$textRun .= htmlspecialchars($child->textContent ?? '', ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
continue;
}

if ( ! $child instanceof DOMElement ) {
$this->generatedAssets = $generatedAssets;
return null;
}

if ( 'svg' !== strtolower($child->tagName) ) {
$textRun .= $this->outerHtml($child);
continue;
}

if ( ! $flushTextRun() ) {
$this->generatedAssets = $generatedAssets;
return null;
}

$image = $this->inlineSvgBlockFromElement($child);
if ( ! is_array($image) || 'core/image' !== ($image['blockName'] ?? '') ) {
$this->generatedAssets = $generatedAssets;
return null;
}

$blocks[] = $image;
}

if ( ! $flushTextRun() || ! $hasTextRun ) {
$this->generatedAssets = $generatedAssets;
return null;
}

return $this->createBlock('core/group', $this->presentationAttributes($element), $blocks, $element);
}

private function stripDecorativeSvgFromRichText(string $content): string
{
$content = preg_replace('/<(?:span|i|b)\b(?=[^>]*\baria-hidden\s*=\s*(["\'])true\1)[^>]*>\s*<svg\b[\s\S]*?<\/svg>\s*<\/(?:span|i|b)>\s*/i', '', $content) ?? $content;
Expand Down Expand Up @@ -2995,6 +3100,12 @@ private function structuredInlineItemBlocks(DOMElement $element): ?array
return null;
}

$inlineSvgTextGroup = $this->inlineSvgTextGroupBlockFromElement($child);
if ( null !== $inlineSvgTextGroup ) {
$blocks[] = $inlineSvgTextGroup;
continue;
}

$content = $this->innerHtml($child);
if ( '' === trim($this->runtime->stripAllTags($content)) ) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "html-inline-svg-text-token-native-decomposition",
"description": "Decomposes classed inline text tokens containing passive SVGs into native paragraphs and materialized images in source order rather than storing unsupported SVG markup in RichText.",
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/html-inline-svg-text-token-native-decomposition.json",
"notes": "Covers generic marquee, badge, and label tokens where SVG artwork and text are siblings."
},
"legacy_comparison": {
"skip": true,
"reason": "This upstream native decomposition has no downstream legacy comparison."
},
"operation": "html_transformer.transform",
"input": {
"content": "<div class=\"ticker\"><span class=\"ticker-item ticker-item--leading\"><svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><circle cx=\"12\" cy=\"12\" r=\"5\"></circle></svg>Open source</span><span class=\"ticker-item ticker-item--split\">Before <svg viewBox=\"0 0 24 24\" fill=\"currentColor\"><circle cx=\"12\" cy=\"12\" r=\"5\"></circle></svg> after</span></div>",
"options": {
"static_css": ".ticker-item{display:inline-flex;gap:8px;color:#125e8a}"
}
},
"expected_blocks": [
{ "path": "blocks.0", "name": "core/group", "attrs": { "className": "ticker" } },
{ "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "ticker-item ticker-item--leading" } },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/image" },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.1", "name": "core/paragraph", "attrs": { "content": "Open source" } },
{ "path": "blocks.0.innerBlocks.1", "name": "core/group", "attrs": { "className": "ticker-item ticker-item--split" } },
{ "path": "blocks.0.innerBlocks.1.innerBlocks.0", "name": "core/paragraph", "attrs": { "content": "Before" } },
{ "path": "blocks.0.innerBlocks.1.innerBlocks.1", "name": "core/image" },
{ "path": "blocks.0.innerBlocks.1.innerBlocks.2", "name": "core/paragraph", "attrs": { "content": "after" } }
],
"expected_fallbacks": [],
"expect": [
{ "path": "status", "assert": "equals", "value": "success" },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.url", "assert": "contains", "value": "assets/materialized-svg/" },
{ "path": "blocks.0.innerBlocks.1.innerBlocks.1.attrs.url", "assert": "contains", "value": "assets/materialized-svg/" },
{ "path": "serialized_blocks", "assert": "contains", "value": "wp-block-group ticker-item ticker-item--leading" },
{ "path": "serialized_blocks", "assert": "contains", "value": "<p>Open source</p>" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "<!-- wp:html" },
{ "path": "fallbacks", "assert": "count", "count": 0 }
]
}
Loading