} } // For PHP versions that don't support HEIC images, extract the size info using Imagick when available. if ( wp_is_heic_image_mime_type( $image_mime_type ) ) { $editor = wp_get_image_editor( $filename ); if ( is_wp_error( $editor ) ) { return false; } // If the editor for HEICs is Imagick, use it to get the image size. if ( $editor instanceof WP_Image_Editor_Imagick ) { $size = $editor->get_size(); return array( $size['width'], $size['height'], IMAGETYPE_HEIF, sprintf( 'width="%d" height="%d"', $size['width'], $size['height'] ), 'mime' => 'image/heic', ); } } // The image could not be parsed. return false; } /** * Extracts meta information about an AVIF file: width, height, bit depth, and number of channels. * * @since 6.5.0 * * @param string $filename Path to an AVIF file. * @return array { * An array of AVIF image information. * * @type int|false $width Image width on success, false on failure. * @type int|false $height Image height on success, false on failure. * @type int|false $bit_depth Image bit depth on success, false on failure. * @type int|false $num_channels Image number of channels on success, false on failure. * } */ function wp_get_avif_info( $filename ) { $results = array( 'width' => false, 'height' => false, 'bit_depth' => false, 'num_channels' => false, ); if ( 'image/avif' !== wp_get_image_mime( $filename ) ) { return $results; } // Parse the file using libavifinfo's PHP implementation. require_once ABSPATH . WPINC . '/class-avif-info.php'; $handle = fopen( $filename, 'rb' ); if ( $handle ) { $parser = new Avifinfo\Parser( $handle ); $success = $parser->parse_ftyp() && $parser->parse_file(); fclose( $handle ); if ( $success ) { $results = $parser->features->primary_item_features; } } return $results; } /** * Extracts meta information about a WebP file: width, height, and type. * * @since 5.8.0 * * @param string $filename Path to a WebP file. * @return array { * An array of WebP image information. * * @type int|false $width Image width on success, false on failure. * @type int|false $height Image height on success, false on failure. * @type string|false $type The WebP type: one of 'lossy', 'lossless' or 'animated-alpha'. * False on failure. * } */ function wp_get_webp_info( $filename ) { $width = false; $height = false; $type = false; if ( 'image/webp' !== wp_get_image_mime( $filename ) ) { return compact( 'width', 'height', 'type' ); } $magic = file_get_contents( $filename, false, null, 0, 40 ); if ( false === $magic ) { return compact( 'width', 'height', 'type' ); } // Make sure we got enough bytes. if ( strlen( $magic ) < 40 ) { return compact( 'width', 'height', 'type' ); } /* * The headers are a little different for each of the three formats. * Header values based on WebP docs, see https://developers.google.com/speed/webp/docs/riff_container. */ switch ( substr( $magic, 12, 4 ) ) { // Lossy WebP. case 'VP8 ': $parts = unpack( 'v2', substr( $magic, 26, 4 ) ); $width = (int) ( $parts[1] & 0x3FFF ); $height = (int) ( $parts[2] & 0x3FFF ); $type = 'lossy'; break; // Lossless WebP. case 'VP8L': $parts = unpack( 'C4', substr( $magic, 21, 4 ) ); $width = (int) ( $parts[1] | ( ( $parts[2] & 0x3F ) << 8 ) ) + 1; $height = (int) ( ( ( $parts[2] & 0xC0 ) >> 6 ) | ( $parts[3] << 2 ) | ( ( $parts[4] & 0x03 ) << 10 ) ) + 1; $type = 'lossless'; break; // Animated/alpha WebP. case 'VP8X': // Pad 24-bit int. $width = unpack( 'V', substr( $magic, 24, 3 ) . "\x00" ); $width = (int) ( $width[1] & 0xFFFFFF ) + 1; // Pad 24-bit int. $height = unpack( 'V', substr( $magic, 27, 3 ) . "\x00" ); $height = (int) ( $height[1] & 0xFFFFFF ) + 1; $type = 'animated-alpha'; break; } return compact( 'width', 'height', 'type' ); } /** * Gets loading optimization attributes. * * This function returns an array of attributes that should be merged into the given attributes array to optimize * loading performance. Potential attributes returned by this function are: * - `loading` attribute with a value of "lazy" * - `fetchpriority` attribute with a value of "high" * - `decoding` attribute with a value of "async" * * If any of these attributes are already present in the given attributes, they will not be modified. Note that no * element should have both `loading="lazy"` and `fetchpriority="high"`, so the function will trigger a warning in case * both attributes are present with those values. * * @since 6.3.0 * @since 7.0.0 Support `fetchpriority=low` and `fetchpriority=auto` so that `loading=lazy` is not added and the media count is not increased. * * @global WP_Query $wp_query WordPress Query object. * * @param string $tag_name The tag name. * @param array $attr Array of the attributes for the tag. * @param string $context Context for the element for which the loading optimization attribute is requested. * @return array Loading optimization attributes. */ function wp_get_loading_optimization_attributes( $tag_name, $attr, $context ) { global $wp_query; /** * Filters whether to short-circuit loading optimization attributes. * * Returning an array from the filter will effectively short-circuit the loading of optimization attributes, * returning that value instead. * * @since 6.4.0 * * @param array|false $loading_attrs False by default, or array of loading optimization attributes to short-circuit. * @param string $tag_name The tag name. * @param array $attr Array of the attributes for the tag. * @param string $context Context for the element for which the loading optimization attribute is requested. */ $loading_attrs = apply_filters( 'pre_wp_get_loading_optimization_attributes', false, $tag_name, $attr, $context ); if ( is_array( $loading_attrs ) ) { return $loading_attrs; } $loading_attrs = array(); /* * Skip lazy-loading for the overall block template, as it is handled more granularly. * The skip is also applicable for `fetchpriority`. */ if ( 'template' === $context ) { /** This filter is documented in wp-includes/media.php */ return apply_filters( 'wp_get_loading_optimization_attributes', $loading_attrs, $tag_name, $attr, $context ); } // For now this function only supports images and iframes. if ( 'img' !== $tag_name && 'iframe' !== $tag_name ) { /** This filter is documented in wp-includes/media.php */ return apply_filters( 'wp_get_loading_optimization_attributes', $loading_attrs, $tag_name, $attr, $context ); } /* * Skip programmatically created images within content blobs as they need to be handled together with the other * images within the post content or widget content. * Without this clause, they would already be considered within their own context which skews the image count and * can result in the first post content image being lazy-loaded or an image further down the page being marked as a * high priority. */ if ( 'the_content' !== $context && doing_filter( 'the_content' ) || 'widget_text_content' !== $context && doing_filter( 'widget_text_content' ) || 'widget_block_content' !== $context && doing_filter( 'widget_block_content' ) ) { /** This filter is documented in wp-includes/media.php */ return apply_filters( 'wp_get_loading_optimization_attributes', $loading_attrs, $tag_name, $attr, $context ); } /* * Add `decoding` with a value of "async" for every image unless it has a * conflicting `decoding` attribute already present. */ if ( 'img' === $tag_name ) { $loading_attrs['decoding'] = $attr['decoding'] ?? 'async'; } // For any resources, width and height must be provided, to avoid layout shifts. if ( ! isset( $attr['width'], $attr['height'] ) ) { /** This filter is documented in wp-includes/media.php */ return apply_filters( 'wp_get_loading_optimization_attributes', $loading_attrs, $tag_name, $attr, $context ); } /* * The key function logic starts here. */ $maybe_in_viewport = null; $increase_count = false; $maybe_increase_count = false; // Logic to handle a `loading` attribute that is already provided. if ( isset( $attr['loading'] ) ) { /* * Interpret "lazy" as not in viewport. Any other value can be * interpreted as in viewport (realistically only "eager" or `false` * to force-omit the attribute are other potential values). */ if ( 'lazy' === $attr['loading'] ) { $maybe_in_viewport = false; } else { $maybe_in_viewport = true; } } // Logic to handle a `fetchpriority` attribute that is already provided. $existing_fetchpriority = ( $attr['fetchpriority'] ?? null ); $is_low_fetchpriority = ( 'low' === $existing_fetchpriority ); if ( 'high' === $existing_fetchpriority ) { /* * If the image was already determined to not be in the viewport (e.g. * from an already provided `loading` attribute), trigger a warning. * Otherwise, the value can be interpreted as in viewport, since only * the most important in-viewport image should have `fetchpriority` set * to "high". */ if ( false === $maybe_in_viewport ) { _doing_it_wrong( __FUNCTION__, __( 'An image should not be lazy-loaded and marked as high priority at the same time.' ), '6.3.0' ); /* * Set `fetchpriority` here for backward-compatibility as we should * not override what a developer decided, even though it seems * incorrect. */ $loading_attrs['fetchpriority'] = 'high'; } else { $maybe_in_viewport = true; } } elseif ( $is_low_fetchpriority ) { /* * An IMG with fetchpriority=low is not initially displayed; it may be hidden in the Navigation Overlay, * or it may be occluded in a non-initial carousel slide. Such images must not be lazy-loaded because the browser * has no heuristic to know when to start loading them before the user needs to see them. */ $maybe_in_viewport = false; // Preserve fetchpriority=low. $loading_attrs['fetchpriority'] = 'low'; } elseif ( 'auto' === $existing_fetchpriority ) { /* * When a block's visibility support identifies that the block is conditionally displayed based on the viewport * size, then it adds `fetchpriority=auto` to the block's IMG tags. These images must not be fetched with high * priority because they could be erroneously loaded in viewports which do not even display them. Contrarily, * they must not get `fetchpriority=low` because they may in fact be displayed in the current viewport. So as * a signal to indicate that an IMG may be in the viewport, `fetchpriority=auto` is added. This has the effect * here of preventing the media count from being increased, so that images hidden with block visibility do not * affect whether a following IMG gets `loading=lazy`. In particular, `loading=lazy` should still be omitted * on an IMG following any number of initial IMGs with `fetchpriority=auto` since those initial images may not * be displayed. */ // Preserve fetchpriority=auto. $loading_attrs['fetchpriority'] = 'auto'; } if ( null === $maybe_in_viewport ) { $header_enforced_contexts = array( 'template_part_' . WP_TEMPLATE_PART_AREA_HEADER => true, 'get_header_image_tag' => true, ); /** * Filters the header-specific contexts. * * @since 6.4.0 * * @param array $default_header_enforced_contexts Map of contexts for which elements should be considered * in the header of the page, as $context => $enabled * pairs. The $enabled should always be true. */ $header_enforced_contexts = apply_filters( 'wp_loading_optimization_force_header_contexts', $header_enforced_contexts ); // Consider elements with these header-specific contexts to be in viewport. if ( isset( $header_enforced_contexts[ $context ] ) ) { $maybe_in_viewport = true; $maybe_increase_count = true; } elseif ( ! is_admin() && in_the_loop() && is_main_query() ) { /* * Get the content media count, since this is a main query * content element. This is accomplished by "increasing" * the count by zero, as the only way to get the count is * to call this function. * The actual count increase happens further below, based * on the `$increase_count` flag set here. */ $content_media_count = wp_increase_content_media_count( 0 ); $increase_count = true; // If the count so far is below the threshold, `loading` attribute is omitted. if ( $content_media_count < wp_omit_loading_attr_threshold() ) { $maybe_in_viewport = true; } else { $maybe_in_viewport = false; } } elseif ( // Only apply for main query but before the loop. $wp_query->before_loop && $wp_query->is_main_query() /* * Any image before the loop, but after the header has started should not be lazy-loaded, * except when the footer has already started which can happen when the current template * does not include any loop. */ && did_action( 'get_header' ) && ! did_action( 'get_footer' ) ) { $maybe_in_viewport = true; $maybe_increase_count = true; } } /* * If the element is in the viewport (`true`), potentially add * `fetchpriority` with a value of "high". Otherwise, i.e. if the element * is not in the viewport (`false`) or it is unknown (`null`), add * `loading` with a value of "lazy" if the element is not already being * de-prioritized with `fetchpriority=low` due to occlusion in * Navigation Overlay, non-initial carousel slides, or a collapsed Details block. */ if ( $maybe_in_viewport ) { $loading_attrs = wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr ); } elseif ( ! $is_low_fetchpriority ) { // Only add `loading="lazy"` if the feature is enabled. if ( wp_lazy_loading_enabled( $tag_name, $context ) ) { $loading_attrs['loading'] = 'lazy'; } } /* * If flag was set based on contextual logic above, increase the content * media count, either unconditionally, or based on whether the image size * is larger than the threshold. This does not apply when the IMG has * fetchpriority=auto because it may be conditionally displayed by viewport * size. */ if ( 'auto' !== $existing_fetchpriority ) { if ( $increase_count ) { wp_increase_content_media_count(); } elseif ( $maybe_increase_count ) { /** This filter is documented in wp-includes/media.php */ $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 ); if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) { wp_increase_content_media_count(); } } } /** * Filters the loading optimization attributes. * * @since 6.4.0 * * @param array $loading_attrs The loading optimization attributes. * @param string $tag_name The tag name. * @param array $attr Array of the attributes for the tag. * @param string $context Context for the element for which the loading optimization attribute is requested. */ return apply_filters( 'wp_get_loading_optimization_attributes', $loading_attrs, $tag_name, $attr, $context ); } /** * Gets the threshold for how many of the first content media elements to not lazy-load. * * This function runs the {@see 'wp_omit_loading_attr_threshold'} filter, which uses a default threshold value of 3. * The filter is only run once per page load, unless the `$force` parameter is used. * * @since 5.9.0 * * @param bool $force Optional. If set to true, the filter will be (re-)applied even if it already has been before. * Default false. * @return int The number of content media elements to not lazy-load. */ function wp_omit_loading_attr_threshold( $force = false ) { static $omit_threshold; // This function may be called multiple times. Run the filter only once per page load. if ( ! isset( $omit_threshold ) || $force ) { /** * Filters the threshold for how many of the first content media elements to not lazy-load. * * For these first content media elements, the `loading` attribute will be omitted. By default, this is the case * for only the very first content media element. * * @since 5.9.0 * @since 6.3.0 The default threshold was changed from 1 to 3. * * @param int $omit_threshold The number of media elements where the `loading` attribute will not be added. Default 3. */ $omit_threshold = apply_filters( 'wp_omit_loading_attr_threshold', 3 ); } return $omit_threshold; } /** * Increases an internal content media count variable. * * @since 5.9.0 * @access private * * @param int $amount Optional. Amount to increase by. Default 1. * @return int The latest content media count, after the increase. */ function wp_increase_content_media_count( $amount = 1 ) { static $content_media_count = 0; $content_media_count += $amount; return $content_media_count; } /** * Determines whether to add `fetchpriority='high'` to loading attributes. * * @since 6.3.0 * @since 7.0.0 Support is added for IMG tags with `fetchpriority='low'` and `fetchpriority='auto'`. * @access private * * @param array $loading_attrs Array of the loading optimization attributes for the element. * @param string $tag_name The tag name. * @param array $attr Array of the attributes for the element. * @return array Updated loading optimization attributes for the element. */ function wp_maybe_add_fetchpriority_high_attr( $loading_attrs, $tag_name, $attr ) { // For now, adding `fetchpriority="high"` is only supported for images. if ( 'img' !== $tag_name ) { return $loading_attrs; } $existing_fetchpriority = $attr['fetchpriority'] ?? null; if ( null !== $existing_fetchpriority && 'auto' !== $existing_fetchpriority ) { /* * When an IMG has been explicitly marked with `fetchpriority=high`, then honor that this is the element that * should have the priority. In contrast, the Navigation block may add `fetchpriority=low` to an IMG which * appears in the Navigation Overlay; such images should never be considered candidates for * `fetchpriority=high`. Lastly, block visibility may add `fetchpriority=auto` to an IMG when the block is * conditionally displayed based on viewport size. Such an image is considered an LCP element candidate if it * exceeds the threshold for the minimum number of square pixels. */ if ( 'high' === $existing_fetchpriority ) { $loading_attrs['fetchpriority'] = 'high'; wp_high_priority_element_flag( false ); } return $loading_attrs; } // Lazy-loading and `fetchpriority="high"` are mutually exclusive. if ( isset( $loading_attrs['loading'] ) && 'lazy' === $loading_attrs['loading'] ) { return $loading_attrs; } if ( ! wp_high_priority_element_flag() ) { return $loading_attrs; } /** * Filters the minimum square-pixels threshold for an image to be eligible as the high-priority image. * * @since 6.3.0 * * @param int $threshold Minimum square-pixels threshold. Default 50000. */ $wp_min_priority_img_pixels = apply_filters( 'wp_min_priority_img_pixels', 50000 ); if ( $wp_min_priority_img_pixels <= $attr['width'] * $attr['height'] ) { if ( 'auto' !== $existing_fetchpriority ) { $loading_attrs['fetchpriority'] = 'high'; } wp_high_priority_element_flag( false ); } return $loading_attrs; } /** * Accesses a flag that indicates if an element is a possible candidate for `fetchpriority='high'`. * * @since 6.3.0 * @access private * * @param bool $value Optional. Used to change the static variable. Default null. * @return bool Returns true if the high-priority element was not already marked. */ function wp_high_priority_element_flag( $value = null ): bool { static $high_priority_element = true; if ( is_bool( $value ) ) { $high_priority_element = $value; } return $high_priority_element; } /** * Determines the output format for the image editor. * * @since 6.7.0 * @access private * * @param string $filename Path to the image. * @param string $mime_type The source image mime type. * @return array An array of mime type mappings. */ function wp_get_image_editor_output_format( $filename, $mime_type ) { $output_format = array( 'image/heic' => 'image/jpeg', 'image/heif' => 'image/jpeg', 'image/heic-sequence' => 'image/jpeg', 'image/heif-sequence' => 'image/jpeg', ); /** * Filters the image editor output format mapping. * * Enables filtering the mime type used to save images. By default HEIC/HEIF images * are converted to JPEGs. * * @see WP_Image_Editor::get_output_format() * * @since 5.8.0 * @since 6.7.0 The default was changed from an empty array to an array * containing the HEIC/HEIF images mime types. * * @param array $output_format An array of mime type mappings. Maps a source mime type to a new * destination mime type. By default maps HEIC/HEIF input to JPEG output. * @param string $filename Path to the image. * @param string $mime_type The source image mime type. */ return apply_filters( 'image_editor_output_format', $output_format, $filename, $mime_type ); } /** * Checks whether client-side media processing is enabled. * * Client-side media processing uses the browser's capabilities to handle * tasks like image resizing and compression before uploading to the server. * * @since 7.1.0 * * @return bool Whether client-side media processing is enabled. */ function wp_is_client_side_media_processing_enabled(): bool { // This is due to SharedArrayBuffer requiring a secure context. $host = strtolower( (string) strtok( $_SERVER['HTTP_HOST'] ?? '', ':' ) ); $enabled = ( is_ssl() || 'localhost' === $host || str_ends_with( $host, '.localhost' ) ); /** * Filters whether client-side media processing is enabled. * * @since 7.1.0 * * @param bool $enabled Whether client-side media processing is enabled. Default true if the page is served in a secure context. */ return (bool) apply_filters( 'wp_client_side_media_processing_enabled', $enabled ); } /** * Sets a global JS variable to indicate that client-side media processing is enabled. * * @since 7.1.0 */ function wp_set_client_side_media_processing_flag(): void { if ( ! wp_is_client_side_media_processing_enabled() ) { return; } wp_add_inline_script( 'wp-block-editor', 'window.__clientSideMediaProcessing = true;', 'before' ); $chromium_version = wp_get_chromium_major_version(); if ( null !== $chromium_version && $chromium_version >= 137 ) { wp_add_inline_script( 'wp-block-editor', 'window.__documentIsolationPolicy = true;', 'before' ); } } /** * Returns the major Chrome/Chromium version from the current request's User-Agent. * * Matches all Chromium-based browsers (Chrome, Edge, Opera, Brave). * * @since 7.1.0 * * @return int|null The major Chrome version, or null if not a Chromium browser. */ function wp_get_chromium_major_version(): ?int { if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) { return null; } if ( preg_match( '#Chrome/(\d+)#', $_SERVER['HTTP_USER_AGENT'], $matches ) ) { return (int) $matches[1]; } return null; } /** * Enables cross-origin isolation in the block editor. * * Required for enabling SharedArrayBuffer for WebAssembly-based * media processing in the editor. Uses Document-Isolation-Policy * on supported browsers (Chromium 137+). * * Skips setup when a third-party page builder overrides the block * editor via a custom `action` query parameter, as DIP would block * same-origin iframe access that these editors rely on. * * @since 7.1.0 */ function wp_set_up_cross_origin_isolation(): void { if ( ! wp_is_client_side_media_processing_enabled() ) { return; } $screen = get_current_screen(); if ( ! $screen ) { return; } if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) { return; } /* * Skip when rendering the classic-theme home route, which shows the site * preview in an iframe and must reach its `contentDocument` to neutralize * interactive elements. DIP would block that same-origin access. * * Keyed off $pagenow rather than the current screen so the guard keeps * working if the header set-up is ever moved to an earlier hook (such as * admin_init) where the screen is not yet available. */ global $pagenow; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( 'site-editor.php' === $pagenow && ! wp_is_block_theme() && ( ! isset( $_GET['p'] ) || '/' === $_GET['p'] ) ) { return; } /* * Skip when a third-party page builder overrides the block editor. * DIP isolates the document into its own agent cluster, * which blocks same-origin iframe access that these editors rely on. */ if ( isset( $_GET['action'] ) && 'edit' !== $_GET['action'] ) { return; } // Cross-origin isolation is not needed if users can't upload files anyway. if ( ! current_user_can( 'upload_files' ) ) { return; } wp_start_cross_origin_isolation_output_buffer(); } /** * Sends the Document-Isolation-Policy header for cross-origin isolation. * * Uses an output buffer to add crossorigin="anonymous" where needed. * * @since 7.1.0 */ function wp_start_cross_origin_isolation_output_buffer(): void { $chromium_version = wp_get_chromium_major_version(); if ( null === $chromium_version || $chromium_version < 137 ) { return; } ob_start( static function ( string $output ): string { header( 'Document-Isolation-Policy: isolate-and-credentialless' ); return wp_add_crossorigin_attributes( $output ); } ); } /** * Adds crossorigin="anonymous" to relevant tags in the given HTML string. * * @since 7.1.0 * * @param string $html HTML input. * @return string Modified HTML. */ function wp_add_crossorigin_attributes( string $html ): string { $site_url = site_url(); $processor = new WP_HTML_Tag_Processor( $html ); // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin. $cross_origin_tag_attributes = array( 'AUDIO' => array( 'src' ), 'LINK' => array( 'href' ), 'SCRIPT' => array( 'src' ), 'VIDEO' => array( 'src', 'poster' ), 'SOURCE' => array( 'src' ), ); while ( $processor->next_tag() ) { $tag = $processor->get_tag(); if ( ! isset( $cross_origin_tag_attributes[ $tag ] ) ) { continue; } $crossorigin = $processor->get_attribute( 'crossorigin' ); if ( null !== $crossorigin ) { continue; } if ( 'AUDIO' === $tag || 'VIDEO' === $tag ) { $processor->set_bookmark( 'audio-video-parent' ); } $processor->set_bookmark( 'resume' ); $sought = false; $is_cross_origin = false; foreach ( $cross_origin_tag_attributes[ $tag ] as $attr ) { $url = $processor->get_attribute( $attr ); if ( is_string( $url ) && ! str_starts_with( $url, $site_url ) && ! str_starts_with( $url, '/' ) ) { $is_cross_origin = true; } if ( $is_cross_origin ) { break; } } if ( $is_cross_origin ) { if ( 'SOURCE' === $tag ) { $sought = $processor->seek( 'audio-video-parent' ); if ( $sought ) { $processor->set_attribute( 'crossorigin', 'anonymous' ); } } else { $processor->set_attribute( 'crossorigin', 'anonymous' ); } if ( $sought ) { $processor->seek( 'resume' ); $processor->release_bookmark( 'audio-video-parent' ); } } } return $processor->get_updated_html(); }