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
76 changes: 35 additions & 41 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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')) ) {
Expand Down
22 changes: 13 additions & 9 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,11 @@ public function match(DOMElement $element, PatternContext $context): ?array
$complexSvgAsset = ( new HtmlTransformer() )->transform(
'<svg role="img" aria-label="Site illustration" viewBox="0 0 400 200"><title>Site illustration</title><path d="M0 0h400v200H0z"></path></svg>'
)->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, '<svg') && str_contains($complexSvgContent, 'viewBox="0 0 400 200"'), 'inline illustrative SVG preserves its viewBox casing so it scales correctly');
$assert(str_contains($complexSvgContent, 'role="img"') && str_contains($complexSvgContent, 'aria-label="Site illustration"'), 'inline illustrative SVG preserves accessibility attributes');

$mathMlResult = ( new HtmlTransformer() )->transform('<main><math><mi>x</mi><mo>=</mo><mn>2</mn></math></main>')->toArray();
$mathMlBlock = $mathMlResult['blocks'][0] ?? array();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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'] ?? '')), '<svg'), 'safe decorative inline SVG markup is omitted');
$assert(str_contains(rawurldecode((string) ($safeDecorativeSvg['serialized_blocks'] ?? '')), '<svg'), 'safe logo-like inline SVG markup is preserved inline instead of externalized to a blocked .svg asset');
$assert(array() === ($safeDecorativeSvg['assets'] ?? array()), 'safe decorative inline SVG does not generate an external .svg image asset');
$assert(str_contains((string) ($safeDecorativeSvg['serialized_blocks'] ?? ''), 'site-logo'), 'safe logo-like inline SVG context preserves its wrapper class');

$unsafeDecorativeSvg = ( new HtmlTransformer() )->transform(
Expand Down Expand Up @@ -1167,12 +1172,11 @@ public function match(DOMElement $element, PatternContext $context): ?array
'generated_html' => '<svg role="img" aria-label="Inline logo" viewBox="0 0 12 12"><title>Inline logo</title><path d="M0 0h12v12H0z"></path></svg>',
)
)->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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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." } }
Expand All @@ -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": "<svg viewBox=\"0 0 20 20\"" },
{ "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "WIDTH" },
{ "path": "assets", "assert": "count", "count": 0 },
{ "path": "fallbacks", "assert": "count", "count": 0 },
{ "path": "source_reports.conversion_report.metrics.fallback_count", "assert": "equals", "value": 0 }
]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "html-product-card-svg-price-grid",
"description": "Preserves product cards with SVG imagery, product names, prices, and CTA buttons inside a card grid.",
"description": "Preserves product cards with illustrative inline SVG imagery (kept inline as a core/html block, since WordPress blocks SVG uploads and an externalized .svg asset would not render), product names, prices, and CTA buttons inside a card grid.",
"source_reference": {
"repo": "php-transformer",
"path": "tests/fixtures/parity/html-product-card-svg-price-grid.json",
Expand All @@ -18,7 +18,7 @@
"expected_blocks": [
{ "path": "blocks.0", "name": "core/group", "attrs": { "className": "product-grid", "layout": { "type": "grid" } } },
{ "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "product-card" } },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/image", "attrs": { "className": "product-image", "alt": "Ceramic bowl" } },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/html" },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "className": "product-name", "content": "Ceramic Bowl", "level": 3 } },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.2", "name": "core/paragraph", "attrs": { "className": "product-price", "content": "$42" } },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.3", "name": "core/buttons" },
Expand All @@ -28,6 +28,8 @@
"expect": [
{ "path": "status", "assert": "equals", "value": "success" },
{ "path": "blocks.0.innerBlocks", "assert": "count", "count": 2 },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "<svg class=\"product-image\" role=\"img\" aria-label=\"Ceramic bowl\" viewBox=\"0 0 10 10\">" },
{ "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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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<br>Benton Harbor, MI<br><a href=\"tel:+12695550193\">(269) 555-0193</a>" } }
],
"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": "<svg viewBox=\"0 0 40 20\"" },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "Storefront illustration" },
{ "path": "assets", "assert": "count", "count": 0 },
{ "path": "serialized_blocks", "assert": "contains", "value": "Storefront illustration" },
{ "path": "coverage.0.fallback_count", "assert": "equals", "value": 0 }
]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "html-single-child-flex-svg",
"description": "Converts a single visual SVG inside a flex centering wrapper without leaking failed column-probe fallbacks.",
"description": "Converts a single illustrative SVG inside a flex centering wrapper without leaking failed column-probe fallbacks, 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-single-child-flex-svg.json",
Expand All @@ -17,16 +17,17 @@
},
"expected_blocks": [
{ "path": "blocks.0", "name": "core/group", "attrs": { "layout": { "type": "flex" } } },
{ "path": "blocks.0.innerBlocks.0", "name": "core/image", "attrs": { "alt": "Measurement diagram" } }
{ "path": "blocks.0.innerBlocks.0", "name": "core/html" }
],
"expected_fallbacks": [],
"expect": [
{ "path": "status", "assert": "equals", "value": "success" },
{ "path": "metrics.block_count", "assert": "equals", "value": 2 },
{ "path": "blocks", "assert": "count", "count": 1 },
{ "path": "blocks.0.innerBlocks", "assert": "count", "count": 1 },
{ "path": "blocks.0.innerBlocks.0.attrs.url", "assert": "contains", "value": "assets/inline-svg-" },
{ "path": "assets.0.content", "assert": "contains", "value": "GIRTH" },
{ "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "<svg viewBox=\"0 0 20 20\"" },
{ "path": "blocks.0.innerBlocks.0.attrs.content", "assert": "contains", "value": "GIRTH" },
{ "path": "assets", "assert": "count", "count": 0 },
{ "path": "fallbacks", "assert": "count", "count": 0 },
{ "path": "source_reports.conversion_report.metrics.fallback_count", "assert": "equals", "value": 0 }
]
Expand Down
Loading