@@ -364,6 +364,9 @@ final class HtmlTransformer
364364 */
365365 private array $conditionalStyleRules = array();
366366
367+ /** @var list<array{selector: string, property: string, value: string, conditions: list<string>, order: int}> Ordered crop declarations, including duplicates. */
368+ private array $imageShapeStyleRules = array();
369+
367370 /**
368371 * @var array<int, array{selector: string, pseudo: string, declarations: array<string, string>}>
369372 */
@@ -634,6 +637,7 @@ public function transform(string $html, array $options = array()): TransformerRe
634637 $this->staticClassPromotions = $this->detectStaticClassPromotions($html);
635638 $this->staticStyleRules = $this->staticStyleRules($html, (string) ($options['static_css'] ?? ''));
636639 $this->conditionalStyleRules = $this->conditionalStyleRules($html, (string) ($options['static_css'] ?? ''));
640+ $this->imageShapeStyleRules = $this->imageShapeStyleRules($html, (string) ($options['static_css'] ?? ''));
637641 $this->staticPseudoElementStyleRules = $this->staticPseudoElementStyleRules($html, (string) ($options['static_css'] ?? ''));
638642 $this->cssCustomProperties = $this->cssCustomProperties($html, (string) ($options['static_css'] ?? ''));
639643 $this->resetPresentationResolutionCache();
@@ -11800,16 +11804,19 @@ private function imagePresentationAttributes(DOMElement $image, ?DOMElement $fig
1180011804 */
1180111805 private function imageShapeConstraintAttributes(DOMElement $image): array
1180211806 {
11803- $declarations = $this->structuralPresentationDeclarations($image);
11807+ $declarations = array(
11808+ 'aspect-ratio' => $this->imageShapeDeclaration($image, 'aspect-ratio'),
11809+ 'object-fit' => $this->imageShapeDeclaration($image, 'object-fit'),
11810+ );
1180411811 $aspectRatio = $this->normalizedAspectRatio(
11805- $this->desktopViewportDeclaration($image, 'aspect-ratio', ( string) ( $declarations['aspect-ratio'] ?? ''))
11812+ ( string) $declarations['aspect-ratio']
1180611813 );
1180711814 // `object-fit:cover !important` is a common defence against core's
1180811815 // `.wp-block-image img` rules. Strip importance symmetrically with
1180911816 // normalizedAspectRatio, or the keyword never matches the allowlist below
1181011817 // and the whole promotion silently declines.
1181111818 $scale = strtolower($this->cssValueWithoutImportant(
11812- $this->desktopViewportDeclaration($image, 'object-fit', ( string) ( $declarations['object-fit'] ?? ''))
11819+ ( string) $declarations['object-fit']
1181311820 ));
1181411821
1181511822 if ( '' === $aspectRatio || ! in_array($scale, array( 'cover', 'contain' ), true) ) {
@@ -11822,57 +11829,65 @@ private function imageShapeConstraintAttributes(DOMElement $image): array
1182211829 );
1182311830 }
1182411831
11825- /**
11826- * WordPress `core/image` carries a SINGLE aspectRatio/scale, but authored CSS
11827- * often makes these responsive: a base rule plus `@media (min-width: N)`
11828- * overrides. The block must reflect what renders at the primary/desktop
11829- * viewport, so among the base value and every matching min-width override we
11830- * pick the value from the widest min-width breakpoint (<= the desktop
11831- * reference width). max-width-bounded (mobile) media rules are ignored; the
11832- * base value wins only when no qualifying min-width override declares it.
11833- * Ties at one breakpoint go to the last rule, as the cascade decides them.
11834- */
11835- private function desktopViewportDeclaration(DOMElement $element, string $property, string $baseValue): string
11832+ /** Resolve one crop declaration at the desktop viewport using the CSS cascade. */
11833+ private function imageShapeDeclaration(DOMElement $element, string $property): string
1183611834 {
11837- $winningWidth = -1;
11838- $winningValue = $baseValue;
11839- // A declaration in the element's own `style` attribute outranks every
11840- // matched stylesheet rule at normal importance, whatever viewport that
11841- // rule is bound to. $baseValue already carries it.
11842- $inlineValue = (string) ($this->cssDeclarations($this->attr($element, 'style'))[$property] ?? '');
11843- $inlineOwns = '' !== trim($inlineValue);
11844- $inlineImportant = $inlineOwns && $this->cssValueIsImportant($inlineValue);
11845-
11846- foreach ( $this->conditionalStyleRules as $rule ) {
11847- if ( ! isset($rule['declarations'][$property]) || ! isset($rule['conditions']) ) {
11835+ $winner = null;
11836+ foreach ($this->imageShapeStyleRules as $rule) {
11837+ if ($property !== $rule['property'] || ! $this->matchesCssSelector($element, $rule['selector'])) {
1184811838 continue;
1184911839 }
11850- if ( $inlineOwns && ( $inlineImportant || ! $this->cssValueIsImportant((string) $rule['declarations'][$property]) ) ) {
11851- continue;
11840+ if (array() !== $rule['conditions']) {
11841+ $minWidth = $this->conditionsDesktopMinWidth($rule['conditions']);
11842+ if (null === $minWidth || $minWidth > self::DESKTOP_REFERENCE_WIDTH) {
11843+ continue;
11844+ }
1185211845 }
11853- if ( ! $this->matchesCssSelector($element, $rule['selector']) ) {
11854- continue;
11846+ $candidate = array(
11847+ 'value' => $rule['value'],
11848+ 'specificity' => $this->mediaTextSelectorSpecificity($rule['selector']),
11849+ 'order' => $rule['order'],
11850+ 'inline' => false,
11851+ );
11852+ if ($this->imageShapeDeclarationWins($candidate, $winner)) {
11853+ $winner = $candidate;
1185511854 }
11856-
11857- $minWidth = $this->conditionsDesktopMinWidth($rule['conditions']);
11858- if ( null === $minWidth || $minWidth > self::DESKTOP_REFERENCE_WIDTH ) {
11855+ }
11856+ $inlineEntries = $this->imageShapeDeclarationEntries($this->attr($element, 'style'));
11857+ foreach ($inlineEntries as $index => $entry) {
11858+ if ($property !== $entry['property']) {
1185911859 continue;
1186011860 }
11861- if ( $minWidth >= $winningWidth ) {
11862- $winningWidth = $minWidth;
11863- $winningValue = (string) $rule['declarations'][$property] ;
11861+ $candidate = array('value' => $entry['value'], 'specificity' => array(PHP_INT_MAX, PHP_INT_MAX, PHP_INT_MAX), 'order' => PHP_INT_MAX - count($inlineEntries) + $index, 'inline' => true);
11862+ if ($this->imageShapeDeclarationWins($candidate, $winner)) {
11863+ $winner = $candidate ;
1186411864 }
1186511865 }
1186611866
11867- return $winningValue;
11867+ return is_array($winner) ? $winner['value'] : '';
11868+ }
11869+
11870+ /** @param array{value:string,specificity:array{int,int,int},order:int,inline:bool} $candidate @param array{value:string,specificity:array{int,int,int},order:int,inline:bool}|null $current */
11871+ private function imageShapeDeclarationWins(array $candidate, ?array $current): bool
11872+ {
11873+ if (null === $current) {
11874+ return true;
11875+ }
11876+ $candidateImportant = $this->cssValueIsImportant($candidate['value']);
11877+ $currentImportant = $this->cssValueIsImportant($current['value']);
11878+ if ($candidateImportant !== $currentImportant) {
11879+ return $candidateImportant;
11880+ }
11881+ $specificity = $this->compareMediaTextSpecificity($candidate['specificity'], $current['specificity']);
11882+ return 0 < $specificity || (0 === $specificity && $candidate['order'] >= $current['order']);
1186811883 }
1186911884
1187011885 /**
1187111886 * The min-width (px) at which a conditional rule's `@media` prelude(s) begin
11872- * to apply, or null when the rule is not a pure min-width desktop override:
11873- * a non-`@media` condition, a negated query, a media type that is not the
11874- * rendered screen, any `max-width` bound (mobile-capped range), or a media
11875- * feature without a usable px min-width all disqualify it . Nested conditions
11887+ * to apply, or null when a condition cannot be positively evaluated at the
11888+ * desktop viewport. `@layer` is an unconditional grouping wrapper. A bounded
11889+ * set of modern crop-relevant `@supports` tests is accepted; unknown feature
11890+ * queries remain unresolved rather than being flattened . Nested conditions
1187611891 * must all qualify; the effective breakpoint is the widest.
1187711892 *
1187811893 * @param list<string> $conditions
@@ -11883,6 +11898,15 @@ private function conditionsDesktopMinWidth(array $conditions): ?int
1188311898
1188411899 foreach ( $conditions as $condition ) {
1188511900 $condition = trim($condition);
11901+ if ( 1 === preg_match('/^@layer\b/i', $condition) ) {
11902+ continue;
11903+ }
11904+ if ( 1 === preg_match('/^@supports\b/i', $condition) ) {
11905+ if ($this->supportsDesktopImageCropCondition($condition)) {
11906+ continue;
11907+ }
11908+ return null;
11909+ }
1188611910 if ( 1 !== preg_match('/^@media\b/i', $condition) ) {
1188711911 return null;
1188811912 }
@@ -11917,6 +11941,26 @@ private function conditionsDesktopMinWidth(array $conditions): ?int
1191711941 return $minWidth;
1191811942 }
1191911943
11944+ /** Bounded browser-capability facts used for crop rules under @supports. */
11945+ private function supportsDesktopImageCropCondition(string $condition): bool
11946+ {
11947+ $query = strtolower(trim(preg_replace('/^@supports\s*/i', '', $condition) ?? ''));
11948+ $query = preg_replace('/^\((.*)\)$/s', '$1', $query) ?? $query;
11949+ [$property, $value] = array_pad(array_map('trim', explode(':', $query, 2)), 2, '');
11950+
11951+ if ('display' === $property) {
11952+ return in_array($value, array('flex', 'grid', 'inline-flex', 'inline-grid'), true);
11953+ }
11954+ if ('aspect-ratio' === $property) {
11955+ return '' !== $this->normalizedAspectRatio($value);
11956+ }
11957+ if ('object-fit' === $property) {
11958+ return in_array($value, array('cover', 'contain'), true);
11959+ }
11960+
11961+ return false;
11962+ }
11963+
1192011964 private function cssValueWithoutImportant(string $value): string
1192111965 {
1192211966 return trim(preg_replace('/\s*!\s*important\s*$/i', '', $value) ?? $value);
0 commit comments