From 77010ae5be7f781aa4636aafc7fdbacd6b500e7d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 22 Jul 2026 19:29:44 -0400 Subject: [PATCH 1/7] Match core image border save shape --- php-transformer/src/HtmlToBlocks/BlockFactory.php | 14 ++++++++++---- php-transformer/tests/contract/run.php | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index b3f96e48..bbba8851 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -422,8 +422,14 @@ private function imageHtml(array $attrs): string { $figureAttrs = $attrs; $baseClass = 'wp-block-image'; - if ( ! empty($attrs['style']['border']) ) { - $baseClass .= ' has-custom-border'; + $border = is_array($attrs['style']['border'] ?? null) ? $attrs['style']['border'] : array(); + $borderSupport = $this->styleSupport(array( 'border' => $border )); + if ( array() !== $border ) { + $baseClass = $this->mergeClassNames('wp-block-image has-custom-border', $borderSupport['classes']); + unset($figureAttrs['style']['border']); + if ( empty($figureAttrs['style']) ) { + unset($figureAttrs['style']); + } } if ( ! empty($attrs['sizeSlug']) ) { $figureAttrs['className'] = $this->mergeClassNames((string) ($figureAttrs['className'] ?? ''), 'size-' . (string) $attrs['sizeSlug']); @@ -438,8 +444,8 @@ private function imageHtml(array $attrs): string 'title' => $attrs['title'] ?? '', 'srcset' => $attrs['srcset'] ?? '', 'sizes' => $attrs['sizes'] ?? '', - 'class' => ! empty($attrs['id']) ? 'wp-image-' . (string) $attrs['id'] : '', - 'style' => $this->imageDimensionStyle($attrs), + 'class' => $this->mergeClassNames(! empty($attrs['id']) ? 'wp-image-' . (string) $attrs['id'] : '', $borderSupport['classes']), + 'style' => trim($this->imageDimensionStyle($attrs) . ';' . $borderSupport['style'], ';'), ); $img = 'htmlAttrs($imageAttrs, array( 'alt' )) . '/>'; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index d674a498..cf39a899 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -3203,8 +3203,8 @@ public function match(DOMElement $element, PatternContext $context): ?array 'className' => 'source-image', 'style' => array('border' => array('color' => '#ffffff', 'style' => 'solid')), )); -$assert(str_contains($borderedImage['innerHTML'], 'class="wp-block-image has-custom-border has-border-color source-image"'), 'core/image emits the custom-border class required by core save'); -$assert(str_contains($borderedImage['innerHTML'], 'style="border-color:#ffffff;border-style:solid"'), 'core/image retains canonical border support styles'); +$assert(str_contains($borderedImage['innerHTML'], '
create( 'core/table', From 46ee2d942dec0f2c6dc0b622bc3a997fee9fae6e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 22 Jul 2026 19:49:45 -0400 Subject: [PATCH 2/7] Preserve native image presentation geometry --- php-transformer/src/HtmlToBlocks/HtmlTransformer.php | 5 ++++- .../src/HtmlToBlocks/Style/StyleResolutionTrait.php | 6 ++++-- .../unit/artifact-author-stylesheet-projection.php | 12 ++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index d5ab581c..e28e0f80 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -1215,7 +1215,10 @@ private function imageProjectionBridgeDeclarations(array $declarations): string { $bridge = array( 'display:block' ); $position = strtolower(trim((string) ($declarations['position'] ?? ''))); - if ( in_array($position, array( 'absolute', 'fixed' ), true) ) { + $width = strtolower(trim((string) ($declarations['width'] ?? ''))); + $height = strtolower(trim((string) ($declarations['height'] ?? ''))); + $ownsBox = ! in_array($width, array( '', 'auto' ), true) && ! in_array($height, array( '', 'auto' ), true); + if ( $ownsBox || in_array($position, array( 'absolute', 'fixed' ), true) ) { $bridge[] = 'width:100%'; $bridge[] = 'height:100%'; } diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index fa5e90f4..5b55f10c 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -47,8 +47,8 @@ trait StyleResolutionTrait private array $mergedPresentationStyleCache = array(); /** - * Inline sizing declarations which core block supports cannot serialize are - * carried by deterministic classes in a generated stylesheet. + * Inline presentation declarations which core block supports cannot serialize + * are carried by deterministic classes in a generated stylesheet. * * @var array */ @@ -71,6 +71,8 @@ private function inlineGeometryProperties(): array 'aspect-ratio', 'box-sizing', 'flex-basis', + 'object-fit', + 'object-position', ); } diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php index 31f1399a..d527f253 100644 --- a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -105,6 +105,18 @@ $assert('text' === ($image['assets'][0]['content_encoding'] ?? '') && ! isset($image['assets'][0]['content_base64']) && '' !== $imageCss, 'stylesheet projection drops the stale base64 twin and keeps the rewritten text as the sole payload representation'); $assert(1 === preg_match('//s', $markup, $matches, PREG_OFFSET_CAPTURE)) return null; $depth = 0; $candidate = null; @@ -319,7 +349,7 @@ private function withoutTopLevelShell(string $markup, string $area): ?string if (!$selfClosing) ++$depth; } if (!is_array($candidate) || !is_int($candidate['end'])) return null; - return substr($markup, 0, $candidate['start']) . substr($markup, $candidate['end']); + return substr($markup, 0, $candidate['start']) . $replacement . substr($markup, $candidate['end']); } /** @param mixed $assets @return array> */ @@ -616,16 +646,14 @@ private static function relativePath(string $origin, string $target): string return str_repeat('../', count($from)) . implode('/', $to); } /** - * Rewrite route links inside every entity binding's search markup so the - * binding anchors on the same canonical block markup the plan emits for its - * source page. Only single-directory route links change; other markup is - * byte-preserved so binding hashes and occurrence counts stay stable. + * Canonicalize every entity binding's search markup through the same asset + * and route projections used for its source page. * * @param array> $declarations * @param array> $routes * @return array> */ - private function routeLinkedEntityBindings(array $declarations, array $routes): array + private function canonicalEntityBindings(array $declarations, AssetReferenceCanonicalizer $references, array $routes): array { foreach ( $declarations as &$declaration ) { if ( ! is_array($declaration) || ! isset($declaration['payload']['entities']) || ! is_array($declaration['payload']['entities']) ) { @@ -637,7 +665,8 @@ private function routeLinkedEntityBindings(array $declarations, array $routes): } foreach ( $entity['bindings'] as &$binding ) { if ( is_array($binding) && is_string($binding['search_block_markup'] ?? null) && is_string($binding['source_path'] ?? null) ) { - $binding['search_block_markup'] = $this->routeLinks($binding['search_block_markup'], $binding['source_path'], $routes); + $markup = $references->content($binding['search_block_markup'], $binding['source_path']); + $binding['search_block_markup'] = $this->routeLinks($markup, $binding['source_path'], $routes); } } unset($binding); diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index ea7bf0b4..769ae013 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -99,9 +99,19 @@ $duplicateForms = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
')))->toArray()['source_reports']['wordpress_site_plan']; $duplicateFormDeclaration = current(array_filter($duplicateForms['runtime_declarations'], static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); $assert(array(1, 2) === array_map(static fn(array $entity): int => (int) ($entity['bindings'][0]['occurrence'] ?? 0), $duplicateFormDeclaration['payload']['entities'] ?? array()), 'Identical page bindings use deterministic occurrence indexes instead of ambiguous cardinality claims.'); -$sharedFormArtifact = array('entrypoint' => 'index.html', 'files' => array('index.html' => '

Home

', 'about.html' => '

About

')); +$assetFormPlan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'assets/form.svg' => '')))->toArray()['source_reports']['wordpress_site_plan'] ?? array(); +$assetFormDeclaration = current(array_filter($assetFormPlan['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); +$assetFormBinding = $assetFormDeclaration['payload']['entities'][0]['bindings'][0] ?? array(); +$assert(str_contains((string) ($assetFormBinding['search_block_markup'] ?? ''), WordPressSitePlan::TOKEN_PREFIX) && 1 === substr_count((string) ($assetFormPlan['pages'][0]['canonical_block_markup'] ?? ''), (string) ($assetFormBinding['search_block_markup'] ?? '')), 'Entity binding anchors use the same canonical asset references and routes as page markup.'); +$sharedFormArtifact = array('entrypoint' => 'index.html', 'files' => array('index.html' => '

Home

', 'about.html' => '

About

')); $sharedFormResult = (new ArtifactCompiler())->compile($sharedFormArtifact)->toArray(); -$assert(isset($sharedFormResult['source_reports']['wordpress_site_plan_diagnostics']) && !isset($sharedFormResult['source_reports']['wordpress_site_plan']), 'Bindings extracted into shared shells fail closed instead of pointing at missing page anchors.'); +$sharedFormPlan = $sharedFormResult['source_reports']['wordpress_site_plan'] ?? array(); +$sharedFormParts = array_column($sharedFormPlan['template_parts'] ?? array(), 'area'); +$sharedFormPages = array_column($sharedFormPlan['pages'] ?? array(), 'canonical_block_markup', 'source_path'); +$sharedFormDeclaration = current(array_filter($sharedFormPlan['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); +$assert(array() !== $sharedFormPlan && in_array('header', $sharedFormParts, true) && !in_array('footer', $sharedFormParts, true), 'An unbound shared header extracts while a shared footer containing runtime binding anchors remains page-owned.'); +$assert(in_array('wordpress_site_plan_shell_retained_runtime_binding', array_column($sharedFormPlan['diagnostics'], 'code'), true), 'Binding-aware shell retention emits a bounded reason-coded diagnostic.'); +foreach ($sharedFormDeclaration['payload']['entities'] ?? array() as $entity) foreach ($entity['bindings'] ?? array() as $binding) $assert(($binding['occurrence'] ?? 0) <= substr_count($sharedFormPages[$binding['source_path']] ?? '', $binding['search_block_markup'] ?? ''), 'Every retained shared-shell binding keeps its declared source-page anchor.'); $unsafeFormScript = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '

Home

', 'contact.html' => '
')))->toArray()['source_reports']['wordpress_site_plan']; $unsafeFormDeclarations = array(); foreach ($unsafeFormScript['runtime_declarations'] as $declaration) $unsafeFormDeclarations[$declaration['kind'] . ':' . ($declaration['type'] ?? $declaration['capability'])] = $declaration; $unsafeSupersededScripts = array_merge(...array_map(static fn(array $entity): array => $entity['superseded_scripts'] ?? array(), $unsafeFormDeclarations['entity_collection:forms']['payload']['entities'])); From c2f3f8d425de2452525955e436b03a09a1f7ebe4 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 23 Jul 2026 15:19:32 -0400 Subject: [PATCH 5/7] Compose provider forms around control slots --- .../src/HtmlToBlocks/HtmlTransformer.php | 65 ++++++++++++++++++- .../Support/FormDispatchTrait.php | 6 ++ .../tests/contract/wordpress-site-plan.php | 9 ++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index e28e0f80..56c9a854 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -225,6 +225,9 @@ final class HtmlTransformer /** @var array */ private array $blockBindingOccurrences = array(); + /** @var array */ + private array $formControlSlotPaths = array(); + /** * @var array> */ @@ -431,6 +434,7 @@ public function transform(string $html, array $options = array()): TransformerRe $this->droppedLinkWrapperFindings = array(); $this->sourceProvenance = array(); $this->blockBindingOccurrences = array(); + $this->formControlSlotPaths = array(); $this->structureProvenance = array(); $this->scriptMetadata = array(); $this->runtimeIslands = array(); @@ -1656,6 +1660,10 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca { $tagName = strtolower($element->tagName); + if ( isset($this->formControlSlotPaths[$element->getNodePath()]) ) { + return $this->htmlPreservationBlock($element); + } + if ( $this->isRedundantMenuToggleControl($element) ) { return null; } @@ -6472,6 +6480,58 @@ private function readableFormBlockFromForm(DOMElement $form, bool $allowFormEven return $this->createBlock('core/group', $this->presentationAttributes($form), $contentBlocks, $form); } + /** + * Preserve one unambiguous controls-only subtree as the provider binding + * slot while converting the form's surrounding visual content normally. + * + * @param array> $fallbacks + * @return array{block:array,slot:array}|null + */ + private function compositionalFormBlock(DOMElement $form, array &$fallbacks): ?array + { + $slot = $this->formControlSlotElement($form); + if ( null === $slot ) return null; + + $path = $slot->getNodePath(); + $this->formControlSlotPaths[$path] = true; + try { + $children = $this->convertChildren($form, $fallbacks, true); + } finally { + unset($this->formControlSlotPaths[$path]); + } + if ( array() === $children ) return null; + + return array( + 'block' => $this->createBlock('core/group', $this->presentationAttributes($form), $children, $form), + 'slot' => $this->htmlPreservationBlock($slot), + ); + } + + private function formControlSlotElement(DOMElement $form): ?DOMElement + { + $controls = $this->formControlElements($form); + if ( array() === $controls ) return null; + + $formPath = $form->getNodePath(); + for ( $candidate = $controls[0]->parentNode; $candidate instanceof DOMElement && $candidate->getNodePath() !== $formPath; $candidate = $candidate->parentNode ) { + if ( array_filter($controls, fn(DOMElement $control): bool => !$this->elementContains($candidate, $control)) ) continue; + foreach ( $candidate->childNodes as $child ) { + if ( XML_TEXT_NODE === $child->nodeType && '' !== trim($child->textContent ?? '') ) continue 2; + if ( !$child instanceof DOMElement ) continue; + if ( !array_filter($controls, fn(DOMElement $control): bool => $this->elementContains($child, $control)) ) continue 2; + } + return $candidate; + } + return null; + } + + private function elementContains(DOMElement $ancestor, DOMElement $element): bool + { + $ancestorPath = $ancestor->getNodePath(); + for ( $node = $element; $node instanceof DOMElement; $node = $node->parentNode ) if ( $node->getNodePath() === $ancestorPath ) return true; + return false; + } + /** * @return array|null */ @@ -6735,8 +6795,11 @@ private function formFallbackFinding(DOMElement $element, ?array $readableFormBl { $controls = $this->formControls($element); $boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element)); + $replacesRuntimeIsland = null !== $bindingBlock; $bindingBlock ??= $readableFormBlock; $bindingMarkup = null !== $bindingBlock ? $this->runtime->serializeBlocks(array($bindingBlock)) : ''; + $supersededRuntimeSelectors = $this->runtimeDomSelectorsForElement($element); + if ( $replacesRuntimeIsland ) $supersededRuntimeSelectors[] = $this->runtimeIslandSelector($element); return FallbackDiagnostic::build(array( 'type' => 'html', @@ -6753,7 +6816,7 @@ private function formFallbackFinding(DOMElement $element, ?array $readableFormBl 'classification' => $this->fallbackEmitter->classifyFallbackSubtree($element), 'events' => $this->eventMetadata($element), 'readable_blocks' => null !== $readableFormBlock ? array( $readableFormBlock ) : array(), - 'binding' => $this->blockBinding($bindingMarkup, 'form', $this->runtimeDomSelectorsForElement($element)), + 'binding' => $this->blockBinding($bindingMarkup, 'form', $supersededRuntimeSelectors), 'controls' => $controls, 'control_count' => count($controls), 'text_length' => strlen(trim($element->textContent ?? '')), diff --git a/php-transformer/src/HtmlToBlocks/Support/FormDispatchTrait.php b/php-transformer/src/HtmlToBlocks/Support/FormDispatchTrait.php index c21562ed..e0853779 100644 --- a/php-transformer/src/HtmlToBlocks/Support/FormDispatchTrait.php +++ b/php-transformer/src/HtmlToBlocks/Support/FormDispatchTrait.php @@ -28,6 +28,12 @@ private function convertFormDispatchElement(DOMElement $element, array &$fallbac } if ( $this->formHasDataEntryControls($element) ) { + $composition = $this->compositionalFormBlock($element, $fallbacks); + if ( null !== $composition ) { + $fallbacks[] = $this->formFallbackFinding($element, $composition['block'], $composition['slot']); + $this->recordFormRuntimeIsland($element, $composition['block']); + return $composition['block']; + } $preservationBlock = $this->htmlPreservationBlock($element); $fallbacks[] = $this->formFallbackFinding($element, $readableFormBlock, $preservationBlock); $this->recordFormRuntimeIsland($element, $readableFormBlock); diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index 769ae013..05c5d241 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -99,10 +99,17 @@ $duplicateForms = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
')))->toArray()['source_reports']['wordpress_site_plan']; $duplicateFormDeclaration = current(array_filter($duplicateForms['runtime_declarations'], static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); $assert(array(1, 2) === array_map(static fn(array $entity): int => (int) ($entity['bindings'][0]['occurrence'] ?? 0), $duplicateFormDeclaration['payload']['entities'] ?? array()), 'Identical page bindings use deterministic occurrence indexes instead of ambiguous cardinality claims.'); -$assetFormPlan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'assets/form.svg' => '')))->toArray()['source_reports']['wordpress_site_plan'] ?? array(); +$assetFormPlan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'assets/form.svg' => '')))->toArray()['source_reports']['wordpress_site_plan'] ?? array(); $assetFormDeclaration = current(array_filter($assetFormPlan['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); $assetFormBinding = $assetFormDeclaration['payload']['entities'][0]['bindings'][0] ?? array(); $assert(str_contains((string) ($assetFormBinding['search_block_markup'] ?? ''), WordPressSitePlan::TOKEN_PREFIX) && 1 === substr_count((string) ($assetFormPlan['pages'][0]['canonical_block_markup'] ?? ''), (string) ($assetFormBinding['search_block_markup'] ?? '')), 'Entity binding anchors use the same canonical asset references and routes as page markup.'); +$assert(in_array('.provider-form', $assetFormBinding['superseded_runtime_selectors'] ?? array(), true), 'A provider binding that replaces a preserved form claims its matching runtime-island selector.'); +$composedFormPlan = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
', 'assets/form.svg' => '')))->toArray()['source_reports']['wordpress_site_plan'] ?? array(); +$composedFormDeclaration = current(array_filter($composedFormPlan['runtime_declarations'] ?? array(), static fn(array $declaration): bool => 'forms' === ($declaration['type'] ?? null))); +$composedFormBinding = $composedFormDeclaration['payload']['entities'][0]['bindings'][0] ?? array(); +$composedFormMarkup = (string) ($composedFormPlan['pages'][0]['canonical_block_markup'] ?? ''); +$assert(str_contains($composedFormMarkup, 'Updates') && str_contains($composedFormMarkup, 'Join the list.') && str_contains($composedFormMarkup, WordPressSitePlan::TOKEN_PREFIX), 'A form with one controls-only subtree preserves its surrounding visual content as native blocks.'); +$assert(str_contains((string) ($composedFormBinding['search_block_markup'] ?? ''), 'class="controls"') && !str_contains((string) ($composedFormBinding['search_block_markup'] ?? ''), 'Updates') && 1 === substr_count($composedFormMarkup, (string) ($composedFormBinding['search_block_markup'] ?? '')), 'A compositional form binding replaces only its exact controls slot.'); $sharedFormArtifact = array('entrypoint' => 'index.html', 'files' => array('index.html' => '

Home

', 'about.html' => '

About

')); $sharedFormResult = (new ArtifactCompiler())->compile($sharedFormArtifact)->toArray(); $sharedFormPlan = $sharedFormResult['source_reports']['wordpress_site_plan'] ?? array(); From 076b29bb27692edce7fcd88c41678a929c71845e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 23 Jul 2026 15:31:52 -0400 Subject: [PATCH 6/7] Preserve background image box geometry --- php-transformer/src/HtmlToBlocks/BlockFactory.php | 6 +++++- php-transformer/src/HtmlToBlocks/HtmlTransformer.php | 8 ++++++++ .../parity/html-layered-background-top-image.json | 3 ++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index bbba8851..0d57dd23 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -487,11 +487,15 @@ private function galleryClasses(array $attrs): string */ private function imageDimensionStyle(array $attrs): string { - if ( ! array_key_exists('width', $attrs) && ! array_key_exists('height', $attrs) ) { + if ( ! array_key_exists('width', $attrs) && ! array_key_exists('height', $attrs) && ! array_key_exists('scale', $attrs) ) { return ''; } $style = array(); + if ( array_key_exists('scale', $attrs) && null !== $attrs['scale'] ) { + $style[] = 'object-fit:' . (string) $attrs['scale']; + } + if ( array_key_exists('width', $attrs) && null !== $attrs['width'] ) { $style[] = 'width:' . (string) $attrs['width']; } diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 56c9a854..c2cbefdd 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -7435,15 +7435,23 @@ private function selectOptions(DOMElement $select): array */ private function backgroundImageBlockFromElement(DOMElement $element): ?array { + $declarations = $this->presentationDeclarations($element); $url = $this->backgroundImageExtractor->urlFromStyle($this->mergedPresentationStyle($element)); if ( '' === $url ) { return null; } + $width = trim((string) ($declarations['width'] ?? '')); + $height = trim((string) ($declarations['height'] ?? '')); + $scale = strtolower(trim((string) ($declarations['background-size'] ?? ''))); + return $this->createBlock('core/image', array_filter(array( 'url' => $this->resolvedAssetImageUrl($url), 'alt' => $this->backgroundImageExtractor->altFromAttributes($this->htmlAttributes($element)), 'className' => 'blocks-engine-background-image', + 'width' => ! in_array(strtolower($width), array( '', 'auto' ), true) ? $width : '', + 'height' => ! in_array(strtolower($height), array( '', 'auto' ), true) ? $height : '', + 'scale' => in_array($scale, array( 'cover', 'contain' ), true) ? $scale : '', ), static fn (string $value): bool => '' !== $value), array(), $element); } diff --git a/php-transformer/tests/fixtures/parity/html-layered-background-top-image.json b/php-transformer/tests/fixtures/parity/html-layered-background-top-image.json index cc661644..bbe01d28 100644 --- a/php-transformer/tests/fixtures/parity/html-layered-background-top-image.json +++ b/php-transformer/tests/fixtures/parity/html-layered-background-top-image.json @@ -17,7 +17,7 @@ }, "expected_blocks": [ { "path": "blocks.0", "name": "core/group" }, - { "path": "blocks.0.innerBlocks.0", "name": "core/image", "attrs": { "url": "assets/top.png", "alt": "Featured story", "className": "blocks-engine-background-image" } }, + { "path": "blocks.0.innerBlocks.0", "name": "core/image", "attrs": { "url": "assets/top.png", "alt": "Featured story", "className": "blocks-engine-background-image", "width": "691px", "height": "345.5px", "scale": "cover" } }, { "path": "blocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "content": "Featured story", "level": 1 } } ], "expected_fallbacks": [], @@ -25,6 +25,7 @@ { "path": "status", "assert": "equals", "value": "success" }, { "path": "serialized_blocks", "assert": "contains", "value": "hero layered-image" }, { "path": "serialized_blocks", "assert": "contains", "value": "src=\"assets/top.png\"" }, + { "path": "serialized_blocks", "assert": "contains", "value": "style=\"object-fit:cover;width:691px;height:345.5px\"" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "src=\"assets/bottom.png\"" }, { "path": "fallbacks", "assert": "count", "count": 0 } ] From 3cbc5bfcdd2593e8e2cdf92c1843fe7a6c6b9a3e Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 23 Jul 2026 22:14:48 -0400 Subject: [PATCH 7/7] Aggregate Figma text coverage evidence --- figma-transformer/src/FigmaTransformer.php | 14 +++++++++++++- figma-transformer/src/Html/StaticHtmlEmitter.php | 11 ++++++++++- .../tests/contract/DiagnosticsEvidenceContract.php | 2 ++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/figma-transformer/src/FigmaTransformer.php b/figma-transformer/src/FigmaTransformer.php index 8be2085d..8406a661 100644 --- a/figma-transformer/src/FigmaTransformer.php +++ b/figma-transformer/src/FigmaTransformer.php @@ -1551,6 +1551,10 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR 'invalid_numeric_token_count' => 0, 'invalid_numeric_tokens' => array(), ); + $textCoverage = array( + 'empty_decoded_text_nodes' => 0, + 'missing_emitted_text_nodes' => 0, + ); $htmlArtifact = array( 'schema' => 'blocks-engine/figma-transformer/html-artifact-diagnostics/v1', 'media_query_count' => 0, @@ -1629,6 +1633,8 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR $pageCss = is_array($diagnostics['css'] ?? null) ? $diagnostics['css'] : array(); DiagnosticAggregation::addIntegerCounts($css, $pageCss, array('invalid_numeric_token_count')); DiagnosticAggregation::appendContextSamples($css, 'invalid_numeric_tokens', $pageCss, 'invalid_numeric_tokens', $pageContext); + $pageQualitySummary = is_array($diagnostics['artifact_quality']['summary'] ?? null) ? $diagnostics['artifact_quality']['summary'] : array(); + DiagnosticAggregation::addIntegerCounts($textCoverage, $pageQualitySummary, array('empty_decoded_text_nodes', 'missing_emitted_text_nodes')); $pageHtmlArtifact = is_array($diagnostics['html_artifact'] ?? null) ? $diagnostics['html_artifact'] : array(); DiagnosticAggregation::addIntegerCounts($htmlArtifact, $pageHtmlArtifact, array('media_query_count', 'fixed_width_over_desktop_count', 'fixed_width_declaration_count', 'fixed_width_with_responsive_override_count', 'fixed_width_without_responsive_override_count', 'giant_fixed_section_count', 'large_overflow_risk_count', 'fallback_prone_form_island_count', 'fallback_prone_svg_island_count', 'fallback_prone_input_island_count', 'invalid_list_child_count', 'missing_semantic_role_count')); DiagnosticAggregation::appendContextSamples($htmlArtifact, 'fixed_width_samples', $pageHtmlArtifact, 'fixed_width_samples', $pageContext); @@ -1777,6 +1783,12 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR ); $generatedSvgAssets = $this->generatedSvgAssetsFromReport($assetReport); + $artifactQuality = $this->artifactQualityDiagnostics($images, $vectors, $fonts, $assets, $generatedSvgAssets, $layout, $links, $css, $htmlArtifact); + $artifactQuality['summary'] = array_merge( + is_array($artifactQuality['summary'] ?? null) ? $artifactQuality['summary'] : array(), + $textCoverage + ); + return array( 'schema' => 'blocks-engine/figma-transformer/transform-diagnostics/v1', 'scope' => 'multi_page', @@ -1793,7 +1805,7 @@ private function mergePageTransformDiagnostics(array $pageReports, array $assetR 'links' => $links, 'css' => $css, 'html_artifact' => $htmlArtifact, - 'artifact_quality' => $this->artifactQualityDiagnostics($images, $vectors, $fonts, $assets, $generatedSvgAssets, $layout, $links, $css, $htmlArtifact), + 'artifact_quality' => $artifactQuality, 'diagnostic_codes' => $diagnosticCodes, ); } diff --git a/figma-transformer/src/Html/StaticHtmlEmitter.php b/figma-transformer/src/Html/StaticHtmlEmitter.php index f3fbca97..cf91103d 100644 --- a/figma-transformer/src/Html/StaticHtmlEmitter.php +++ b/figma-transformer/src/Html/StaticHtmlEmitter.php @@ -4716,6 +4716,9 @@ private function componentCloneOmissionReason(array $node, ?array $parentNode, ? if ( null !== $parentNode && $this->isComposedVectorChild($node, $parentNode) ) { return 'composed-into-parent'; } + if ( null !== $parentNode && $this->formControlComposesChild($parentNode, $node) ) { + return 'form-control-composed-into-parent'; + } if ( $this->isComponentSourceDuplicateNode($node) ) { return 'component_source_duplicate'; } @@ -4735,7 +4738,13 @@ private function componentCloneOmissionReason(array $node, ?array $parentNode, ? private function isIntentionalComponentCloneSuppression(string $reason): bool { - return in_array($reason, array('hidden', 'zero-area', 'mask-source', 'composed-into-parent', 'non-rendering-vector-layer', 'invisible-zero-area-scaffold', 'component_source_duplicate'), true); + return in_array($reason, array('hidden', 'zero-area', 'mask-source', 'composed-into-parent', 'form-control-composed-into-parent', 'non-rendering-vector-layer', 'invisible-zero-area-scaffold', 'component_source_duplicate'), true); + } + + private function formControlComposesChild(array $parent, array $child): bool + { + $parentIsControl = ($this->isInputLike($parent) || $this->isTextareaLike($parent)) && $this->hasFormControlAccessoryChildren($parent); + return $parentIsControl && ($this->isFormControlPlaceholderChild($child) || $this->isInputLike($child, $parent) || $this->isTextareaLike($child, $parent)); } /** diff --git a/figma-transformer/tests/contract/DiagnosticsEvidenceContract.php b/figma-transformer/tests/contract/DiagnosticsEvidenceContract.php index 61240a3f..326b1158 100644 --- a/figma-transformer/tests/contract/DiagnosticsEvidenceContract.php +++ b/figma-transformer/tests/contract/DiagnosticsEvidenceContract.php @@ -944,6 +944,8 @@ function blocks_engine_figma_transformer_run_diagnostics_evidence_contract(calla $assert('diag:aggregation-home-vector' === ($placeholderNodes[0]['node_id'] ?? null), 'diagnostics-evidence-multi-page-placeholder-home-node'); $assert('aggregation-about.html' === ($placeholderNodes[1]['page_path'] ?? null), 'diagnostics-evidence-multi-page-placeholder-about-context'); $assert(2 === ($multiPageDiagnostics['diagnostic_codes']['unsupported_vector_node_placeholder'] ?? null), 'diagnostics-evidence-multi-page-diagnostic-code-count'); + $assert(0 === ($multiPageDiagnostics['artifact_quality']['summary']['empty_decoded_text_nodes'] ?? null), 'diagnostics-evidence-multi-page-empty-text-count-aggregated'); + $assert(0 === ($multiPageDiagnostics['artifact_quality']['summary']['missing_emitted_text_nodes'] ?? null), 'diagnostics-evidence-multi-page-missing-text-count-aggregated'); blocks_engine_figma_transformer_contract_assert_diagnostic_value($assert, $multiPageDiagnostics, array('decision_traces', 'schema'), 'blocks-engine/figma-transformer/decision-traces/v1', 'diagnostics-evidence-multi-page-decision-traces-schema'); $assert(2 <= ($multiPageDiagnostics['decision_traces']['domain_counts']['positioning_context'] ?? 0), 'diagnostics-evidence-multi-page-decision-traces-positioning-aggregated'); blocks_engine_figma_transformer_contract_assert_diagnostic_value($assert, $multiPageDiagnostics, array('layout', 'positional_parity', 'schema'), 'blocks-engine/figma-transformer/positional-parity/v1', 'diagnostics-evidence-multi-page-positional-parity-schema');