From e509423a513dea435464748f2c965a15b3981091 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 18 Jul 2026 09:40:46 -0400 Subject: [PATCH] Decompose inline SVG text tokens --- .../src/HtmlToBlocks/HtmlTransformer.php | 111 ++++++++++++++++++ ...e-svg-text-token-native-decomposition.json | 41 +++++++ 2 files changed, 152 insertions(+) create mode 100644 php-transformer/tests/fixtures/parity/html-inline-svg-text-token-native-decomposition.json diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index b3ce1bbd..1e4970e9 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -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); @@ -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|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*\s*<\/(?:span|i|b)>\s*/i', '', $content) ?? $content; @@ -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; diff --git a/php-transformer/tests/fixtures/parity/html-inline-svg-text-token-native-decomposition.json b/php-transformer/tests/fixtures/parity/html-inline-svg-text-token-native-decomposition.json new file mode 100644 index 00000000..59590f0e --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-inline-svg-text-token-native-decomposition.json @@ -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": "
Open sourceBefore after
", + "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": "

Open source

" }, + { "path": "serialized_blocks", "assert": "not_contains", "value": "