PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
← All changes | includes/class-resource-hints-processor.php +400 -25 1.3.2 → 1.3.7 View file →
@@ -148,26 +148,37 @@
148 148 return $out;
149 149 }
150 150
151 151 /**
152 - * Strip core `loading="lazy"` and add `fetchpriority="high"` +
153 - * `decoding="async"` on every <img> whose tag matches one of the given
154 - * exclusion substrings. Mirrors what Lazy_Loader does for an excluded image
155 - * inside the_content, but page-wide so heroes outside it are covered too.
156 - * (FBS-83553 H2)
152 + * Strip core `loading="lazy"` and add `decoding="async"` on every <img>
153 + * whose tag matches one of the given exclusion substrings. Mirrors what
154 + * Lazy_Loader does for an excluded image inside the_content, but page-wide
155 + * so heroes outside it are covered too. (FBS-83553 H2)
157 156 *
157 + * Only the first visible match outside <footer>/<nav>/<aside> gets
158 + * `fetchpriority="high"`, and only when no image in the page holds it yet.
159 + * Every match used to get it, so a pattern naming the header and footer
160 + * logo, or a hero in a closed accordion, put several images at High. (#558)
161 + *
158 162 * @param string $html Full page HTML.
159 163 * @param string[] $exclusions Substring patterns identifying above-the-fold heroes.
160 164 */
161 165 private static function promote_excluded_images( string $html, array $exclusions ): string {
162 - return (string) preg_replace_callback(
163 - '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i',
164 - static function ( array $m ) use ( $exclusions ) {
165 - $tag = $m[0];
166 + $img_re = '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
167 + $claimed = (bool) preg_match( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*?(?<![-\w])fetchpriority\s*=\s*["\']?high\b#i', $html );
168 + $skip = $claimed ? array() : array_merge( self::chrome_container_ranges( $html ), Lazy_Loader::hidden_ranges( $html ) );
169 +
170 + $result = preg_replace_callback(
171 + $img_re,
172 + static function ( array $m ) use ( $exclusions, &$claimed, $skip ) {
173 + [ $tag, $offset ] = $m[0];
166 174 foreach ( $exclusions as $needle ) {
167 175 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
168 176 $tag = (string) preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag );
169 - $tag = self::set_fetchpriority( $tag );
177 + if ( ! $claimed && ! self::offset_in_ranges( (int) $offset, $skip ) && ! Lazy_Loader::tag_is_hidden( $tag, 'img' ) ) {
178 + $tag = self::set_fetchpriority( $tag );
179 + $claimed = true;
180 + }
170 181 if ( ! preg_match( '#\bdecoding=#i', $tag ) ) {
171 182 $tag = (string) preg_replace( '#<img\b#i', '<img decoding="async"', $tag, 1 );
172 183 }
173 184 return $tag;
@@ -174,10 +185,15 @@
174 185 }
175 186 }
176 187 return $tag;
177 188 },
178 - $html
189 + $html,
190 + -1,
191 + $count,
192 + PREG_OFFSET_CAPTURE
179 193 );
194 +
195 + return is_string( $result ) ? $result : $html;
180 196 }
181 197
182 198 /**
183 199 * Build preconnect <link>s for detected font hosts + user hosts.
@@ -256,22 +272,21 @@
256 272 // one that matters, and the feature reported success either way. The
257 273 // marker list and size gate were heuristics layered on top of the
258 274 // wrong primitive rather than replacing it. (#96)
259 275 $candidates = array();
260 - $skip_ranges = self::chrome_container_ranges( $html );
276 + // Markup hidden on arrival (a closed <details>, `hidden`, inline
277 + // display:none) cannot paint as LCP either. Preloading the hero of a
278 + // closed accordion spent the page's one High fetch on it. (#558)
279 + $skip_ranges = array_merge( self::chrome_container_ranges( $html ), Lazy_Loader::hidden_ranges( $html ) );
280 + // <img> indexes that can't be the LCP however they are marked: chrome,
281 + // hidden, or a logo/icon. Only these give up a stray `high`. (#558)
282 + $ruled_out = array();
261 283 if ( preg_match_all( '#<img\b[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
262 284 foreach ( $matches[0] as $index => $match ) {
263 285 [ $tag, $offset ] = $match;
264 286
265 287 // Skip anything the user excluded.
266 - $excluded = false;
267 - foreach ( $exclusions as $needle ) {
268 - if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
269 - $excluded = true;
270 - break;
271 - }
272 - }
273 - if ( $excluded ) {
288 + if ( self::matches_any( $tag, $exclusions ) ) {
274 289 continue;
275 290 }
276 291
277 292 // An image inside <footer>/<nav>/<aside> is site chrome by
@@ -278,9 +293,10 @@
278 293 // construction — a footer brand strip or FAQ illustration can
279 294 // never be the LCP element, whatever size it declares. On the
280 295 // FBS-84576 repro these decoys outranked the real hero three
281 296 // times on one layout.
282 - if ( self::offset_in_ranges( $offset, $skip_ranges ) ) {
297 + if ( self::offset_in_ranges( $offset, $skip_ranges ) || Lazy_Loader::tag_is_hidden( $tag, 'img' ) ) {
298 + $ruled_out[ $index ] = true;
283 299 continue;
284 300 }
285 301
286 302 // An image the theme explicitly lazy-loads is never the
@@ -302,8 +318,9 @@
302 318
303 319 // Chrome markers / explicit opt-out / obviously-tiny images
304 320 // never compete. (FBS-83553 H1 "logo before hero".)
305 321 if ( self::looks_too_small( $tag ) ) {
322 + $ruled_out[ $index ] = true;
306 323 continue;
307 324 }
308 325
309 326 $candidates[] = array(
@@ -312,8 +329,9 @@
312 329 'srcset' => $srcset,
313 330 'sizes' => $sizes,
314 331 'score' => self::weighted_score( self::lcp_score( $tag, $srcset ), $tag, $index ),
315 332 'order' => $index,
333 + 'offset' => $offset,
316 334 );
317 335 }
318 336 }
319 337
@@ -328,9 +346,47 @@
328 346 foreach ( self::background_candidates( $html, $exclusions, $skip_ranges ) as $bg ) {
329 347 $candidates[] = $bg;
330 348 }
331 349
350 + // PASS 1c — <video poster="…">. A full-screen hero video paints its
351 + // poster first, and that first frame IS the LCP; measured on a live
352 + // page, a preloaded poster cut the LCP load delay from 1.5 s to 21 ms.
353 + foreach ( self::video_poster_candidates( $html, $exclusions, $skip_ranges ) as $vp ) {
354 + $candidates[] = $vp;
355 + }
356 +
357 + // PASS 1d — background rules in inline <style> blocks. Page builders
358 + // put the hero's background-image in generated per-post CSS printed
359 + // inline (Elementor's `.elementor-N .elementor-element-X` rules), not
360 + // in a style attribute — so PASS 1b never saw the element that
361 + // actually paints as LCP on five of seven measured sites. External
362 + // stylesheets stay out for the same reasons as before (#247): fetching
363 + // CSS from an output-buffer pass costs more than the preload saves.
364 + foreach ( self::style_block_candidates( $html, $exclusions, $skip_ranges ) as $sb ) {
365 + $candidates[] = $sb;
366 + }
367 +
368 + // A background video with no poster, ahead of every candidate, is the
369 + // hero. Nothing in its box is preloadable, and every image after it
370 + // sits lower on the page. Preloading the first of those spent the one
371 + // high-priority fetch on an image below the fold, ahead of the CSS.
372 + // Those images also give up a stray `high` from the lazy pass.
373 + $video_at = self::background_video_offset( $html, $skip_ranges );
374 + if ( null !== $video_at ) {
375 + foreach ( $candidates as $i => $c ) {
376 + if ( $c['offset'] > $video_at ) {
377 + if ( empty( $c['background'] ) ) {
378 + $ruled_out[ $c['order'] ] = true;
379 + }
380 + unset( $candidates[ $i ] );
381 + }
382 + }
383 + }
384 +
332 385 if ( empty( $candidates ) ) {
386 + if ( null !== $video_at ) {
387 + $html = self::demote_images( $html, $ruled_out );
388 + }
333 389 return array( $html, '' );
334 390 }
335 391
336 392 // Rank by score, biggest first. Document order breaks ties, so two
@@ -374,14 +430,26 @@
374 430 // Rewrite only the winning tags. Counting occurrences rather than
375 431 // matching on tag text, because the same markup can legitimately
376 432 // appear more than once on a page and only the ranked instance should
377 433 // be promoted.
378 - $seen = -1;
379 - $html = preg_replace_callback(
434 + //
435 + // When an <img> wins, it is the page's one High image, so an image
436 + // that can't be the LCP (a logo, an icon, a hidden panel, chrome)
437 + // gives up any `high` it carries. That is usually the lazy pass handing
438 + // its slot to the first image in the_content. An image that merely scored
439 + // lower, or that the user kept out of the pick, keeps its hint: the
440 + // ranking can be wrong, and core or the theme may have named the real
441 + // hero. (#558)
442 + $demote = ! empty( $chosen ) || null !== $video_at ? $ruled_out : array();
443 + $seen = -1;
444 + $html = preg_replace_callback(
380 445 '#<img\b[^>]*>#i',
381 - static function ( array $m ) use ( &$seen, $chosen ) {
446 + static function ( array $m ) use ( &$seen, $chosen, $demote ) {
382 447 ++$seen;
383 448 if ( ! isset( $chosen[ $seen ] ) ) {
449 + if ( isset( $demote[ $seen ] ) && 'high' === strtolower( self::attr( $m[0], 'fetchpriority' ) ) ) {
450 + return (string) preg_replace( '#\s*(?<![-\w])fetchpriority\s*=\s*(["\']?)high\1#i', '', $m[0], 1 );
451 + }
384 452 return $m[0];
385 453 }
386 454 // Add fetchpriority="high" AND remove any loading="lazy" the
387 455 // theme / WP core left on the LCP image. fetchpriority="high"
@@ -396,8 +464,56 @@
396 464 return array( (string) $html, $preload );
397 465 }
398 466
399 467 /**
468 + * Byte offset of the first visible background video with no poster, or
469 + * null when the page has none.
470 + *
471 + * @param string $html Full page HTML.
472 + * @param array<int,array{0:int,1:int}> $skip_ranges Chrome and hidden spans.
473 + */
474 + private static function background_video_offset( string $html, array $skip_ranges ): ?int {
475 + $body = stripos( $html, '<body' );
476 + if ( ! preg_match_all( '#<video\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, false === $body ? 0 : $body ) ) {
477 + return null;
478 + }
479 + foreach ( $m[0] as [ $tag, $offset ] ) {
480 + if ( self::offset_in_ranges( $offset, $skip_ranges ) || Lazy_Loader::tag_is_hidden( $tag, 'video' ) ) {
481 + continue;
482 + }
483 + if ( Lazy_Loader::is_background_video_without_poster( $tag ) ) {
484 + return $offset;
485 + }
486 + }
487 + return null;
488 + }
489 +
490 + /**
491 + * Strip fetchpriority="high" from the <img> tags at the given indexes.
492 + *
493 + * @param string $html Page HTML.
494 + * @param array<int,bool> $indexes <img> indexes, in document order.
495 + */
496 + private static function demote_images( string $html, array $indexes ): string {
497 + if ( empty( $indexes ) ) {
498 + return $html;
499 + }
500 + $seen = -1;
501 + $out = preg_replace_callback(
502 + '#<img\b[^>]*>#i',
503 + static function ( array $m ) use ( &$seen, $indexes ) {
504 + ++$seen;
505 + if ( isset( $indexes[ $seen ] ) && 'high' === strtolower( self::attr( $m[0], 'fetchpriority' ) ) ) {
506 + return (string) preg_replace( '#\s*(?<![-\w])fetchpriority\s*=\s*(["\']?)high\1#i', '', $m[0], 1 );
507 + }
508 + return $m[0];
509 + },
510 + $html
511 + );
512 + return null === $out ? $html : $out;
513 + }
514 +
515 + /**
400 516 * Collect CSS `background-image` heroes as LCP candidates.
401 517 *
402 518 * Only INLINE `style` attributes are read. A background declared in an
403 519 * external stylesheet is invisible here by design: resolving it would mean
@@ -469,8 +585,9 @@
469 585 'score' => (float) $area,
470 586 // Offset so a background never ties ahead of an <img> that
471 587 // appeared earlier in the document; ties still break on order.
472 588 'order' => 100000 + $index,
589 + 'offset' => $offset,
473 590 'background' => true,
474 591 );
475 592 }
476 593
@@ -477,8 +594,249 @@
477 594 return $found;
478 595 }
479 596
480 597 /**
598 + * Collect `<video poster="…">` first frames as LCP candidates.
599 + *
600 + * The poster is what the viewer sees until (and unless) the video plays —
601 + * on a background-video hero, delayed by the Lazy module, it is the ONLY
602 + * frame the initial paint has. Scored like a background: the element's
603 + * declared inline-style area, or the unknown-size floor, with the order
604 + * offset past every <img> so a poster never ties ahead of one.
605 + *
606 + * @param string $html Full page HTML.
607 + * @param string[] $exclusions Substring patterns the user excluded.
608 + * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
609 + * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
610 + */
611 + private static function video_poster_candidates( string $html, array $exclusions, array $skip_ranges ): array {
612 + if ( ! preg_match_all( '#<video\b[^>]*\bposter\s*=\s*(["\'])(.*?)\1[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
613 + return array();
614 + }
615 +
616 + $found = array();
617 + foreach ( $matches[0] as $index => $match ) {
618 + [ $tag, $offset ] = $match;
619 + $src = trim( html_entity_decode( $matches[2][ $index ][0], ENT_QUOTES ) );
620 + if ( '' === $src || 0 === stripos( $src, 'data:' ) ) {
621 + continue;
622 + }
623 + if ( self::offset_in_ranges( $offset, $skip_ranges ) ) {
624 + continue;
625 + }
626 + foreach ( $exclusions as $needle ) {
627 + if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
628 + continue 2;
629 + }
630 + }
631 + if ( self::looks_too_small( $tag ) ) {
632 + continue;
633 + }
634 +
635 + $area = self::style_area( self::attr( $tag, 'style' ) );
636 + if ( 0 === $area ) {
637 + $area = self::UNKNOWN_SIZE_SCORE;
638 + }
639 +
640 + $found[] = array(
641 + 'tag' => $tag,
642 + 'src' => $src,
643 + 'srcset' => '',
644 + 'sizes' => '',
645 + 'score' => (float) $area,
646 + 'order' => 100000 + $index,
647 + 'offset' => $offset,
648 + 'background' => true,
649 + );
650 + }
651 +
652 + return $found;
653 + }
654 +
655 + /**
656 + * How many <style>-block background rules are considered per page. The
657 + * scan is linear, but each rule costs one class-lookup pass over the
658 + * body, so a pathological page (thousands of generated rules) is capped
659 + * rather than trusted.
660 + */
661 + private const STYLE_RULE_BUDGET = 40;
662 +
663 + /**
664 + * Collect background-image rules from inline <style> blocks whose
665 + * selector matches an element in the body.
666 + *
667 + * The match is deliberately narrow: the rule's RIGHTMOST simple selector
668 + * must carry a class or id, and the first element in the body bearing it
669 + * (outside chrome containers) is taken as the painted element. Rules
670 + * inside @media (or any other at-rule block) are skipped — a desktop-only
671 + * background preloaded on mobile is a wasted high-priority fetch, and the
672 + * markup gives no viewport to resolve the query against.
673 + *
674 + * @param string $html Full page HTML.
675 + * @param string[] $exclusions Substring patterns the user excluded.
676 + * @param array<int,array{0:int,1:int}> $skip_ranges Byte ranges of chrome containers.
677 + * @return array<int,array{tag:string,src:string,srcset:string,sizes:string,score:float,order:int,background:bool}>
678 + */
679 + private static function style_block_candidates( string $html, array $exclusions, array $skip_ranges ): array {
680 + if ( ! preg_match_all( '#<style\b[^>]*>(.*?)</style\s*>#is', $html, $blocks ) ) {
681 + return array();
682 + }
683 +
684 + $found = array();
685 + $budget = self::STYLE_RULE_BUDGET;
686 + foreach ( $blocks[1] as $css ) {
687 + if ( $budget <= 0 ) {
688 + break;
689 + }
690 + $css = self::strip_at_rule_blocks( $css );
691 + if ( false === stripos( $css, 'url(' ) ) {
692 + continue;
693 + }
694 + // One flat rule at a time: selector list up to '{', body to '}'.
695 + if ( ! preg_match_all( '#(?:^|})\s*([^{}]{1,512})\{([^{}]*)\}#s', $css, $rules, PREG_SET_ORDER ) ) {
696 + continue;
697 + }
698 + foreach ( $rules as $rule ) {
699 + if ( $budget <= 0 ) {
700 + break 2;
701 + }
702 + if ( false === stripos( $rule[2], 'url(' ) ) {
703 + continue;
704 + }
705 + $src = self::background_url( $rule[2] );
706 + if ( '' === $src ) {
707 + continue;
708 + }
709 + --$budget;
710 + // First selector of the list, rightmost compound of it.
711 + $selector = trim( (string) strtok( $rule[1], ',' ) );
712 + $parts = preg_split( '#[\s>+~]+#', $selector );
713 + $last = (string) end( $parts );
714 + // The last class or id token of that compound. Pseudo-classes
715 + // (:hover, ::before) mean the background is not the initial
716 + // paint, so they disqualify the rule.
717 + if ( false !== strpos( $last, ':' ) ) {
718 + continue;
719 + }
720 + if ( ! preg_match( '#([.\#])([-\w]+)$#', $last, $tok ) ) {
721 + continue;
722 + }
723 + $el = '.' === $tok[1]
724 + ? self::first_element_with_class( $html, $tok[2], $skip_ranges )
725 + : self::first_element_with_id( $html, $tok[2], $skip_ranges );
726 + if ( null === $el ) {
727 + continue;
728 + }
729 + [ $tag, $offset ] = $el;
730 + foreach ( $exclusions as $needle ) {
731 + if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
732 + continue 2;
733 + }
734 + }
735 + if ( self::looks_too_small( $tag ) ) {
736 + continue;
737 + }
738 + $area = self::style_area( self::attr( $tag, 'style' ) );
739 + if ( 0 === $area ) {
740 + $area = self::UNKNOWN_SIZE_SCORE;
741 + }
742 + $found[] = array(
743 + 'tag' => $tag,
744 + 'src' => $src,
745 + 'srcset' => '',
746 + 'sizes' => '',
747 + 'score' => (float) $area,
748 + // Offset past the inline-style backgrounds: a rule-matched
749 + // background is one inference step less certain, so it must
750 + // never tie ahead of one read straight off the element.
751 + 'order' => 200000 + $offset,
752 + 'offset' => $offset,
753 + 'background' => true,
754 + );
755 + }
756 + }
757 +
758 + return $found;
759 + }
760 +
761 + /**
762 + * CSS with every at-rule BLOCK (@media, @supports, @container, …) removed,
763 + * by brace depth — a regex cannot pair nested braces. Flat at-rules
764 + * (@import, @charset) have no block and pass through harmlessly.
765 + */
766 + private static function strip_at_rule_blocks( string $css ): string {
767 + $out = '';
768 + $len = strlen( $css );
769 + $i = 0;
770 + while ( $i < $len ) {
771 + $at = strpos( $css, '@', $i );
772 + if ( false === $at ) {
773 + return $out . substr( $css, $i );
774 + }
775 + $brace = strpos( $css, '{', $at );
776 + $semi = strpos( $css, ';', $at );
777 + $out .= substr( $css, $i, $at - $i );
778 + if ( false === $brace || ( false !== $semi && $semi < $brace ) ) {
779 + // Flat at-rule — skip to its semicolon (or end).
780 + $i = false === $semi ? $len : $semi + 1;
781 + continue;
782 + }
783 + // Block at-rule — skip to its matching close brace.
784 + $depth = 1;
785 + $i = $brace + 1;
786 + while ( $i < $len && $depth > 0 ) {
787 + $c = $css[ $i ];
788 + if ( '{' === $c ) {
789 + ++$depth;
790 + } elseif ( '}' === $c ) {
791 + --$depth;
792 + }
793 + ++$i;
794 + }
795 + }
796 + return $out;
797 + }
798 +
799 + /**
800 + * The first element in the BODY carrying $class (outside chrome ranges),
801 + * as [tag, offset], or null. Body-only, so a head <meta> can never match
802 + * and a hit's offset is comparable with the chrome ranges.
803 + *
804 + * @return array{0:string,1:int}|null
805 + */
806 + private static function first_element_with_class( string $html, string $class, array $skip_ranges ): ?array {
807 + $body = stripos( $html, '<body' );
808 + $from = false === $body ? 0 : $body;
809 + if ( ! preg_match_all( '#<[a-z][^>]*\bclass\s*=\s*(["\'])[^"\']*(?<![-\w])' . preg_quote( $class, '#' ) . '(?![-\w])[^"\']*\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) {
810 + return null;
811 + }
812 + foreach ( $m[0] as $match ) {
813 + if ( ! self::offset_in_ranges( $match[1], $skip_ranges ) ) {
814 + return array( $match[0], $match[1] );
815 + }
816 + }
817 + return null;
818 + }
819 +
820 + /**
821 + * The first element carrying id="$id" (outside chrome ranges), as
822 + * [tag, offset], or null.
823 + *
824 + * @return array{0:string,1:int}|null
825 + */
826 + private static function first_element_with_id( string $html, string $id, array $skip_ranges ): ?array {
827 + $body = stripos( $html, '<body' );
828 + $from = false === $body ? 0 : $body;
829 + if ( ! preg_match( '#<[a-z][^>]*\bid\s*=\s*(["\'])' . preg_quote( $id, '#' ) . '\1[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, $from ) ) {
830 + return null;
831 + }
832 + if ( self::offset_in_ranges( $m[0][1], $skip_ranges ) ) {
833 + return null;
834 + }
835 + return array( $m[0][0], $m[0][1] );
836 + }
837 +
838 + /**
481 839 * Pull a real image URL out of a `background`/`background-image` declaration.
482 840 *
483 841 * Returns '' for anything with nothing to fetch: a gradient (which is a
484 842 * background-image but not a resource), a data: URI, or `none`.
@@ -669,12 +1027,29 @@
669 1027 }
670 1028 return $ranges;
671 1029 }
672 1030
1031 + /**
1032 + * Does a tag contain any of the given substring patterns?
1033 + *
1034 + * @param string[] $patterns
1035 + */
1036 + private static function matches_any( string $tag, array $patterns ): bool {
1037 + foreach ( $patterns as $needle ) {
1038 + if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
1039 + return true;
1040 + }
1041 + }
1042 + return false;
1043 + }
1044 +
673 1045 /** Does a byte offset fall inside any of the given [start, end] ranges? */
674 1046 private static function offset_in_ranges( int $offset, array $ranges ): bool {
675 1047 foreach ( $ranges as $range ) {
676 - if ( $offset > $range[0] && $offset < $range[1] ) {
1048 + // >= on the start: a closed <details> span from
1049 + // Lazy_Loader::hidden_ranges() opens right at the first image
1050 + // after its <summary>.
1051 + if ( $offset >= $range[0] && $offset < $range[1] ) {
677 1052 return true;
678 1053 }
679 1054 }
680 1055 return false;