diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 3a4e5d1c..90cfe205 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -2697,15 +2697,42 @@ private function inlineSvgBlockFromElement(DOMElement $element): ?array return null; } - $attrs = array_filter(array_merge($this->presentationAttributes($element), array( - 'url' => $this->materializeInlineSvgAsset($html, $element), - 'alt' => $this->inlineSvgAltText($element), - 'title' => $this->inlineSvgTitleText($element), - 'width' => $this->attr($element, 'width'), - 'height' => $this->attr($element, 'height'), - )), static fn ($value): bool => '' !== $value); + // Keep illustrative/decorative inline SVG inline as a core/html block. + // Externalizing to an `assets/*.svg` file + core/image would be lost in + // WordPress, which blocks SVG uploads by default. The markup is already + // safe-sanitized above (scripts, event handlers, javascript: URLs + // stripped via safeFallbackHtml + verified by isSafeSvgContent), and the + // original outer SVG preserves viewBox/role/aria-label/class. + return $this->createBlock('core/html', array( 'content' => $this->restoreSvgAttributeCasing($html) ), array(), $element); + } + + /** + * Restore the canonical camelCase casing of SVG attribute names that the + * HTML parser lowercases (e.g. `viewbox` -> `viewBox`). SVG attribute names + * are case-sensitive, so a lowercased `viewbox` is ignored by browsers and + * the inline SVG would not scale to its viewport. + */ + private function restoreSvgAttributeCasing(string $html): string + { + static $camelCaseAttributes = array( + 'viewBox', 'preserveAspectRatio', 'baseProfile', 'attributeName', 'attributeType', + 'repeatCount', 'repeatDur', 'calcMode', 'keyPoints', 'keySplines', 'keyTimes', + 'gradientUnits', 'gradientTransform', 'spreadMethod', 'patternUnits', + 'patternContentUnits', 'patternTransform', 'clipPath', 'clipPathUnits', + 'maskUnits', 'maskContentUnits', 'markerWidth', 'markerHeight', 'markerUnits', + 'refX', 'refY', 'stdDeviation', 'stitchTiles', 'surfaceScale', 'specularConstant', + 'specularExponent', 'diffuseConstant', 'kernelMatrix', 'kernelUnitLength', + 'numOctaves', 'baseFrequency', 'tableValues', 'targetX', 'targetY', + 'lengthAdjust', 'textLength', 'startOffset', 'pathLength', 'filterUnits', + 'primitiveUnits', 'edgeMode', 'limitingConeAngle', 'pointsAtX', 'pointsAtY', + 'pointsAtZ', 'systemLanguage', + ); + + foreach ( $camelCaseAttributes as $attribute ) { + $html = preg_replace('/(\s)' . preg_quote($attribute, '/') . '(\s*=)/i', '$1' . $attribute . '$2', $html) ?? $html; + } - return $this->createBlock('core/image', $attrs, array(), $element); + return $html; } /** @@ -2795,39 +2822,6 @@ private function numericSvgLength(string $value): ?float return preg_match('/^\s*(\d+(?:\.\d+)?)(?:px)?\s*$/i', $value, $matches) ? (float) $matches[1] : null; } - private function materializeInlineSvgAsset(string $html, DOMElement $element): string - { - $hash = hash('sha256', $html); - $path = 'assets/inline-svg-' . substr($hash, 0, 16) . '.svg'; - - if ( ! isset($this->generatedAssets[$path]) ) { - $this->generatedAssets[$path] = array( - 'source' => 'html-inline-svg', - 'path' => $path, - 'target_path' => $path, - 'kind' => 'image', - 'role' => 'image', - 'media_type' => 'image/svg+xml', - 'mime_type' => 'image/svg+xml', - 'bytes' => strlen($html), - 'binary' => false, - 'encoding' => 'text', - 'content_encoding' => 'text', - 'content' => $html, - 'hash' => $hash, - 'references' => array( - array_filter(array( - 'selector' => $this->elementSelector($element), - 'element' => 'svg', - 'attribute' => 'generated-inline-svg', - )), - ), - ); - } - - return $path; - } - private function inlineSvgAltText(DOMElement $element): string { if ( 'true' === strtolower($this->attr($element, 'aria-hidden')) || 'presentation' === strtolower($this->attr($element, 'role')) ) { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 7dbe4cbb..c81bdaca 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -547,7 +547,11 @@ public function match(DOMElement $element, PatternContext $context): ?array $complexSvgAsset = ( new HtmlTransformer() )->transform( 'Site illustration' )->toArray(); -$assert('core/image' === ($complexSvgAsset['blocks'][0]['blockName'] ?? ''), 'large accessible inline SVG stays on the existing image asset path'); +$complexSvgContent = (string) ($complexSvgAsset['blocks'][0]['attrs']['content'] ?? ''); +$assert('core/html' === ($complexSvgAsset['blocks'][0]['blockName'] ?? ''), 'large illustrative inline SVG is preserved inline as a core/html block (WordPress blocks SVG uploads, so an externalized .svg asset would not render)'); +$assert(array() === ($complexSvgAsset['assets'] ?? array()), 'inline illustrative SVG is not externalized to a generated .svg image asset'); +$assert(str_contains($complexSvgContent, 'transform('
x=2
')->toArray(); $mathMlBlock = $mathMlResult['blocks'][0] ?? array(); @@ -872,7 +876,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(1 <= count($preservedRuntimeDiagnostics), 'runtime script fallback emits preserved_runtime_island diagnostics'); $assert('runtime_island_preserved' === ($preservedRuntimeDiagnostics[0]['diagnostic_class'] ?? ''), 'preserved_runtime_island diagnostic exposes runtime-island diagnostic class'); $assertNormalizedFallbackDiagnostic($diagnosticsByCode['html_iframe_embed_fallback'] ?? array(), 'html_iframe_embed_fallback', 'warning', 'third_party_embed_runtime', 'embed'); -$assert(! isset($diagnosticsByCode['html_inline_svg_fallback']), 'safe inline SVGs convert to image blocks instead of fallback diagnostics'); +$assert(! isset($diagnosticsByCode['html_inline_svg_fallback']), 'safe inline SVGs convert to inline core/html blocks instead of fallback diagnostics'); $assert(! isset($diagnosticsByCode['html_canvas_runtime_fallback']), 'non-runtime canvas does not emit runtime canvas fallback diagnostics'); $safeProviderIframe = ( new HtmlTransformer() )->transform( @@ -1027,7 +1031,8 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(array() === $safeDecorativeDiagnostics, 'safe decorative inline SVGs do not emit fallback diagnostics'); $assert(1 <= ($safeDecorativeSvg['metrics']['block_count'] ?? 0), 'safe decorative inline SVG wrappers still materialize when they carry presentation signals'); $assert(! str_contains((string) ($safeDecorativeSvg['serialized_blocks'] ?? ''), 'data:image/svg+xml,'), 'safe decorative inline SVGs do not serialize as image data URIs'); -$assert(! str_contains(rawurldecode((string) ($safeDecorativeSvg['serialized_blocks'] ?? '')), 'transform( @@ -1167,12 +1172,11 @@ public function match(DOMElement $element, PatternContext $context): ?array 'generated_html' => 'Inline logo', ) )->toArray(); -$artifactInlineSvgPath = (string) ($artifactInlineSvg['blocks'][0]['attrs']['url'] ?? ''); -$artifactInlineSvgAsset = $artifactInlineSvg['source_reports']['materialization_plan']['assets'][0] ?? array(); -$assert(str_starts_with($artifactInlineSvgPath, 'assets/inline-svg-'), 'artifact safe inline SVG block references a generated SVG asset'); -$assert($artifactInlineSvgPath === ($artifactInlineSvgAsset['path'] ?? ''), 'artifact materialization plan includes generated inline SVG asset path'); -$assert('image/svg+xml' === ($artifactInlineSvgAsset['mime_type'] ?? ''), 'artifact generated inline SVG asset has SVG MIME type'); -$assert(str_contains((string) ($artifactInlineSvgAsset['content'] ?? ''), 'aria-label="Inline logo"'), 'artifact generated inline SVG asset preserves sanitized SVG content'); +$artifactInlineSvgContent = (string) ($artifactInlineSvg['blocks'][0]['attrs']['content'] ?? ''); +$artifactInlineSvgAssets = $artifactInlineSvg['source_reports']['materialization_plan']['assets'] ?? array(); +$assert('core/html' === ($artifactInlineSvg['blocks'][0]['blockName'] ?? ''), 'artifact safe inline SVG is preserved inline as a core/html block (WordPress blocks SVG uploads, so an externalized .svg asset would not render)'); +$assert(array() === $artifactInlineSvgAssets, 'artifact safe inline SVG is not externalized to a generated .svg image asset'); +$assert(str_contains($artifactInlineSvgContent, 'aria-label="Inline logo"'), 'artifact inline SVG block preserves sanitized SVG content'); $assert(! str_contains((string) ($artifactInlineSvg['serialized_blocks'] ?? ''), 'data:image/svg+xml,'), 'artifact safe inline SVG avoids data URI serialization'); $artifactNonEntryInlineSvg = $compiler->compile( diff --git a/php-transformer/tests/fixtures/parity/html-flex-media-text-columns.json b/php-transformer/tests/fixtures/parity/html-flex-media-text-columns.json index 6754379e..ba6e8a06 100644 --- a/php-transformer/tests/fixtures/parity/html-flex-media-text-columns.json +++ b/php-transformer/tests/fixtures/parity/html-flex-media-text-columns.json @@ -1,7 +1,7 @@ { "schema": "blocks-engine/php-transformer/parity-fixture/v1", "name": "html-flex-media-text-columns", - "description": "Converts two-child flex media/text rows with direct SVG children as flex groups without unsupported primitive fallbacks.", + "description": "Converts two-child flex media/text rows with direct illustrative SVG children as flex groups, preserving the SVG inline as a core/html block (WordPress blocks SVG uploads, so an externalized .svg asset would not render).", "source_reference": { "repo": "php-transformer", "path": "tests/fixtures/parity/html-flex-media-text-columns.json", @@ -17,7 +17,7 @@ }, "expected_blocks": [ { "path": "blocks.0", "name": "core/group", "attrs": { "layout": { "type": "flex" } } }, - { "path": "blocks.0.innerBlocks.0", "name": "core/image", "attrs": { "alt": "Paw width diagram" } }, + { "path": "blocks.0.innerBlocks.0", "name": "core/html" }, { "path": "blocks.0.innerBlocks.1", "name": "core/group" }, { "path": "blocks.0.innerBlocks.1.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Paw Width", "level": 3 } }, { "path": "blocks.0.innerBlocks.1.innerBlocks.1", "name": "core/paragraph", "attrs": { "content": "Measure the widest point while standing." } } @@ -28,8 +28,9 @@ { "path": "metrics.block_count", "assert": "equals", "value": 5 }, { "path": "blocks", "assert": "count", "count": 1 }, { "path": "blocks.0.innerBlocks", "assert": "count", "count": 2 }, - { "path": "blocks.0.innerBlocks.0.attrs.url", "assert": "contains", "value": "assets/inline-svg-" }, - { "path": "assets.0.content", "assert": "contains", "value": "WIDTH" }, + { "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "" }, + { "path": "assets", "assert": "count", "count": 0 }, { "path": "serialized_blocks", "assert": "contains", "value": "product-price" }, { "path": "source_reports.html.structure_signals.3.signals.price_like", "assert": "equals", "value": true }, { "path": "source_reports.html.structure_signals.14.signals.grid_like", "assert": "equals", "value": true } diff --git a/php-transformer/tests/fixtures/parity/html-single-child-flex-svg-address.json b/php-transformer/tests/fixtures/parity/html-single-child-flex-svg-address.json index 7f9ed968..a291ea55 100644 --- a/php-transformer/tests/fixtures/parity/html-single-child-flex-svg-address.json +++ b/php-transformer/tests/fixtures/parity/html-single-child-flex-svg-address.json @@ -1,7 +1,7 @@ { "schema": "blocks-engine/php-transformer/parity-fixture/v1", "name": "html-single-child-flex-svg-address", - "description": "Preserves single-child flex wrappers with safe inline SVG images and readable address line breaks without unsupported fallbacks.", + "description": "Preserves single-child flex wrappers with safe illustrative inline SVG (kept inline as a core/html block, since WordPress blocks SVG uploads and an externalized .svg asset would not render) and readable address line breaks without unsupported fallbacks.", "source_reference": { "repo": "php-transformer", "path": "tests/fixtures/parity/html-single-child-flex-svg-address.json", @@ -18,15 +18,16 @@ "expected_blocks": [ { "path": "blocks.0", "name": "core/group" }, { "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "illustration-frame", "style": "display:flex;align-items:center;justify-content:center;" } }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/image", "attrs": { "alt": "Storefront illustration" } }, + { "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/html" }, { "path": "blocks.0.innerBlocks.1", "name": "core/paragraph", "attrs": { "className": "contact-card", "content": "214 Maple Street
Benton Harbor, MI
(269) 555-0193" } } ], "expected_fallbacks": [], "expect": [ { "path": "status", "assert": "equals", "value": "success" }, { "path": "fallbacks", "assert": "count", "count": 0 }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.url", "assert": "contains", "value": "assets/inline-svg-" }, - { "path": "assets.0.content", "assert": "contains", "value": "Storefront illustration" }, + { "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "