Plugin Directory

Changeset 3444709


Ignore:
Timestamp:
01/22/2026 10:44:36 AM (9 days ago)
Author:
ddegner
Message:

Update to version 0.5.21 from GitHub

Location:
avif-local-support
Files:
8 edited
1 copied

Legend:

Unmodified
Added
Removed
  • avif-local-support/tags/0.5.21/avif-local-support.php

    r3433195 r3444709  
    77 * Plugin URI: https://github.com/ddegner/avif-local-support
    88 * Description: High-quality AVIF image conversion for WordPress — local, quality-first.
    9  * Version: 0.5.20
     9 * Version: 0.5.21
    1010 * Author: ddegner
    1111 * Author URI: https://www.daviddegner.com
     
    2222
    2323// Define constants
    24 \define('AVIFLOSU_VERSION', '0.5.20');
     24\define('AVIFLOSU_VERSION', '0.5.21');
    2525\define('AVIFLOSU_PLUGIN_FILE', __FILE__);
    2626\define('AVIFLOSU_PLUGIN_DIR', plugin_dir_path(__FILE__));
  • avif-local-support/tags/0.5.21/includes/ThumbHash.php

    r3432335 r3444709  
    88
    99// Prevent direct access.
    10 \defined('ABSPATH') || exit;
     10\defined( 'ABSPATH' ) || exit;
    1111
    1212/**
     
    1616 * client-side to smooth placeholders while full images load.
    1717 */
    18 final class ThumbHash
    19 {
     18final class ThumbHash {
     19
    2020
    2121
     
    2727    /**
    2828     * Maximum dimension for thumbnail before hashing.
    29      * Fixed at 32px to match ThumbHash decoder output resolution.
    30      */
    31     private const MAX_DIMENSION = 32;
     29     * Set to 100px (ThumbHash maximum) to capture more detail in the DCT encoding.
     30     * The decoder outputs 32px, but larger input = more frequency data = richer placeholders.
     31     */
     32    private const MAX_DIMENSION = 100;
    3233
    3334    /**
     
    3536     * Fixed at 32px to match the decoder output.
    3637     */
    37     public static function getMaxDimension(): int
    38     {
     38    public static function getMaxDimension(): int {
    3939        return self::MAX_DIMENSION;
    4040    }
     
    4343     * Check if ThumbHash feature is enabled.
    4444     */
    45     public static function isEnabled(): bool
    46     {
    47         return (bool) \get_option('aviflosu_thumbhash_enabled', false);
     45    public static function isEnabled(): bool {
     46        return (bool) \get_option( 'aviflosu_thumbhash_enabled', false );
    4847    }
    4948
     
    5352     * @return bool True if the library class exists, false otherwise.
    5453     */
    55     public static function isLibraryAvailable(): bool
    56     {
    57         return class_exists('Thumbhash\Thumbhash');
     54    public static function isLibraryAvailable(): bool {
     55        return class_exists( 'Thumbhash\Thumbhash' );
    5856    }
    5957
     
    6462     * @return string|null Base64-encoded ThumbHash or null on failure.
    6563     */
    66     public static function generate(string $imagePath): ?string
    67     {
     64    public static function generate( string $imagePath ): ?string {
    6865        // Check if the ThumbHash library is available
    69         if (!self::isLibraryAvailable()) {
     66        if ( ! self::isLibraryAvailable() ) {
    7067            self::$lastError = 'ThumbHash library not found. Please run "composer install" in the plugin directory to install dependencies.';
    71             if (class_exists(Logger::class)) {
    72                 (new Logger())->addLog(
     68            if ( class_exists( Logger::class ) ) {
     69                ( new Logger() )->addLog(
    7370                    'error',
    7471                    'ThumbHash library not available',
    7572                    array(
    76                         'path' => $imagePath,
     73                        'path'  => $imagePath,
    7774                        'error' => 'Thumbhash\Thumbhash class not found. Composer dependencies may not be installed.',
    7875                    )
     
    8279        }
    8380
    84         if (!file_exists($imagePath) || !is_readable($imagePath)) {
     81        if ( ! file_exists( $imagePath ) || ! is_readable( $imagePath ) ) {
    8582            self::$lastError = "File not found or unreadable: $imagePath";
    86             if (class_exists(Logger::class)) {
    87                 (new Logger())->addLog('error', 'ThumbHash failed: File not found', array('path' => $imagePath));
     83            if ( class_exists( Logger::class ) ) {
     84                ( new Logger() )->addLog( 'error', 'ThumbHash failed: File not found', array( 'path' => $imagePath ) );
    8885            }
    8986            return null;
     
    9289        try {
    9390            // Try Imagick first (better alpha support)
    94             if (extension_loaded('imagick') && class_exists(\Imagick::class)) {
    95                 return self::generateWithImagick($imagePath);
     91            if ( extension_loaded( 'imagick' ) && class_exists( \Imagick::class ) ) {
     92                return self::generateWithImagick( $imagePath );
    9693            }
    9794
    9895            // Fall back to GD
    99             if (extension_loaded('gd')) {
    100                 return self::generateWithGd($imagePath);
     96            if ( extension_loaded( 'gd' ) ) {
     97                return self::generateWithGd( $imagePath );
    10198            }
    10299
    103100            self::$lastError = 'No supported image library (Imagick or GD) found.';
    104101            return null;
    105         } catch (\Throwable $e) {
     102        } catch ( \Throwable $e ) {
    106103            // Log error but don't block conversion
    107104            self::$lastError = $e->getMessage();
    108105
    109             if (defined('WP_DEBUG') && WP_DEBUG) {
     106            if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
    110107                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
    111                 error_log('ThumbHash generation failed for ' . $imagePath . ': ' . $e->getMessage());
    112             }
    113 
    114             if (class_exists(Logger::class)) {
    115                 (new Logger())->addLog(
     108                error_log( 'ThumbHash generation failed for ' . $imagePath . ': ' . $e->getMessage() );
     109            }
     110
     111            if ( class_exists( Logger::class ) ) {
     112                ( new Logger() )->addLog(
    116113                    'error',
    117114                    'ThumbHash generation exception',
    118115                    array(
    119                         'path' => $imagePath,
     116                        'path'  => $imagePath,
    120117                        'error' => $e->getMessage(),
    121118                    )
     
    135132     * Get the last error that occurred during generation.
    136133     */
    137     public static function getLastError(): ?string
    138     {
     134    public static function getLastError(): ?string {
    139135        return self::$lastError;
    140136    }
     
    143139     * Generate ThumbHash using Imagick.
    144140     */
    145     private static function generateWithImagick(string $imagePath): ?string
    146     {
     141    private static function generateWithImagick( string $imagePath ): ?string {
    147142        $imagick = new \Imagick();
    148143        // Optimization: Hint to libjpeg to load a smaller version (downscale) to save memory.
     
    150145        // sufficient source data for smooth downscaling while still significantly reducing memory for large JPEGs.
    151146        try {
    152             $imagick->setOption('jpeg:size', '200x200');
    153         } catch (\Throwable $e) {
     147            $imagick->setOption( 'jpeg:size', '200x200' );
     148        } catch ( \Throwable $e ) {
    154149            // Ignore if setOption fails (e.g. older ImageMagick versions)
    155150        }
    156         $imagick->readImage($imagePath);
     151        $imagick->readImage( $imagePath );
    157152
    158153        // Get original dimensions
    159         $width = $imagick->getImageWidth();
     154        $width  = $imagick->getImageWidth();
    160155        $height = $imagick->getImageHeight();
    161156
    162157        // Calculate thumbnail dimensions maintaining aspect ratio
    163         if ($width > self::getMaxDimension() || $height > self::getMaxDimension()) {
    164             if ($width >= $height) {
    165                 $newWidth = self::getMaxDimension();
    166                 $newHeight = (int) round($height * (self::getMaxDimension() / $width));
     158        if ( $width > self::getMaxDimension() || $height > self::getMaxDimension() ) {
     159            if ( $width >= $height ) {
     160                $newWidth  = self::getMaxDimension();
     161                $newHeight = (int) round( $height * ( self::getMaxDimension() / $width ) );
    167162            } else {
    168163                $newHeight = self::getMaxDimension();
    169                 $newWidth = (int) round($width * (self::getMaxDimension() / $height));
     164                $newWidth  = (int) round( $width * ( self::getMaxDimension() / $height ) );
    170165            }
    171166            // Ensure minimum of 1px
    172             $newWidth = max(1, $newWidth);
    173             $newHeight = max(1, $newHeight);
    174             $imagick->thumbnailImage($newWidth, $newHeight);
     167            $newWidth  = max( 1, $newWidth );
     168            $newHeight = max( 1, $newHeight );
     169            $imagick->thumbnailImage( $newWidth, $newHeight );
    175170        } else {
    176             $newWidth = $width;
     171            $newWidth  = $width;
     172            $newHeight = $height;
     173        }
     174
     175        // Extract RGBA pixels
     176        $pixels   = array();
     177        $iterator = $imagick->getPixelIterator();
     178
     179        foreach ( $iterator as $row ) {
     180            foreach ( $row as $pixel ) {
     181                /** @var \ImagickPixel $pixel */
     182                // Use getColorValue() for compatibility across Imagick versions
     183                $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_RED ) * 255 );
     184                $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_GREEN ) * 255 );
     185                $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_BLUE ) * 255 );
     186                $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_ALPHA ) * 255 );
     187            }
     188            $iterator->syncIterator();
     189        }
     190
     191        $imagick->destroy();
     192
     193        // Generate hash
     194        $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels );
     195
     196        return ThumbhashLib::convertHashToString( $hash );
     197    }
     198
     199    /**
     200     * Generate ThumbHash using GD.
     201     */
     202    private static function generateWithGd( string $imagePath ): ?string {
     203        $imageInfo = @getimagesize( $imagePath );
     204        if ( ! $imageInfo ) {
     205            return null;
     206        }
     207
     208        $mimeType = $imageInfo['mime'] ?? '';
     209        $image    = match ( $mimeType ) {
     210            'image/jpeg', 'image/jpg' => @imagecreatefromjpeg( $imagePath ),
     211            'image/png' => @imagecreatefrompng( $imagePath ),
     212            'image/gif' => @imagecreatefromgif( $imagePath ),
     213            'image/webp' => @imagecreatefromwebp( $imagePath ),
     214            default => false,
     215        };
     216
     217        if ( ! $image ) {
     218            return null;
     219        }
     220
     221        $width  = imagesx( $image );
     222        $height = imagesy( $image );
     223
     224        // Calculate thumbnail dimensions maintaining aspect ratio
     225        if ( $width > self::getMaxDimension() || $height > self::getMaxDimension() ) {
     226            if ( $width >= $height ) {
     227                $newWidth  = self::getMaxDimension();
     228                $newHeight = (int) round( $height * ( self::getMaxDimension() / $width ) );
     229            } else {
     230                $newHeight = self::getMaxDimension();
     231                $newWidth  = (int) round( $width * ( self::getMaxDimension() / $height ) );
     232            }
     233            $newWidth  = max( 1, $newWidth );
     234            $newHeight = max( 1, $newHeight );
     235
     236            $resized = imagecreatetruecolor( $newWidth, $newHeight );
     237            if ( ! $resized ) {
     238                return null;
     239            }
     240
     241            // Preserve alpha channel
     242            imagealphablending( $resized, false );
     243            imagesavealpha( $resized, true );
     244
     245            imagecopyresampled( $resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height );
     246            $image = $resized;
     247        } else {
     248            $newWidth  = $width;
    177249            $newHeight = $height;
    178250        }
     
    180252        // Extract RGBA pixels
    181253        $pixels = array();
    182         $iterator = $imagick->getPixelIterator();
    183 
    184         foreach ($iterator as $row) {
    185             foreach ($row as $pixel) {
    186                 /** @var \ImagickPixel $pixel */
    187                 // Use getColorValue() for compatibility across Imagick versions
    188                 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_RED) * 255);
    189                 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_GREEN) * 255);
    190                 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_BLUE) * 255);
    191                 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_ALPHA) * 255);
    192             }
    193             $iterator->syncIterator();
    194         }
    195 
    196         $imagick->destroy();
    197 
    198         // Generate hash
    199         $hash = ThumbhashLib::RGBAToHash($newWidth, $newHeight, $pixels);
    200 
    201         return ThumbhashLib::convertHashToString($hash);
    202     }
    203 
    204     /**
    205      * Generate ThumbHash using GD.
    206      */
    207     private static function generateWithGd(string $imagePath): ?string
    208     {
    209         $imageInfo = @getimagesize($imagePath);
    210         if (!$imageInfo) {
    211             return null;
    212         }
    213 
    214         $mimeType = $imageInfo['mime'] ?? '';
    215         $image = match ($mimeType) {
    216             'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($imagePath),
    217             'image/png' => @imagecreatefrompng($imagePath),
    218             'image/gif' => @imagecreatefromgif($imagePath),
    219             'image/webp' => @imagecreatefromwebp($imagePath),
    220             default => false,
    221         };
    222 
    223         if (!$image) {
    224             return null;
    225         }
    226 
    227         $width = imagesx($image);
    228         $height = imagesy($image);
    229 
    230         // Calculate thumbnail dimensions maintaining aspect ratio
    231         if ($width > self::getMaxDimension() || $height > self::getMaxDimension()) {
    232             if ($width >= $height) {
    233                 $newWidth = self::getMaxDimension();
    234                 $newHeight = (int) round($height * (self::getMaxDimension() / $width));
    235             } else {
    236                 $newHeight = self::getMaxDimension();
    237                 $newWidth = (int) round($width * (self::getMaxDimension() / $height));
    238             }
    239             $newWidth = max(1, $newWidth);
    240             $newHeight = max(1, $newHeight);
    241 
    242             $resized = imagecreatetruecolor($newWidth, $newHeight);
    243             if (!$resized) {
    244                 return null;
    245             }
    246 
    247             // Preserve alpha channel
    248             imagealphablending($resized, false);
    249             imagesavealpha($resized, true);
    250 
    251             imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
    252             $image = $resized;
    253         } else {
    254             $newWidth = $width;
    255             $newHeight = $height;
    256         }
    257 
    258         // Extract RGBA pixels
    259         $pixels = array();
    260         for ($y = 0; $y < $newHeight; $y++) {
    261             for ($x = 0; $x < $newWidth; $x++) {
    262                 $rgba = imagecolorat($image, $x, $y);
    263                 $pixels[] = ($rgba >> 16) & 0xFF; // R
    264                 $pixels[] = ($rgba >> 8) & 0xFF;  // G
     254        for ( $y = 0; $y < $newHeight; $y++ ) {
     255            for ( $x = 0; $x < $newWidth; $x++ ) {
     256                $rgba     = imagecolorat( $image, $x, $y );
     257                $pixels[] = ( $rgba >> 16 ) & 0xFF; // R
     258                $pixels[] = ( $rgba >> 8 ) & 0xFF;  // G
    265259                $pixels[] = $rgba & 0xFF;          // B
    266260                // GD alpha: 127 = transparent, 0 = opaque (invert for ThumbHash)
    267                 $alpha = ($rgba >> 24) & 0x7F;
    268                 $pixels[] = (int) round((127 - $alpha) * (255 / 127));
    269             }
    270         }
    271 
    272 
     261                $alpha    = ( $rgba >> 24 ) & 0x7F;
     262                $pixels[] = (int) round( ( 127 - $alpha ) * ( 255 / 127 ) );
     263            }
     264        }
    273265
    274266        // Generate hash
    275         $hash = ThumbhashLib::RGBAToHash($newWidth, $newHeight, $pixels);
    276 
    277         return ThumbhashLib::convertHashToString($hash);
     267        $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels );
     268
     269        return ThumbhashLib::convertHashToString( $hash );
    278270    }
    279271
     
    285277     * @return string|null Base64-encoded ThumbHash or null if not available.
    286278     */
    287     public static function getForAttachment(int $attachmentId, string $size = 'full'): ?string
    288     {
    289         if (!self::isEnabled()) {
    290             return null;
    291         }
    292 
    293         $meta = \get_post_meta($attachmentId, self::META_KEY, true);
    294         if (!is_array($meta)) {
    295             return null;
    296         }
    297 
    298         return $meta[$size] ?? $meta['full'] ?? null;
     279    public static function getForAttachment( int $attachmentId, string $size = 'full' ): ?string {
     280        if ( ! self::isEnabled() ) {
     281            return null;
     282        }
     283
     284        $meta = \get_post_meta( $attachmentId, self::META_KEY, true );
     285        if ( ! is_array( $meta ) ) {
     286            return null;
     287        }
     288
     289        return $meta[ $size ] ?? $meta['full'] ?? null;
    299290    }
    300291
     
    305296     * @return array<string, string>|null Hash array keyed by size name, or null on failure.
    306297     */
    307     public static function generateForAttachment(int $attachmentId): ?array
    308     {
    309         if (!self::isEnabled()) {
    310             return null;
    311         }
    312 
    313         return self::doGenerateForAttachment($attachmentId);
     298    public static function generateForAttachment( int $attachmentId ): ?array {
     299        if ( ! self::isEnabled() ) {
     300            return null;
     301        }
     302
     303        return self::doGenerateForAttachment( $attachmentId );
    314304    }
    315305
     
    319309     * @param int $attachmentId WordPress attachment ID.
    320310     */
    321     public static function deleteForAttachment(int $attachmentId): void
    322     {
    323         \delete_post_meta($attachmentId, self::META_KEY);
     311    public static function deleteForAttachment( int $attachmentId ): void {
     312        \delete_post_meta( $attachmentId, self::META_KEY );
    324313    }
    325314
     
    328317     * Used by uninstall.php for cleanup.
    329318     */
    330     public static function getMetaKey(): string
    331     {
     319    public static function getMetaKey(): string {
    332320        return self::META_KEY;
    333321    }
     
    339327     * @return array{generated: int, skipped: int, failed: int}
    340328     */
    341     public static function generateAll(bool $force = false): array
    342     {
     329    public static function generateAll( bool $force = false ): array {
    343330        $result = array(
    344331            'generated' => 0,
    345             'skipped' => 0,
    346             'failed' => 0,
     332            'skipped'   => 0,
     333            'failed'    => 0,
    347334        );
    348335
    349336        $query = new \WP_Query(
    350337            array(
    351                 'post_type' => 'attachment',
    352                 'post_mime_type' => array('image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'),
    353                 'post_status' => 'inherit',
    354                 'posts_per_page' => -1,
    355                 'fields' => 'ids',
    356                 'no_found_rows' => true,
     338                'post_type'              => 'attachment',
     339                'post_mime_type'         => array( 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp' ),
     340                'post_status'            => 'inherit',
     341                'posts_per_page'         => -1,
     342                'fields'                 => 'ids',
     343                'no_found_rows'          => true,
    357344                'update_post_meta_cache' => false, // Avoid potential caching issues
    358345                'update_post_term_cache' => false,
     
    360347        );
    361348
    362         $logger = class_exists(Logger::class) ? new Logger() : null;
    363 
    364         foreach ($query->posts as $attachmentId) {
     349        $logger = class_exists( Logger::class ) ? new Logger() : null;
     350
     351        foreach ( $query->posts as $attachmentId ) {
    365352            // Clear object cache for this post to ensure fresh meta data
    366353            // This prevents stale data from persistent object caching (Redis/Memcached)
    367             \clean_post_cache((int) $attachmentId);
     354            \clean_post_cache( (int) $attachmentId );
    368355
    369356            // Skip if already has valid ThumbHash (unless forcing regeneration)
    370             if (!$force) {
    371                 $existing = \get_post_meta($attachmentId, self::META_KEY, true);
     357            if ( ! $force ) {
     358                $existing = \get_post_meta( $attachmentId, self::META_KEY, true );
    372359                // Verify it's a valid ThumbHash array with at least a 'full' entry
    373                 if (is_array($existing) && isset($existing['full']) && is_string($existing['full']) && strlen($existing['full']) > 10) {
     360                if ( is_array( $existing ) && isset( $existing['full'] ) && is_string( $existing['full'] ) && strlen( $existing['full'] ) > 10 ) {
    374361                    ++$result['skipped'];
    375362                    // Log individual skip
    376                     if ($logger) {
     363                    if ( $logger ) {
    377364                        $logger->addLog(
    378365                            'info',
    379                             sprintf('LQIP skipped for attachment ID %d (already exists)', $attachmentId),
    380                             array('attachment_id' => $attachmentId)
     366                            sprintf( 'LQIP skipped for attachment ID %d (already exists)', $attachmentId ),
     367                            array( 'attachment_id' => $attachmentId )
    381368                        );
    382369                    }
     
    386373
    387374            // Use private helper to generate (bypasses isEnabled check for bulk operations)
    388             $hashes = self::doGenerateForAttachment((int) $attachmentId);
    389 
    390             if (is_array($hashes) && !empty($hashes['full'])) {
     375            $hashes = self::doGenerateForAttachment( (int) $attachmentId );
     376
     377            if ( is_array( $hashes ) && ! empty( $hashes['full'] ) ) {
    391378                ++$result['generated'];
    392379                // Log individual success
    393                 if ($logger) {
     380                if ( $logger ) {
    394381                    $logger->addLog(
    395382                        'success',
    396                         sprintf('LQIP generated for attachment ID %d', $attachmentId),
     383                        sprintf( 'LQIP generated for attachment ID %d', $attachmentId ),
    397384                        array(
    398                             'attachment_id' => $attachmentId,
    399                             'sizes_generated' => count($hashes),
     385                            'attachment_id'   => $attachmentId,
     386                            'sizes_generated' => count( $hashes ),
    400387                        )
    401388                    );
     
    404391                ++$result['failed'];
    405392                // Capture the last error for debugging
    406                 if (!isset($result['last_error']) && self::$lastError) {
     393                if ( ! isset( $result['last_error'] ) && self::$lastError ) {
    407394                    $result['last_error'] = self::$lastError;
    408395                }
    409396                // Log individual failure
    410                 if ($logger) {
     397                if ( $logger ) {
    411398                    $logger->addLog(
    412399                        'error',
    413                         sprintf('LQIP generation failed for attachment ID %d', $attachmentId),
     400                        sprintf( 'LQIP generation failed for attachment ID %d', $attachmentId ),
    414401                        array(
    415402                            'attachment_id' => $attachmentId,
    416                             'error' => self::$lastError ?? 'Unknown error',
     403                            'error'         => self::$lastError ?? 'Unknown error',
    417404                        )
    418405                    );
     
    422409
    423410        // Log summary of bulk operation
    424         if ($logger && ($result['generated'] > 0 || $result['failed'] > 0 || $result['skipped'] > 0)) {
     411        if ( $logger && ( $result['generated'] > 0 || $result['failed'] > 0 || $result['skipped'] > 0 ) ) {
    425412            $logger->addLog(
    426413                $result['failed'] > 0 ? 'warning' : 'success',
     
    433420                array(
    434421                    'generated' => $result['generated'],
    435                     'skipped' => $result['skipped'],
    436                     'failed' => $result['failed'],
     422                    'skipped'   => $result['skipped'],
     423                    'failed'    => $result['failed'],
    437424                )
    438425            );
     
    449436     * @return array<string, string>|null Hash array or null on failure.
    450437     */
    451     private static function doGenerateForAttachment(int $attachmentId): ?array
    452     {
    453         $metadata = \wp_get_attachment_metadata($attachmentId);
    454         if (!is_array($metadata) || empty($metadata['file'])) {
     438    private static function doGenerateForAttachment( int $attachmentId ): ?array {
     439        $metadata = \wp_get_attachment_metadata( $attachmentId );
     440        if ( ! is_array( $metadata ) || empty( $metadata['file'] ) ) {
    455441            self::$lastError = "Invalid or missing metadata for attachment $attachmentId";
    456442            return null;
     
    458444
    459445        $uploadDir = \wp_upload_dir();
    460         $baseDir = $uploadDir['basedir'] ?? '';
    461         if (!$baseDir) {
     446        $baseDir   = $uploadDir['basedir'] ?? '';
     447        if ( ! $baseDir ) {
    462448            self::$lastError = 'Upload basedir not found.';
    463449            return null;
    464450        }
    465451
    466         $hashes = array();
    467         $file = $metadata['file'];
    468         $fileDir = dirname($file);
     452        $hashes  = array();
     453        $file    = $metadata['file'];
     454        $fileDir = dirname( $file );
    469455
    470456        // Generate for original/full image
    471457        $fullPath = $baseDir . '/' . $file;
    472458        // Some setups have 'file' as absolute path (rare but possible in offload plugins)
    473         if (!file_exists($fullPath)) {
    474             if (file_exists($file)) {
     459        if ( ! file_exists( $fullPath ) ) {
     460            if ( file_exists( $file ) ) {
    475461                $fullPath = $file;
    476462            } else {
    477463                self::$lastError = "File not found: $fullPath";
    478                 if (class_exists(Logger::class)) {
    479                     (new Logger())->addLog('warning', "ThumbHash skipped: Source file missing for ID $attachmentId", array('path' => $fullPath));
     464                if ( class_exists( Logger::class ) ) {
     465                    ( new Logger() )->addLog( 'warning', "ThumbHash skipped: Source file missing for ID $attachmentId", array( 'path' => $fullPath ) );
    480466                }
    481467            }
    482468        }
    483469
    484         if (file_exists($fullPath)) {
    485             $hash = self::generate($fullPath);
    486             if ($hash) {
     470        if ( file_exists( $fullPath ) ) {
     471            $hash = self::generate( $fullPath );
     472            if ( $hash ) {
    487473                $hashes['full'] = $hash;
    488474            }
     
    491477        // Generate for each registered size
    492478        $sizes = $metadata['sizes'] ?? array();
    493         foreach ($sizes as $sizeName => $sizeData) {
     479        foreach ( $sizes as $sizeName => $sizeData ) {
    494480            $sizeFile = $sizeData['file'] ?? '';
    495             if (!$sizeFile) {
     481            if ( ! $sizeFile ) {
    496482                continue;
    497483            }
    498484
    499485            $sizePath = $baseDir . '/' . $fileDir . '/' . $sizeFile;
    500             if (file_exists($sizePath)) {
    501                 $hash = self::generate($sizePath);
    502                 if ($hash) {
    503                     $hashes[$sizeName] = $hash;
     486            if ( file_exists( $sizePath ) ) {
     487                $hash = self::generate( $sizePath );
     488                if ( $hash ) {
     489                    $hashes[ $sizeName ] = $hash;
    504490                }
    505491            }
    506492        }
    507493
    508         if (!empty($hashes)) {
    509             \update_post_meta($attachmentId, self::META_KEY, $hashes);
     494        if ( ! empty( $hashes ) ) {
     495            \update_post_meta( $attachmentId, self::META_KEY, $hashes );
    510496            return $hashes;
    511497        }
     
    519505     * @return int Number of meta entries deleted.
    520506     */
    521     public static function deleteAll(): int
    522     {
     507    public static function deleteAll(): int {
    523508        global $wpdb;
    524509
     
    532517        );
    533518
    534         if (empty($postIds)) {
     519        if ( empty( $postIds ) ) {
    535520            return 0;
    536521        }
     
    546531
    547532        // Clear object cache for affected posts to prevent stale data in get_post_meta
    548         foreach ($postIds as $postId) {
    549             \clean_post_cache((int) $postId);
     533        foreach ( $postIds as $postId ) {
     534            \clean_post_cache( (int) $postId );
    550535        }
    551536
    552537        // Log the deletion
    553         if (class_exists(Logger::class)) {
    554             (new Logger())->addLog(
     538        if ( class_exists( Logger::class ) ) {
     539            ( new Logger() )->addLog(
    555540                'info',
    556                 sprintf('LQIP bulk delete: %d entries deleted', $deleted),
    557                 array('deleted' => (int) $deleted)
     541                sprintf( 'LQIP bulk delete: %d entries deleted', $deleted ),
     542                array( 'deleted' => (int) $deleted )
    558543            );
    559544        }
     
    570555     * @return array{with_hash: int, without_hash: int, total: int}
    571556     */
    572     public static function getStats(): array
    573     {
     557    public static function getStats(): array {
    574558        global $wpdb;
    575559
     
    590574        );
    591575
    592         $withHash = 0;
     576        $withHash    = 0;
    593577        $seenPostIds = array();
    594578
    595         foreach ($rows as $row) {
     579        foreach ( $rows as $row ) {
    596580            // Skip if we've already counted this post
    597             if (isset($seenPostIds[$row->post_id])) {
     581            if ( isset( $seenPostIds[ $row->post_id ] ) ) {
    598582                continue;
    599583            }
    600584
    601585            // Validate the structure matches what generateAll() skip logic expects
    602             $meta = maybe_unserialize($row->meta_value);
    603             if (is_array($meta) && isset($meta['full']) && is_string($meta['full']) && strlen($meta['full']) > 10) {
     586            $meta = maybe_unserialize( $row->meta_value );
     587            if ( is_array( $meta ) && isset( $meta['full'] ) && is_string( $meta['full'] ) && strlen( $meta['full'] ) > 10 ) {
    604588                ++$withHash;
    605                 $seenPostIds[$row->post_id] = true;
     589                $seenPostIds[ $row->post_id ] = true;
    606590            }
    607591        }
    608592
    609593        return array(
    610             'with_hash' => $withHash,
     594            'with_hash'    => $withHash,
    611595            'without_hash' => $total - $withHash,
    612             'total' => $total,
     596            'total'        => $total,
    613597        );
    614598    }
  • avif-local-support/tags/0.5.21/includes/class-support.php

    r3432774 r3444709  
    66
    77// Prevent direct access.
    8 \defined('ABSPATH') || exit;
    9 
    10 final class Support
    11 {
    12 
    13 
    14 
    15 
    16     private array $fileCache = array();
     8\defined( 'ABSPATH' ) || exit;
     9
     10final class Support {
     11
     12
     13
     14
     15
     16    private array $fileCache   = array();
    1717    private array $uploadsInfo = array();
    1818
    19     public function init(): void
    20     {
     19    public function init(): void {
     20
    2121        $this->uploadsInfo = \wp_upload_dir();
    22         $this->fileCache = \get_transient('aviflosu_file_cache') ?: array();
    23         add_filter('wp_get_attachment_image', array($this, 'wrapAttachment'), 10, 5);
    24         add_filter('the_content', array($this, 'wrapContentImages'));
    25         add_filter('post_thumbnail_html', array($this, 'wrapContentImages'));
    26         add_filter('render_block', array($this, 'renderBlock'), 10, 2);
    27         add_action('shutdown', array($this, 'saveCache'));
     22        $this->fileCache   = \get_transient( 'aviflosu_file_cache' ) ?: array();
     23        add_filter( 'wp_get_attachment_image', array( $this, 'wrapAttachment' ), 10, 5 );
     24        // Priority 15 runs AFTER WordPress adds srcset via wp_filter_content_tags at priority 10.
     25        add_filter( 'the_content', array( $this, 'wrapContentImages' ), 15 );
     26        add_filter( 'post_thumbnail_html', array( $this, 'wrapContentImages' ), 15 );
     27        add_action( 'shutdown', array( $this, 'saveCache' ) );
    2828
    2929        // Enqueue ThumbHash decoder if enabled - inline in head for early execution.
    30         if (ThumbHash::isEnabled() && !\is_admin()) {
    31             add_action('wp_head', array($this, 'inlineThumbHashDecoder'), 1);
     30        if ( ThumbHash::isEnabled() && ! \is_admin() ) {
     31            add_action( 'wp_head', array( $this, 'inlineThumbHashDecoder' ), 1 );
    3232        }
    3333    }
     
    3737     * This ensures placeholders appear before images start loading.
    3838     */
    39     public function inlineThumbHashDecoder(): void
    40     {
     39    public function inlineThumbHashDecoder(): void {
    4140        $scriptPath = AVIFLOSU_PLUGIN_DIR . 'assets/thumbhash-decoder.min.js';
    42         if (!file_exists($scriptPath)) {
     41        if ( ! file_exists( $scriptPath ) ) {
    4342            return;
    4443        }
    45         $script = file_get_contents($scriptPath);
    46         if ($script) {
     44        $script = file_get_contents( $scriptPath );
     45        if ( $script ) {
    4746            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Inline JS from trusted local file.
    4847            echo '<script id="aviflosu-thumbhash-decoder">' . $script . '</script>' . "\n";
     
    5049
    5150        // Inject CSS for fading if enabled.
    52         if ((bool) get_option('aviflosu_lqip_fade', true)) {
     51        if ( (bool) get_option( 'aviflosu_lqip_fade', true ) ) {
    5352            // CSS explanation:
    5453            // 1. img[data-thumbhash] starts visible (opacity 1) with a transition.
     
    6059                . 'img.thumbhash-loading[data-thumbhash]{opacity:0;}';
    6160            // Optionally render placeholders as sharp pixels instead of smooth blur.
    62             if ((bool) get_option('aviflosu_lqip_pixelated', false)) {
     61            if ( (bool) get_option( 'aviflosu_lqip_pixelated', false ) ) {
    6362                $css .= '.thumbhash-loading,img.thumbhash-loading{image-rendering:pixelated;}';
    6463            }
     
    6867    }
    6968
    70     public function wrapAttachment(string $html, int $attachmentId, $size, bool $icon, array $attr): string
    71     {
    72         if (str_contains($html, '<picture')) {
     69    public function wrapAttachment( string $html, int $attachmentId, $size, bool $icon, array $attr ): string {
     70        if ( str_contains( $html, '<picture' ) ) {
    7371            return $html;
    7472        }
    7573
    76         $mime = \get_post_mime_type($attachmentId);
    77         if (!\is_string($mime) || !\in_array($mime, array('image/jpeg', 'image/jpg'), true)) {
     74        $mime = \get_post_mime_type( $attachmentId );
     75        if ( ! \is_string( $mime ) || ! \in_array( $mime, array( 'image/jpeg', 'image/jpg' ), true ) ) {
    7876            return $html;
    7977        }
    8078
    81         $imageSrc = \wp_get_attachment_image_src($attachmentId, $size);
    82         if (!$imageSrc || !\is_array($imageSrc) || empty($imageSrc[0])) {
     79        $imageSrc = \wp_get_attachment_image_src( $attachmentId, $size );
     80        if ( ! $imageSrc || ! \is_array( $imageSrc ) || empty( $imageSrc[0] ) ) {
    8381            return $html;
    8482        }
    8583
    86         $avifSrc = $this->avifUrlFor($imageSrc[0]);
    87 
    88         $srcset = \wp_get_attachment_image_srcset($attachmentId, $size);
    89         $avifSrcset = ($srcset && $avifSrc) ? $this->convertSrcsetToAvif($srcset) : '';
    90         $sizes = \wp_get_attachment_image_sizes($attachmentId, $size) ?: '';
     84        $avifSrc = $this->avifUrlFor( $imageSrc[0] );
     85
     86        $srcset     = \wp_get_attachment_image_srcset( $attachmentId, $size );
     87        $avifSrcset = $srcset ? $this->convertSrcsetToAvif( $srcset ) : '';
     88
     89        // Ensure single AVIF candidates have width descriptors for responsive selection.
     90        if ( '' === $avifSrcset && $avifSrc ) {
     91            $w          = (int) ( $imageSrc[1] ?? 0 );
     92            $avifSrcset = $w > 0 ? ( $avifSrc . ' ' . $w . 'w' ) : $avifSrc;
     93        }
     94
     95        $sizes = \wp_get_attachment_image_sizes( $attachmentId, $size ) ?: '';
    9196
    9297        // Get ThumbHash for LQIP if enabled.
    93         $sizeName = is_array($size) ? 'full' : (string) $size;
    94         $thumbhash = ThumbHash::getForAttachment($attachmentId, $sizeName);
    95 
    96         return $this->pictureMarkup($html, $avifSrc, $avifSrcset, $sizes, $thumbhash);
    97     }
    98 
    99     public function wrapContentImages(string $content): string
    100     {
    101         if (\is_admin() || \wp_doing_ajax() || (\defined('REST_REQUEST') && REST_REQUEST)) {
     98        $sizeName  = is_array( $size ) ? 'full' : (string) $size;
     99        $thumbhash = ThumbHash::getForAttachment( $attachmentId, $sizeName );
     100
     101        return $this->pictureMarkup( $html, $avifSrc, $avifSrcset, $sizes, $thumbhash );
     102    }
     103
     104    public function wrapContentImages( string $content ): string {
     105        if ( \is_admin() || \wp_doing_ajax() || ( \defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
    102106            return $content;
    103107        }
    104         if (!str_contains($content, '<img')) {
     108        if ( ! str_contains( $content, '<img' ) ) {
    105109            return $content;
    106110        }
    107         return $this->wrapHtmlImages($content);
    108     }
    109 
    110     public function renderBlock(string $block_content, array $block): string
    111     {
    112         $name = $block['blockName'] ?? '';
    113         if ('core/image' !== $name && 'core/gallery' !== $name) {
    114             return $block_content;
    115         }
    116         if ('' === $block_content || false === strpos($block_content, '<img')) {
    117             return $block_content;
    118         }
    119         return $this->wrapHtmlImages($block_content);
    120     }
    121 
    122     private function avifUrlFor(string $jpegUrl): ?string
    123     {
    124         if (!$this->isUploadsImage($jpegUrl)) {
    125             return null;
    126         }
    127         $parts = \wp_parse_url($jpegUrl);
    128         if (false === $parts || empty($parts['path'])) {
     111        return $this->wrapHtmlImages( $content );
     112    }
     113
     114    private function avifUrlFor( string $jpegUrl ): ?string {
     115        if ( ! $this->isUploadsImage( $jpegUrl ) ) {
     116            return null;
     117        }
     118        $parts = \wp_parse_url( $jpegUrl );
     119        if ( false === $parts || empty( $parts['path'] ) ) {
    129120            return null;
    130121        }
    131122        $path = $parts['path'];
    132         if (!\preg_match('/\.(jpe?g)$/i', $path)) {
    133             return null;
    134         }
    135         $avifPath = (string) \preg_replace('/\.(jpe?g)$/i', '.avif', $path);
    136         $reconstructed = ($parts['scheme'] ?? '') !== ''
    137             ? ($parts['scheme'] . '://')
    138             : '';
    139         if (!empty($parts['host'])) {
    140             $reconstructed .= $parts['host'];
    141         }
    142         if (!empty($parts['port'])) {
    143             $reconstructed .= ':' . $parts['port'];
    144         }
    145         $reconstructed .= $avifPath;
    146         if (!empty($parts['query'])) {
    147             $reconstructed .= '?' . $parts['query'];
    148         }
    149         if (!empty($parts['fragment'])) {
    150             $reconstructed .= '#' . $parts['fragment'];
    151         }
    152 
    153         $relative = str_replace($this->uploadsInfo['baseurl'] ?? '', '', (string) $reconstructed);
    154         $avifLocal = ($this->uploadsInfo['basedir'] ?? '') . $relative;
    155         return $this->avifExists($avifLocal) ? $reconstructed : null;
    156     }
    157 
    158     private function isUploadsImage(string $src): bool
    159     {
     123        if ( ! \preg_match( '/\.(jpe?g)$/i', $path ) ) {
     124            return null;
     125        }
     126        $avifPath = (string) \preg_replace( '/\.(jpe?g)$/i', '.avif', $path );
     127
     128        // Build local path from path-only (no query/fragment) for file_exists check.
     129        $uploadsBasePath = wp_parse_url( $this->uploadsInfo['baseurl'] ?? '', PHP_URL_PATH ) ?: '';
     130        if ( ! str_starts_with( $avifPath, $uploadsBasePath ) ) {
     131            return null;
     132        }
     133        $avifRelative = substr( $avifPath, strlen( $uploadsBasePath ) );
     134        $avifLocal    = trailingslashit( $this->uploadsInfo['basedir'] ?? '' ) . ltrim( $avifRelative, '/' );
     135
     136        if ( ! $this->avifExists( $avifLocal ) ) {
     137            return null;
     138        }
     139
     140        // Reconstruct AVIF URL (preserve query string for cache-busting if present).
     141        $avifUrl  = ( $parts['scheme'] ?? '' ) !== '' ? ( $parts['scheme'] . '://' ) : '';
     142        $avifUrl .= $parts['host'] ?? '';
     143        $avifUrl .= ! empty( $parts['port'] ) ? ( ':' . $parts['port'] ) : '';
     144        $avifUrl .= $avifPath;
     145        if ( ! empty( $parts['query'] ) ) {
     146            $avifUrl .= '?' . $parts['query'];
     147        }
     148        return $avifUrl;
     149    }
     150
     151    private function isUploadsImage( string $src ): bool {
    160152        $uploadsUrl = $this->uploadsInfo['baseurl'] ?? '';
    161         return '' !== $uploadsUrl && str_starts_with($src, $uploadsUrl);
    162     }
    163 
    164     private function avifExists(string $filePath): bool
    165     {
     153        return '' !== $uploadsUrl && str_starts_with( $src, $uploadsUrl );
     154    }
     155
     156    private function avifExists( string $filePath ): bool {
    166157        // Only cache positive (true) results. Negative results should always be re-checked
    167158        // because files may be converted after the cache was populated.
    168         if (isset($this->fileCache[$filePath]) && true === $this->fileCache[$filePath]) {
     159        if ( isset( $this->fileCache[ $filePath ] ) && true === $this->fileCache[ $filePath ] ) {
    169160            return true;
    170161        }
    171         $exists = file_exists($filePath);
    172         if ($exists) {
    173             $this->fileCache[$filePath] = true;
     162        $exists = file_exists( $filePath );
     163        if ( $exists ) {
     164            $this->fileCache[ $filePath ] = true;
    174165        }
    175166        return $exists;
    176167    }
    177168
    178     private function convertSrcsetToAvif(string $srcset): string
    179     {
    180         $parts = array_map('trim', explode(',', $srcset));
    181         $out = array();
    182         foreach ($parts as $part) {
    183             if ('' === $part) {
     169    private function convertSrcsetToAvif( string $srcset ): string {
     170        $parts = array_map( 'trim', explode( ',', $srcset ) );
     171        $out   = array();
     172        foreach ( $parts as $part ) {
     173            if ( '' === $part ) {
    184174                continue;
    185175            }
    186             $pieces = preg_split('/\s+/', trim($part), 2);
    187             $url = $pieces[0];
     176            $pieces     = preg_split( '/\s+/', trim( $part ), 2 );
     177            $url        = $pieces[0];
    188178            $descriptor = $pieces[1] ?? '';
    189             $avifUrl = $this->avifUrlFor($url);
    190             if ($avifUrl) {
    191                 $out[] = trim($avifUrl . ' ' . $descriptor);
    192             }
    193         }
    194         return implode(', ', $out);
    195     }
    196 
    197     private function pictureMarkup(string $originalHtml, ?string $avifSrc, string $avifSrcset = '', string $sizes = '', ?string $thumbhash = null): string
    198     {
    199         if ((!$avifSrc || '' === $avifSrc) && (!$thumbhash || '' === $thumbhash)) {
     179            $avifUrl    = $this->avifUrlFor( $url );
     180            if ( $avifUrl ) {
     181                $out[] = trim( $avifUrl . ' ' . $descriptor );
     182            }
     183        }
     184        return implode( ', ', $out );
     185    }
     186
     187    private function pictureMarkup( string $originalHtml, ?string $avifSrc, string $avifSrcset = '', string $sizes = '', ?string $thumbhash = null ): string {
     188        if ( ( ! $avifSrc || '' === $avifSrc ) && ( ! $thumbhash || '' === $thumbhash ) ) {
    200189            return $originalHtml;
    201190        }
    202         $srcset = '' !== $avifSrcset ? $avifSrcset : $avifSrc;
    203         $sizesAttr = '' !== $sizes ? sprintf(' sizes="%s"', \esc_attr($sizes)) : '';
     191        $srcset    = '' !== $avifSrcset ? $avifSrcset : $avifSrc;
     192        $sizesAttr = '' !== $sizes ? sprintf( ' sizes="%s"', \esc_attr( $sizes ) ) : '';
    204193
    205194        // Add ThumbHash data attribute to img tag if available.
    206195        $imgHtml = $originalHtml;
    207         if ($thumbhash !== null && $thumbhash !== '') {
     196        if ( $thumbhash !== null && $thumbhash !== '' ) {
    208197            $imgHtml = preg_replace(
    209198                '/<img\s/',
    210                 '<img data-thumbhash="' . \esc_attr($thumbhash) . '" ',
     199                '<img data-thumbhash="' . \esc_attr( $thumbhash ) . '" ',
    211200                $originalHtml,
    212201                1
    213202            );
    214             if ($imgHtml === null) {
     203            if ( $imgHtml === null ) {
    215204                $imgHtml = $originalHtml;
    216205            }
    217206        }
    218207
    219         if (!$avifSrc || '' === $avifSrc) {
     208        if ( ! $avifSrc || '' === $avifSrc ) {
    220209            return $imgHtml;
    221210        }
    222211
    223212        // Only wrap in <picture> if AVIF serving is enabled.
    224         $avifEnabled = (bool) \get_option('aviflosu_enable_support', true);
    225         if (!$avifEnabled) {
     213        $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true );
     214        if ( ! $avifEnabled ) {
    226215            return $imgHtml;
    227216        }
    228217
    229         return sprintf('<picture><source type="image/avif" srcset="%s"%s>%s</picture>', \esc_attr($srcset), $sizesAttr, $imgHtml);
    230     }
    231 
    232     private function isInsidePicture(\DOMNode $node): bool
    233     {
     218        return sprintf( '<picture><source type="image/avif" srcset="%s"%s>%s</picture>', \esc_attr( $srcset ), $sizesAttr, $imgHtml );
     219    }
     220
     221    private function isInsidePicture( \DOMNode $node ): bool {
    234222        $parent = $node->parentNode;
    235         while ($parent) {
    236             if ($parent instanceof \DOMElement && 'picture' === strtolower($parent->nodeName)) {
     223        while ( $parent ) {
     224            if ( $parent instanceof \DOMElement && 'picture' === strtolower( $parent->nodeName ) ) {
    237225                return true;
    238226            }
     
    242230    }
    243231
    244     private function wrapImgNodeToPicture(\DOMDocument $dom, \DOMElement $img, string $avifSrcset, string $sizes, ?string $thumbhash = null): void
    245     {
     232    private function wrapImgNodeToPicture( \DOMDocument $dom, \DOMElement $img, string $avifSrcset, string $sizes, ?string $thumbhash = null ): void {
    246233        // Add ThumbHash data attribute if available
    247         if ($thumbhash !== null && $thumbhash !== '') {
    248             $img->setAttribute('data-thumbhash', $thumbhash);
    249         }
    250 
    251         if ('' === $avifSrcset) {
     234        if ( $thumbhash !== null && $thumbhash !== '' ) {
     235            $img->setAttribute( 'data-thumbhash', $thumbhash );
     236        }
     237
     238        if ( '' === $avifSrcset ) {
    252239            return;
    253240        }
    254241
    255242        // Only wrap in <picture> if AVIF serving is enabled.
    256         $avifEnabled = (bool) \get_option('aviflosu_enable_support', true);
    257         if (!$avifEnabled) {
     243        $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true );
     244        if ( ! $avifEnabled ) {
    258245            return;
    259246        }
    260247
    261         $picture = $dom->createElement('picture');
    262         $source = $dom->createElement('source');
    263         $source->setAttribute('type', 'image/avif');
    264         $source->setAttribute('srcset', $avifSrcset);
    265         if ('' !== $sizes) {
    266             $source->setAttribute('sizes', $sizes);
    267         }
    268         $picture->appendChild($source);
    269         $img->parentNode?->replaceChild($picture, $img);
    270         $picture->appendChild($img);
    271     }
    272 
    273     private function wrapHtmlImages(string $htmlInput): string
    274     {
     248        $picture = $dom->createElement( 'picture' );
     249        $source  = $dom->createElement( 'source' );
     250        $source->setAttribute( 'type', 'image/avif' );
     251        $source->setAttribute( 'srcset', $avifSrcset );
     252        if ( '' !== $sizes ) {
     253            $source->setAttribute( 'sizes', $sizes );
     254        }
     255        $picture->appendChild( $source );
     256        $img->parentNode?->replaceChild( $picture, $img );
     257        $picture->appendChild( $img );
     258    }
     259
     260    private function wrapHtmlImages( string $htmlInput ): string {
    275261        $html = '<?xml encoding="utf-8" ?>' . $htmlInput;
    276         $dom = new \DOMDocument();
    277         \libxml_use_internal_errors(true);
    278         if (!$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD)) {
     262        $dom  = new \DOMDocument();
     263        \libxml_use_internal_errors( true );
     264        if ( ! $dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ) ) {
    279265            \libxml_clear_errors();
    280266            return $htmlInput;
     
    282268        \libxml_clear_errors();
    283269
    284         $imgs = $dom->getElementsByTagName('img');
     270        $imgs      = $dom->getElementsByTagName( 'img' );
    285271        $toProcess = array();
    286         foreach ($imgs as $img) {
     272        foreach ( $imgs as $img ) {
    287273            $toProcess[] = $img;
    288274        }
    289         foreach ($toProcess as $img) {
    290             if (!($img instanceof \DOMElement)) {
     275        foreach ( $toProcess as $img ) {
     276            if ( ! ( $img instanceof \DOMElement ) ) {
    291277                continue;
    292278            }
    293             if ($this->isInsidePicture($img)) {
     279            if ( $this->isInsidePicture( $img ) ) {
    294280                continue;
    295281            }
    296             $src = (string) $img->getAttribute('src');
    297             $avifUrl = $this->avifUrlFor($src);
     282            $src     = (string) $img->getAttribute( 'src' );
     283            $avifUrl = $this->avifUrlFor( $src );
    298284
    299285            // Extract attachment ID from wp-image-{ID} class for ThumbHash lookup
    300286            $thumbhash = null;
    301             if (ThumbHash::isEnabled()) {
    302                 $class = (string) $img->getAttribute('class');
    303                 if (preg_match('/wp-image-(\d+)/', $class, $matches)) {
     287            if ( ThumbHash::isEnabled() ) {
     288                $class = (string) $img->getAttribute( 'class' );
     289                if ( preg_match( '/wp-image-(\d+)/', $class, $matches ) ) {
    304290                    $attachmentId = (int) $matches[1];
    305                     $thumbhash = ThumbHash::getForAttachment($attachmentId, 'full');
     291                    $thumbhash    = ThumbHash::getForAttachment( $attachmentId, 'full' );
    306292                }
    307293            }
    308294
    309             if (!$avifUrl && !$thumbhash) {
     295            if ( ! $avifUrl && ! $thumbhash ) {
    310296                continue;
    311297            }
     
    313299            // Check if the image is wrapped in a link to a JPEG that also has an AVIF version.
    314300            $parent = $img->parentNode;
    315             if ($parent instanceof \DOMElement && strtolower($parent->nodeName) === 'a') {
    316                 $href = (string) $parent->getAttribute('href');
     301            if ( $parent instanceof \DOMElement && strtolower( $parent->nodeName ) === 'a' ) {
     302                $href = (string) $parent->getAttribute( 'href' );
    317303                // Only process if href looks like a JPEG
    318                 if (preg_match('/\.(jpe?g)$/i', $href)) {
    319                     $avifHref = $this->avifUrlFor($href);
    320                     if ($avifHref) {
    321                         $parent->setAttribute('href', $avifHref);
     304                if ( preg_match( '/\.(jpe?g)$/i', $href ) ) {
     305                    $avifHref = $this->avifUrlFor( $href );
     306                    if ( $avifHref ) {
     307                        $parent->setAttribute( 'href', $avifHref );
    322308                    }
    323309                }
    324310            }
    325311
    326             $srcset = (string) $img->getAttribute('srcset');
    327             $sizes = (string) $img->getAttribute('sizes');
    328             $avifSrcset = ('' !== $srcset && $avifUrl) ? $this->convertSrcsetToAvif($srcset) : ($avifUrl ?: '');
    329 
    330             $this->wrapImgNodeToPicture($dom, $img, $avifSrcset, $sizes, $thumbhash);
     312            $srcset     = (string) $img->getAttribute( 'srcset' );
     313            $sizes      = (string) $img->getAttribute( 'sizes' );
     314            $avifSrcset = ( '' !== $srcset ) ? $this->convertSrcsetToAvif( $srcset ) : ( $avifUrl ?: '' );
     315
     316            // Ensure single AVIF candidates have width descriptors for responsive selection.
     317            if ( '' === $avifSrcset && $avifUrl ) {
     318                $w          = (int) $img->getAttribute( 'width' );
     319                $avifSrcset = $w > 0 ? ( $avifUrl . ' ' . $w . 'w' ) : $avifUrl;
     320            }
     321
     322            $this->wrapImgNodeToPicture( $dom, $img, $avifSrcset, $sizes, $thumbhash );
    331323        }
    332324
    333325        // Cleanup: Remove the XML declaration node we added to force UTF-8
    334         while ($dom->firstChild instanceof \DOMProcessingInstruction && $dom->firstChild->nodeName === 'xml') {
    335             $dom->removeChild($dom->firstChild);
     326        while ( $dom->firstChild instanceof \DOMProcessingInstruction && $dom->firstChild->nodeName === 'xml' ) {
     327            $dom->removeChild( $dom->firstChild );
    336328        }
    337329
    338330        $out = $dom->saveHTML();
    339         return \is_string($out) && $out !== '' ? $out : $htmlInput;
    340     }
    341 
    342     public function saveCache(): void
    343     {
     331        return \is_string( $out ) && $out !== '' ? $out : $htmlInput;
     332    }
     333
     334    public function saveCache(): void {
    344335        // Only save positive (true) entries - filter out any false entries that might have snuck in.
    345         $positiveOnly = array_filter($this->fileCache, fn($v) => true === $v);
    346         set_transient('aviflosu_file_cache', $positiveOnly, (int) get_option('aviflosu_cache_duration', 3600));
     336        $positiveOnly = array_filter( $this->fileCache, fn( $v ) => true === $v );
     337        set_transient( 'aviflosu_file_cache', $positiveOnly, (int) get_option( 'aviflosu_cache_duration', 3600 ) );
    347338    }
    348339}
  • avif-local-support/tags/0.5.21/readme.txt

    r3433195 r3444709  
    44Requires at least: 6.8
    55Tested up to: 6.9
    6 Stable tag: 0.5.20
     6Stable tag: 0.5.21
    77Requires PHP: 8.3
    88License: GPLv2 or later
     
    198198
    199199== Changelog ==
     200
     201= 0.5.21 =
     202
     203- Enhancement: ThumbHash input increased to 100px for richer LQIP placeholders.
     204- Fix: AVIF srcset now correctly lists all responsive sizes by running after WordPress adds srcset attributes.
     205- Fix: Single AVIF images now include width descriptors for proper responsive selection.
     206- Refactor: Removed redundant `render_block` filter; unified image processing in `the_content` filter.
    200207
    201208= 0.5.20 =
  • avif-local-support/trunk/avif-local-support.php

    r3433195 r3444709  
    77 * Plugin URI: https://github.com/ddegner/avif-local-support
    88 * Description: High-quality AVIF image conversion for WordPress — local, quality-first.
    9  * Version: 0.5.20
     9 * Version: 0.5.21
    1010 * Author: ddegner
    1111 * Author URI: https://www.daviddegner.com
     
    2222
    2323// Define constants
    24 \define('AVIFLOSU_VERSION', '0.5.20');
     24\define('AVIFLOSU_VERSION', '0.5.21');
    2525\define('AVIFLOSU_PLUGIN_FILE', __FILE__);
    2626\define('AVIFLOSU_PLUGIN_DIR', plugin_dir_path(__FILE__));
  • avif-local-support/trunk/includes/ThumbHash.php

    r3432335 r3444709  
    88
    99// Prevent direct access.
    10 \defined('ABSPATH') || exit;
     10\defined( 'ABSPATH' ) || exit;
    1111
    1212/**
     
    1616 * client-side to smooth placeholders while full images load.
    1717 */
    18 final class ThumbHash
    19 {
     18final class ThumbHash {
     19
    2020
    2121
     
    2727    /**
    2828     * Maximum dimension for thumbnail before hashing.
    29      * Fixed at 32px to match ThumbHash decoder output resolution.
    30      */
    31     private const MAX_DIMENSION = 32;
     29     * Set to 100px (ThumbHash maximum) to capture more detail in the DCT encoding.
     30     * The decoder outputs 32px, but larger input = more frequency data = richer placeholders.
     31     */
     32    private const MAX_DIMENSION = 100;
    3233
    3334    /**
     
    3536     * Fixed at 32px to match the decoder output.
    3637     */
    37     public static function getMaxDimension(): int
    38     {
     38    public static function getMaxDimension(): int {
    3939        return self::MAX_DIMENSION;
    4040    }
     
    4343     * Check if ThumbHash feature is enabled.
    4444     */
    45     public static function isEnabled(): bool
    46     {
    47         return (bool) \get_option('aviflosu_thumbhash_enabled', false);
     45    public static function isEnabled(): bool {
     46        return (bool) \get_option( 'aviflosu_thumbhash_enabled', false );
    4847    }
    4948
     
    5352     * @return bool True if the library class exists, false otherwise.
    5453     */
    55     public static function isLibraryAvailable(): bool
    56     {
    57         return class_exists('Thumbhash\Thumbhash');
     54    public static function isLibraryAvailable(): bool {
     55        return class_exists( 'Thumbhash\Thumbhash' );
    5856    }
    5957
     
    6462     * @return string|null Base64-encoded ThumbHash or null on failure.
    6563     */
    66     public static function generate(string $imagePath): ?string
    67     {
     64    public static function generate( string $imagePath ): ?string {
    6865        // Check if the ThumbHash library is available
    69         if (!self::isLibraryAvailable()) {
     66        if ( ! self::isLibraryAvailable() ) {
    7067            self::$lastError = 'ThumbHash library not found. Please run "composer install" in the plugin directory to install dependencies.';
    71             if (class_exists(Logger::class)) {
    72                 (new Logger())->addLog(
     68            if ( class_exists( Logger::class ) ) {
     69                ( new Logger() )->addLog(
    7370                    'error',
    7471                    'ThumbHash library not available',
    7572                    array(
    76                         'path' => $imagePath,
     73                        'path'  => $imagePath,
    7774                        'error' => 'Thumbhash\Thumbhash class not found. Composer dependencies may not be installed.',
    7875                    )
     
    8279        }
    8380
    84         if (!file_exists($imagePath) || !is_readable($imagePath)) {
     81        if ( ! file_exists( $imagePath ) || ! is_readable( $imagePath ) ) {
    8582            self::$lastError = "File not found or unreadable: $imagePath";
    86             if (class_exists(Logger::class)) {
    87                 (new Logger())->addLog('error', 'ThumbHash failed: File not found', array('path' => $imagePath));
     83            if ( class_exists( Logger::class ) ) {
     84                ( new Logger() )->addLog( 'error', 'ThumbHash failed: File not found', array( 'path' => $imagePath ) );
    8885            }
    8986            return null;
     
    9289        try {
    9390            // Try Imagick first (better alpha support)
    94             if (extension_loaded('imagick') && class_exists(\Imagick::class)) {
    95                 return self::generateWithImagick($imagePath);
     91            if ( extension_loaded( 'imagick' ) && class_exists( \Imagick::class ) ) {
     92                return self::generateWithImagick( $imagePath );
    9693            }
    9794
    9895            // Fall back to GD
    99             if (extension_loaded('gd')) {
    100                 return self::generateWithGd($imagePath);
     96            if ( extension_loaded( 'gd' ) ) {
     97                return self::generateWithGd( $imagePath );
    10198            }
    10299
    103100            self::$lastError = 'No supported image library (Imagick or GD) found.';
    104101            return null;
    105         } catch (\Throwable $e) {
     102        } catch ( \Throwable $e ) {
    106103            // Log error but don't block conversion
    107104            self::$lastError = $e->getMessage();
    108105
    109             if (defined('WP_DEBUG') && WP_DEBUG) {
     106            if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
    110107                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
    111                 error_log('ThumbHash generation failed for ' . $imagePath . ': ' . $e->getMessage());
    112             }
    113 
    114             if (class_exists(Logger::class)) {
    115                 (new Logger())->addLog(
     108                error_log( 'ThumbHash generation failed for ' . $imagePath . ': ' . $e->getMessage() );
     109            }
     110
     111            if ( class_exists( Logger::class ) ) {
     112                ( new Logger() )->addLog(
    116113                    'error',
    117114                    'ThumbHash generation exception',
    118115                    array(
    119                         'path' => $imagePath,
     116                        'path'  => $imagePath,
    120117                        'error' => $e->getMessage(),
    121118                    )
     
    135132     * Get the last error that occurred during generation.
    136133     */
    137     public static function getLastError(): ?string
    138     {
     134    public static function getLastError(): ?string {
    139135        return self::$lastError;
    140136    }
     
    143139     * Generate ThumbHash using Imagick.
    144140     */
    145     private static function generateWithImagick(string $imagePath): ?string
    146     {
     141    private static function generateWithImagick( string $imagePath ): ?string {
    147142        $imagick = new \Imagick();
    148143        // Optimization: Hint to libjpeg to load a smaller version (downscale) to save memory.
     
    150145        // sufficient source data for smooth downscaling while still significantly reducing memory for large JPEGs.
    151146        try {
    152             $imagick->setOption('jpeg:size', '200x200');
    153         } catch (\Throwable $e) {
     147            $imagick->setOption( 'jpeg:size', '200x200' );
     148        } catch ( \Throwable $e ) {
    154149            // Ignore if setOption fails (e.g. older ImageMagick versions)
    155150        }
    156         $imagick->readImage($imagePath);
     151        $imagick->readImage( $imagePath );
    157152
    158153        // Get original dimensions
    159         $width = $imagick->getImageWidth();
     154        $width  = $imagick->getImageWidth();
    160155        $height = $imagick->getImageHeight();
    161156
    162157        // Calculate thumbnail dimensions maintaining aspect ratio
    163         if ($width > self::getMaxDimension() || $height > self::getMaxDimension()) {
    164             if ($width >= $height) {
    165                 $newWidth = self::getMaxDimension();
    166                 $newHeight = (int) round($height * (self::getMaxDimension() / $width));
     158        if ( $width > self::getMaxDimension() || $height > self::getMaxDimension() ) {
     159            if ( $width >= $height ) {
     160                $newWidth  = self::getMaxDimension();
     161                $newHeight = (int) round( $height * ( self::getMaxDimension() / $width ) );
    167162            } else {
    168163                $newHeight = self::getMaxDimension();
    169                 $newWidth = (int) round($width * (self::getMaxDimension() / $height));
     164                $newWidth  = (int) round( $width * ( self::getMaxDimension() / $height ) );
    170165            }
    171166            // Ensure minimum of 1px
    172             $newWidth = max(1, $newWidth);
    173             $newHeight = max(1, $newHeight);
    174             $imagick->thumbnailImage($newWidth, $newHeight);
     167            $newWidth  = max( 1, $newWidth );
     168            $newHeight = max( 1, $newHeight );
     169            $imagick->thumbnailImage( $newWidth, $newHeight );
    175170        } else {
    176             $newWidth = $width;
     171            $newWidth  = $width;
     172            $newHeight = $height;
     173        }
     174
     175        // Extract RGBA pixels
     176        $pixels   = array();
     177        $iterator = $imagick->getPixelIterator();
     178
     179        foreach ( $iterator as $row ) {
     180            foreach ( $row as $pixel ) {
     181                /** @var \ImagickPixel $pixel */
     182                // Use getColorValue() for compatibility across Imagick versions
     183                $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_RED ) * 255 );
     184                $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_GREEN ) * 255 );
     185                $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_BLUE ) * 255 );
     186                $pixels[] = (int) round( $pixel->getColorValue( \Imagick::COLOR_ALPHA ) * 255 );
     187            }
     188            $iterator->syncIterator();
     189        }
     190
     191        $imagick->destroy();
     192
     193        // Generate hash
     194        $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels );
     195
     196        return ThumbhashLib::convertHashToString( $hash );
     197    }
     198
     199    /**
     200     * Generate ThumbHash using GD.
     201     */
     202    private static function generateWithGd( string $imagePath ): ?string {
     203        $imageInfo = @getimagesize( $imagePath );
     204        if ( ! $imageInfo ) {
     205            return null;
     206        }
     207
     208        $mimeType = $imageInfo['mime'] ?? '';
     209        $image    = match ( $mimeType ) {
     210            'image/jpeg', 'image/jpg' => @imagecreatefromjpeg( $imagePath ),
     211            'image/png' => @imagecreatefrompng( $imagePath ),
     212            'image/gif' => @imagecreatefromgif( $imagePath ),
     213            'image/webp' => @imagecreatefromwebp( $imagePath ),
     214            default => false,
     215        };
     216
     217        if ( ! $image ) {
     218            return null;
     219        }
     220
     221        $width  = imagesx( $image );
     222        $height = imagesy( $image );
     223
     224        // Calculate thumbnail dimensions maintaining aspect ratio
     225        if ( $width > self::getMaxDimension() || $height > self::getMaxDimension() ) {
     226            if ( $width >= $height ) {
     227                $newWidth  = self::getMaxDimension();
     228                $newHeight = (int) round( $height * ( self::getMaxDimension() / $width ) );
     229            } else {
     230                $newHeight = self::getMaxDimension();
     231                $newWidth  = (int) round( $width * ( self::getMaxDimension() / $height ) );
     232            }
     233            $newWidth  = max( 1, $newWidth );
     234            $newHeight = max( 1, $newHeight );
     235
     236            $resized = imagecreatetruecolor( $newWidth, $newHeight );
     237            if ( ! $resized ) {
     238                return null;
     239            }
     240
     241            // Preserve alpha channel
     242            imagealphablending( $resized, false );
     243            imagesavealpha( $resized, true );
     244
     245            imagecopyresampled( $resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height );
     246            $image = $resized;
     247        } else {
     248            $newWidth  = $width;
    177249            $newHeight = $height;
    178250        }
     
    180252        // Extract RGBA pixels
    181253        $pixels = array();
    182         $iterator = $imagick->getPixelIterator();
    183 
    184         foreach ($iterator as $row) {
    185             foreach ($row as $pixel) {
    186                 /** @var \ImagickPixel $pixel */
    187                 // Use getColorValue() for compatibility across Imagick versions
    188                 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_RED) * 255);
    189                 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_GREEN) * 255);
    190                 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_BLUE) * 255);
    191                 $pixels[] = (int) round($pixel->getColorValue(\Imagick::COLOR_ALPHA) * 255);
    192             }
    193             $iterator->syncIterator();
    194         }
    195 
    196         $imagick->destroy();
    197 
    198         // Generate hash
    199         $hash = ThumbhashLib::RGBAToHash($newWidth, $newHeight, $pixels);
    200 
    201         return ThumbhashLib::convertHashToString($hash);
    202     }
    203 
    204     /**
    205      * Generate ThumbHash using GD.
    206      */
    207     private static function generateWithGd(string $imagePath): ?string
    208     {
    209         $imageInfo = @getimagesize($imagePath);
    210         if (!$imageInfo) {
    211             return null;
    212         }
    213 
    214         $mimeType = $imageInfo['mime'] ?? '';
    215         $image = match ($mimeType) {
    216             'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($imagePath),
    217             'image/png' => @imagecreatefrompng($imagePath),
    218             'image/gif' => @imagecreatefromgif($imagePath),
    219             'image/webp' => @imagecreatefromwebp($imagePath),
    220             default => false,
    221         };
    222 
    223         if (!$image) {
    224             return null;
    225         }
    226 
    227         $width = imagesx($image);
    228         $height = imagesy($image);
    229 
    230         // Calculate thumbnail dimensions maintaining aspect ratio
    231         if ($width > self::getMaxDimension() || $height > self::getMaxDimension()) {
    232             if ($width >= $height) {
    233                 $newWidth = self::getMaxDimension();
    234                 $newHeight = (int) round($height * (self::getMaxDimension() / $width));
    235             } else {
    236                 $newHeight = self::getMaxDimension();
    237                 $newWidth = (int) round($width * (self::getMaxDimension() / $height));
    238             }
    239             $newWidth = max(1, $newWidth);
    240             $newHeight = max(1, $newHeight);
    241 
    242             $resized = imagecreatetruecolor($newWidth, $newHeight);
    243             if (!$resized) {
    244                 return null;
    245             }
    246 
    247             // Preserve alpha channel
    248             imagealphablending($resized, false);
    249             imagesavealpha($resized, true);
    250 
    251             imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
    252             $image = $resized;
    253         } else {
    254             $newWidth = $width;
    255             $newHeight = $height;
    256         }
    257 
    258         // Extract RGBA pixels
    259         $pixels = array();
    260         for ($y = 0; $y < $newHeight; $y++) {
    261             for ($x = 0; $x < $newWidth; $x++) {
    262                 $rgba = imagecolorat($image, $x, $y);
    263                 $pixels[] = ($rgba >> 16) & 0xFF; // R
    264                 $pixels[] = ($rgba >> 8) & 0xFF;  // G
     254        for ( $y = 0; $y < $newHeight; $y++ ) {
     255            for ( $x = 0; $x < $newWidth; $x++ ) {
     256                $rgba     = imagecolorat( $image, $x, $y );
     257                $pixels[] = ( $rgba >> 16 ) & 0xFF; // R
     258                $pixels[] = ( $rgba >> 8 ) & 0xFF;  // G
    265259                $pixels[] = $rgba & 0xFF;          // B
    266260                // GD alpha: 127 = transparent, 0 = opaque (invert for ThumbHash)
    267                 $alpha = ($rgba >> 24) & 0x7F;
    268                 $pixels[] = (int) round((127 - $alpha) * (255 / 127));
    269             }
    270         }
    271 
    272 
     261                $alpha    = ( $rgba >> 24 ) & 0x7F;
     262                $pixels[] = (int) round( ( 127 - $alpha ) * ( 255 / 127 ) );
     263            }
     264        }
    273265
    274266        // Generate hash
    275         $hash = ThumbhashLib::RGBAToHash($newWidth, $newHeight, $pixels);
    276 
    277         return ThumbhashLib::convertHashToString($hash);
     267        $hash = ThumbhashLib::RGBAToHash( $newWidth, $newHeight, $pixels );
     268
     269        return ThumbhashLib::convertHashToString( $hash );
    278270    }
    279271
     
    285277     * @return string|null Base64-encoded ThumbHash or null if not available.
    286278     */
    287     public static function getForAttachment(int $attachmentId, string $size = 'full'): ?string
    288     {
    289         if (!self::isEnabled()) {
    290             return null;
    291         }
    292 
    293         $meta = \get_post_meta($attachmentId, self::META_KEY, true);
    294         if (!is_array($meta)) {
    295             return null;
    296         }
    297 
    298         return $meta[$size] ?? $meta['full'] ?? null;
     279    public static function getForAttachment( int $attachmentId, string $size = 'full' ): ?string {
     280        if ( ! self::isEnabled() ) {
     281            return null;
     282        }
     283
     284        $meta = \get_post_meta( $attachmentId, self::META_KEY, true );
     285        if ( ! is_array( $meta ) ) {
     286            return null;
     287        }
     288
     289        return $meta[ $size ] ?? $meta['full'] ?? null;
    299290    }
    300291
     
    305296     * @return array<string, string>|null Hash array keyed by size name, or null on failure.
    306297     */
    307     public static function generateForAttachment(int $attachmentId): ?array
    308     {
    309         if (!self::isEnabled()) {
    310             return null;
    311         }
    312 
    313         return self::doGenerateForAttachment($attachmentId);
     298    public static function generateForAttachment( int $attachmentId ): ?array {
     299        if ( ! self::isEnabled() ) {
     300            return null;
     301        }
     302
     303        return self::doGenerateForAttachment( $attachmentId );
    314304    }
    315305
     
    319309     * @param int $attachmentId WordPress attachment ID.
    320310     */
    321     public static function deleteForAttachment(int $attachmentId): void
    322     {
    323         \delete_post_meta($attachmentId, self::META_KEY);
     311    public static function deleteForAttachment( int $attachmentId ): void {
     312        \delete_post_meta( $attachmentId, self::META_KEY );
    324313    }
    325314
     
    328317     * Used by uninstall.php for cleanup.
    329318     */
    330     public static function getMetaKey(): string
    331     {
     319    public static function getMetaKey(): string {
    332320        return self::META_KEY;
    333321    }
     
    339327     * @return array{generated: int, skipped: int, failed: int}
    340328     */
    341     public static function generateAll(bool $force = false): array
    342     {
     329    public static function generateAll( bool $force = false ): array {
    343330        $result = array(
    344331            'generated' => 0,
    345             'skipped' => 0,
    346             'failed' => 0,
     332            'skipped'   => 0,
     333            'failed'    => 0,
    347334        );
    348335
    349336        $query = new \WP_Query(
    350337            array(
    351                 'post_type' => 'attachment',
    352                 'post_mime_type' => array('image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'),
    353                 'post_status' => 'inherit',
    354                 'posts_per_page' => -1,
    355                 'fields' => 'ids',
    356                 'no_found_rows' => true,
     338                'post_type'              => 'attachment',
     339                'post_mime_type'         => array( 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp' ),
     340                'post_status'            => 'inherit',
     341                'posts_per_page'         => -1,
     342                'fields'                 => 'ids',
     343                'no_found_rows'          => true,
    357344                'update_post_meta_cache' => false, // Avoid potential caching issues
    358345                'update_post_term_cache' => false,
     
    360347        );
    361348
    362         $logger = class_exists(Logger::class) ? new Logger() : null;
    363 
    364         foreach ($query->posts as $attachmentId) {
     349        $logger = class_exists( Logger::class ) ? new Logger() : null;
     350
     351        foreach ( $query->posts as $attachmentId ) {
    365352            // Clear object cache for this post to ensure fresh meta data
    366353            // This prevents stale data from persistent object caching (Redis/Memcached)
    367             \clean_post_cache((int) $attachmentId);
     354            \clean_post_cache( (int) $attachmentId );
    368355
    369356            // Skip if already has valid ThumbHash (unless forcing regeneration)
    370             if (!$force) {
    371                 $existing = \get_post_meta($attachmentId, self::META_KEY, true);
     357            if ( ! $force ) {
     358                $existing = \get_post_meta( $attachmentId, self::META_KEY, true );
    372359                // Verify it's a valid ThumbHash array with at least a 'full' entry
    373                 if (is_array($existing) && isset($existing['full']) && is_string($existing['full']) && strlen($existing['full']) > 10) {
     360                if ( is_array( $existing ) && isset( $existing['full'] ) && is_string( $existing['full'] ) && strlen( $existing['full'] ) > 10 ) {
    374361                    ++$result['skipped'];
    375362                    // Log individual skip
    376                     if ($logger) {
     363                    if ( $logger ) {
    377364                        $logger->addLog(
    378365                            'info',
    379                             sprintf('LQIP skipped for attachment ID %d (already exists)', $attachmentId),
    380                             array('attachment_id' => $attachmentId)
     366                            sprintf( 'LQIP skipped for attachment ID %d (already exists)', $attachmentId ),
     367                            array( 'attachment_id' => $attachmentId )
    381368                        );
    382369                    }
     
    386373
    387374            // Use private helper to generate (bypasses isEnabled check for bulk operations)
    388             $hashes = self::doGenerateForAttachment((int) $attachmentId);
    389 
    390             if (is_array($hashes) && !empty($hashes['full'])) {
     375            $hashes = self::doGenerateForAttachment( (int) $attachmentId );
     376
     377            if ( is_array( $hashes ) && ! empty( $hashes['full'] ) ) {
    391378                ++$result['generated'];
    392379                // Log individual success
    393                 if ($logger) {
     380                if ( $logger ) {
    394381                    $logger->addLog(
    395382                        'success',
    396                         sprintf('LQIP generated for attachment ID %d', $attachmentId),
     383                        sprintf( 'LQIP generated for attachment ID %d', $attachmentId ),
    397384                        array(
    398                             'attachment_id' => $attachmentId,
    399                             'sizes_generated' => count($hashes),
     385                            'attachment_id'   => $attachmentId,
     386                            'sizes_generated' => count( $hashes ),
    400387                        )
    401388                    );
     
    404391                ++$result['failed'];
    405392                // Capture the last error for debugging
    406                 if (!isset($result['last_error']) && self::$lastError) {
     393                if ( ! isset( $result['last_error'] ) && self::$lastError ) {
    407394                    $result['last_error'] = self::$lastError;
    408395                }
    409396                // Log individual failure
    410                 if ($logger) {
     397                if ( $logger ) {
    411398                    $logger->addLog(
    412399                        'error',
    413                         sprintf('LQIP generation failed for attachment ID %d', $attachmentId),
     400                        sprintf( 'LQIP generation failed for attachment ID %d', $attachmentId ),
    414401                        array(
    415402                            'attachment_id' => $attachmentId,
    416                             'error' => self::$lastError ?? 'Unknown error',
     403                            'error'         => self::$lastError ?? 'Unknown error',
    417404                        )
    418405                    );
     
    422409
    423410        // Log summary of bulk operation
    424         if ($logger && ($result['generated'] > 0 || $result['failed'] > 0 || $result['skipped'] > 0)) {
     411        if ( $logger && ( $result['generated'] > 0 || $result['failed'] > 0 || $result['skipped'] > 0 ) ) {
    425412            $logger->addLog(
    426413                $result['failed'] > 0 ? 'warning' : 'success',
     
    433420                array(
    434421                    'generated' => $result['generated'],
    435                     'skipped' => $result['skipped'],
    436                     'failed' => $result['failed'],
     422                    'skipped'   => $result['skipped'],
     423                    'failed'    => $result['failed'],
    437424                )
    438425            );
     
    449436     * @return array<string, string>|null Hash array or null on failure.
    450437     */
    451     private static function doGenerateForAttachment(int $attachmentId): ?array
    452     {
    453         $metadata = \wp_get_attachment_metadata($attachmentId);
    454         if (!is_array($metadata) || empty($metadata['file'])) {
     438    private static function doGenerateForAttachment( int $attachmentId ): ?array {
     439        $metadata = \wp_get_attachment_metadata( $attachmentId );
     440        if ( ! is_array( $metadata ) || empty( $metadata['file'] ) ) {
    455441            self::$lastError = "Invalid or missing metadata for attachment $attachmentId";
    456442            return null;
     
    458444
    459445        $uploadDir = \wp_upload_dir();
    460         $baseDir = $uploadDir['basedir'] ?? '';
    461         if (!$baseDir) {
     446        $baseDir   = $uploadDir['basedir'] ?? '';
     447        if ( ! $baseDir ) {
    462448            self::$lastError = 'Upload basedir not found.';
    463449            return null;
    464450        }
    465451
    466         $hashes = array();
    467         $file = $metadata['file'];
    468         $fileDir = dirname($file);
     452        $hashes  = array();
     453        $file    = $metadata['file'];
     454        $fileDir = dirname( $file );
    469455
    470456        // Generate for original/full image
    471457        $fullPath = $baseDir . '/' . $file;
    472458        // Some setups have 'file' as absolute path (rare but possible in offload plugins)
    473         if (!file_exists($fullPath)) {
    474             if (file_exists($file)) {
     459        if ( ! file_exists( $fullPath ) ) {
     460            if ( file_exists( $file ) ) {
    475461                $fullPath = $file;
    476462            } else {
    477463                self::$lastError = "File not found: $fullPath";
    478                 if (class_exists(Logger::class)) {
    479                     (new Logger())->addLog('warning', "ThumbHash skipped: Source file missing for ID $attachmentId", array('path' => $fullPath));
     464                if ( class_exists( Logger::class ) ) {
     465                    ( new Logger() )->addLog( 'warning', "ThumbHash skipped: Source file missing for ID $attachmentId", array( 'path' => $fullPath ) );
    480466                }
    481467            }
    482468        }
    483469
    484         if (file_exists($fullPath)) {
    485             $hash = self::generate($fullPath);
    486             if ($hash) {
     470        if ( file_exists( $fullPath ) ) {
     471            $hash = self::generate( $fullPath );
     472            if ( $hash ) {
    487473                $hashes['full'] = $hash;
    488474            }
     
    491477        // Generate for each registered size
    492478        $sizes = $metadata['sizes'] ?? array();
    493         foreach ($sizes as $sizeName => $sizeData) {
     479        foreach ( $sizes as $sizeName => $sizeData ) {
    494480            $sizeFile = $sizeData['file'] ?? '';
    495             if (!$sizeFile) {
     481            if ( ! $sizeFile ) {
    496482                continue;
    497483            }
    498484
    499485            $sizePath = $baseDir . '/' . $fileDir . '/' . $sizeFile;
    500             if (file_exists($sizePath)) {
    501                 $hash = self::generate($sizePath);
    502                 if ($hash) {
    503                     $hashes[$sizeName] = $hash;
     486            if ( file_exists( $sizePath ) ) {
     487                $hash = self::generate( $sizePath );
     488                if ( $hash ) {
     489                    $hashes[ $sizeName ] = $hash;
    504490                }
    505491            }
    506492        }
    507493
    508         if (!empty($hashes)) {
    509             \update_post_meta($attachmentId, self::META_KEY, $hashes);
     494        if ( ! empty( $hashes ) ) {
     495            \update_post_meta( $attachmentId, self::META_KEY, $hashes );
    510496            return $hashes;
    511497        }
     
    519505     * @return int Number of meta entries deleted.
    520506     */
    521     public static function deleteAll(): int
    522     {
     507    public static function deleteAll(): int {
    523508        global $wpdb;
    524509
     
    532517        );
    533518
    534         if (empty($postIds)) {
     519        if ( empty( $postIds ) ) {
    535520            return 0;
    536521        }
     
    546531
    547532        // Clear object cache for affected posts to prevent stale data in get_post_meta
    548         foreach ($postIds as $postId) {
    549             \clean_post_cache((int) $postId);
     533        foreach ( $postIds as $postId ) {
     534            \clean_post_cache( (int) $postId );
    550535        }
    551536
    552537        // Log the deletion
    553         if (class_exists(Logger::class)) {
    554             (new Logger())->addLog(
     538        if ( class_exists( Logger::class ) ) {
     539            ( new Logger() )->addLog(
    555540                'info',
    556                 sprintf('LQIP bulk delete: %d entries deleted', $deleted),
    557                 array('deleted' => (int) $deleted)
     541                sprintf( 'LQIP bulk delete: %d entries deleted', $deleted ),
     542                array( 'deleted' => (int) $deleted )
    558543            );
    559544        }
     
    570555     * @return array{with_hash: int, without_hash: int, total: int}
    571556     */
    572     public static function getStats(): array
    573     {
     557    public static function getStats(): array {
    574558        global $wpdb;
    575559
     
    590574        );
    591575
    592         $withHash = 0;
     576        $withHash    = 0;
    593577        $seenPostIds = array();
    594578
    595         foreach ($rows as $row) {
     579        foreach ( $rows as $row ) {
    596580            // Skip if we've already counted this post
    597             if (isset($seenPostIds[$row->post_id])) {
     581            if ( isset( $seenPostIds[ $row->post_id ] ) ) {
    598582                continue;
    599583            }
    600584
    601585            // Validate the structure matches what generateAll() skip logic expects
    602             $meta = maybe_unserialize($row->meta_value);
    603             if (is_array($meta) && isset($meta['full']) && is_string($meta['full']) && strlen($meta['full']) > 10) {
     586            $meta = maybe_unserialize( $row->meta_value );
     587            if ( is_array( $meta ) && isset( $meta['full'] ) && is_string( $meta['full'] ) && strlen( $meta['full'] ) > 10 ) {
    604588                ++$withHash;
    605                 $seenPostIds[$row->post_id] = true;
     589                $seenPostIds[ $row->post_id ] = true;
    606590            }
    607591        }
    608592
    609593        return array(
    610             'with_hash' => $withHash,
     594            'with_hash'    => $withHash,
    611595            'without_hash' => $total - $withHash,
    612             'total' => $total,
     596            'total'        => $total,
    613597        );
    614598    }
  • avif-local-support/trunk/includes/class-support.php

    r3432774 r3444709  
    66
    77// Prevent direct access.
    8 \defined('ABSPATH') || exit;
    9 
    10 final class Support
    11 {
    12 
    13 
    14 
    15 
    16     private array $fileCache = array();
     8\defined( 'ABSPATH' ) || exit;
     9
     10final class Support {
     11
     12
     13
     14
     15
     16    private array $fileCache   = array();
    1717    private array $uploadsInfo = array();
    1818
    19     public function init(): void
    20     {
     19    public function init(): void {
     20
    2121        $this->uploadsInfo = \wp_upload_dir();
    22         $this->fileCache = \get_transient('aviflosu_file_cache') ?: array();
    23         add_filter('wp_get_attachment_image', array($this, 'wrapAttachment'), 10, 5);
    24         add_filter('the_content', array($this, 'wrapContentImages'));
    25         add_filter('post_thumbnail_html', array($this, 'wrapContentImages'));
    26         add_filter('render_block', array($this, 'renderBlock'), 10, 2);
    27         add_action('shutdown', array($this, 'saveCache'));
     22        $this->fileCache   = \get_transient( 'aviflosu_file_cache' ) ?: array();
     23        add_filter( 'wp_get_attachment_image', array( $this, 'wrapAttachment' ), 10, 5 );
     24        // Priority 15 runs AFTER WordPress adds srcset via wp_filter_content_tags at priority 10.
     25        add_filter( 'the_content', array( $this, 'wrapContentImages' ), 15 );
     26        add_filter( 'post_thumbnail_html', array( $this, 'wrapContentImages' ), 15 );
     27        add_action( 'shutdown', array( $this, 'saveCache' ) );
    2828
    2929        // Enqueue ThumbHash decoder if enabled - inline in head for early execution.
    30         if (ThumbHash::isEnabled() && !\is_admin()) {
    31             add_action('wp_head', array($this, 'inlineThumbHashDecoder'), 1);
     30        if ( ThumbHash::isEnabled() && ! \is_admin() ) {
     31            add_action( 'wp_head', array( $this, 'inlineThumbHashDecoder' ), 1 );
    3232        }
    3333    }
     
    3737     * This ensures placeholders appear before images start loading.
    3838     */
    39     public function inlineThumbHashDecoder(): void
    40     {
     39    public function inlineThumbHashDecoder(): void {
    4140        $scriptPath = AVIFLOSU_PLUGIN_DIR . 'assets/thumbhash-decoder.min.js';
    42         if (!file_exists($scriptPath)) {
     41        if ( ! file_exists( $scriptPath ) ) {
    4342            return;
    4443        }
    45         $script = file_get_contents($scriptPath);
    46         if ($script) {
     44        $script = file_get_contents( $scriptPath );
     45        if ( $script ) {
    4746            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Inline JS from trusted local file.
    4847            echo '<script id="aviflosu-thumbhash-decoder">' . $script . '</script>' . "\n";
     
    5049
    5150        // Inject CSS for fading if enabled.
    52         if ((bool) get_option('aviflosu_lqip_fade', true)) {
     51        if ( (bool) get_option( 'aviflosu_lqip_fade', true ) ) {
    5352            // CSS explanation:
    5453            // 1. img[data-thumbhash] starts visible (opacity 1) with a transition.
     
    6059                . 'img.thumbhash-loading[data-thumbhash]{opacity:0;}';
    6160            // Optionally render placeholders as sharp pixels instead of smooth blur.
    62             if ((bool) get_option('aviflosu_lqip_pixelated', false)) {
     61            if ( (bool) get_option( 'aviflosu_lqip_pixelated', false ) ) {
    6362                $css .= '.thumbhash-loading,img.thumbhash-loading{image-rendering:pixelated;}';
    6463            }
     
    6867    }
    6968
    70     public function wrapAttachment(string $html, int $attachmentId, $size, bool $icon, array $attr): string
    71     {
    72         if (str_contains($html, '<picture')) {
     69    public function wrapAttachment( string $html, int $attachmentId, $size, bool $icon, array $attr ): string {
     70        if ( str_contains( $html, '<picture' ) ) {
    7371            return $html;
    7472        }
    7573
    76         $mime = \get_post_mime_type($attachmentId);
    77         if (!\is_string($mime) || !\in_array($mime, array('image/jpeg', 'image/jpg'), true)) {
     74        $mime = \get_post_mime_type( $attachmentId );
     75        if ( ! \is_string( $mime ) || ! \in_array( $mime, array( 'image/jpeg', 'image/jpg' ), true ) ) {
    7876            return $html;
    7977        }
    8078
    81         $imageSrc = \wp_get_attachment_image_src($attachmentId, $size);
    82         if (!$imageSrc || !\is_array($imageSrc) || empty($imageSrc[0])) {
     79        $imageSrc = \wp_get_attachment_image_src( $attachmentId, $size );
     80        if ( ! $imageSrc || ! \is_array( $imageSrc ) || empty( $imageSrc[0] ) ) {
    8381            return $html;
    8482        }
    8583
    86         $avifSrc = $this->avifUrlFor($imageSrc[0]);
    87 
    88         $srcset = \wp_get_attachment_image_srcset($attachmentId, $size);
    89         $avifSrcset = ($srcset && $avifSrc) ? $this->convertSrcsetToAvif($srcset) : '';
    90         $sizes = \wp_get_attachment_image_sizes($attachmentId, $size) ?: '';
     84        $avifSrc = $this->avifUrlFor( $imageSrc[0] );
     85
     86        $srcset     = \wp_get_attachment_image_srcset( $attachmentId, $size );
     87        $avifSrcset = $srcset ? $this->convertSrcsetToAvif( $srcset ) : '';
     88
     89        // Ensure single AVIF candidates have width descriptors for responsive selection.
     90        if ( '' === $avifSrcset && $avifSrc ) {
     91            $w          = (int) ( $imageSrc[1] ?? 0 );
     92            $avifSrcset = $w > 0 ? ( $avifSrc . ' ' . $w . 'w' ) : $avifSrc;
     93        }
     94
     95        $sizes = \wp_get_attachment_image_sizes( $attachmentId, $size ) ?: '';
    9196
    9297        // Get ThumbHash for LQIP if enabled.
    93         $sizeName = is_array($size) ? 'full' : (string) $size;
    94         $thumbhash = ThumbHash::getForAttachment($attachmentId, $sizeName);
    95 
    96         return $this->pictureMarkup($html, $avifSrc, $avifSrcset, $sizes, $thumbhash);
    97     }
    98 
    99     public function wrapContentImages(string $content): string
    100     {
    101         if (\is_admin() || \wp_doing_ajax() || (\defined('REST_REQUEST') && REST_REQUEST)) {
     98        $sizeName  = is_array( $size ) ? 'full' : (string) $size;
     99        $thumbhash = ThumbHash::getForAttachment( $attachmentId, $sizeName );
     100
     101        return $this->pictureMarkup( $html, $avifSrc, $avifSrcset, $sizes, $thumbhash );
     102    }
     103
     104    public function wrapContentImages( string $content ): string {
     105        if ( \is_admin() || \wp_doing_ajax() || ( \defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
    102106            return $content;
    103107        }
    104         if (!str_contains($content, '<img')) {
     108        if ( ! str_contains( $content, '<img' ) ) {
    105109            return $content;
    106110        }
    107         return $this->wrapHtmlImages($content);
    108     }
    109 
    110     public function renderBlock(string $block_content, array $block): string
    111     {
    112         $name = $block['blockName'] ?? '';
    113         if ('core/image' !== $name && 'core/gallery' !== $name) {
    114             return $block_content;
    115         }
    116         if ('' === $block_content || false === strpos($block_content, '<img')) {
    117             return $block_content;
    118         }
    119         return $this->wrapHtmlImages($block_content);
    120     }
    121 
    122     private function avifUrlFor(string $jpegUrl): ?string
    123     {
    124         if (!$this->isUploadsImage($jpegUrl)) {
    125             return null;
    126         }
    127         $parts = \wp_parse_url($jpegUrl);
    128         if (false === $parts || empty($parts['path'])) {
     111        return $this->wrapHtmlImages( $content );
     112    }
     113
     114    private function avifUrlFor( string $jpegUrl ): ?string {
     115        if ( ! $this->isUploadsImage( $jpegUrl ) ) {
     116            return null;
     117        }
     118        $parts = \wp_parse_url( $jpegUrl );
     119        if ( false === $parts || empty( $parts['path'] ) ) {
    129120            return null;
    130121        }
    131122        $path = $parts['path'];
    132         if (!\preg_match('/\.(jpe?g)$/i', $path)) {
    133             return null;
    134         }
    135         $avifPath = (string) \preg_replace('/\.(jpe?g)$/i', '.avif', $path);
    136         $reconstructed = ($parts['scheme'] ?? '') !== ''
    137             ? ($parts['scheme'] . '://')
    138             : '';
    139         if (!empty($parts['host'])) {
    140             $reconstructed .= $parts['host'];
    141         }
    142         if (!empty($parts['port'])) {
    143             $reconstructed .= ':' . $parts['port'];
    144         }
    145         $reconstructed .= $avifPath;
    146         if (!empty($parts['query'])) {
    147             $reconstructed .= '?' . $parts['query'];
    148         }
    149         if (!empty($parts['fragment'])) {
    150             $reconstructed .= '#' . $parts['fragment'];
    151         }
    152 
    153         $relative = str_replace($this->uploadsInfo['baseurl'] ?? '', '', (string) $reconstructed);
    154         $avifLocal = ($this->uploadsInfo['basedir'] ?? '') . $relative;
    155         return $this->avifExists($avifLocal) ? $reconstructed : null;
    156     }
    157 
    158     private function isUploadsImage(string $src): bool
    159     {
     123        if ( ! \preg_match( '/\.(jpe?g)$/i', $path ) ) {
     124            return null;
     125        }
     126        $avifPath = (string) \preg_replace( '/\.(jpe?g)$/i', '.avif', $path );
     127
     128        // Build local path from path-only (no query/fragment) for file_exists check.
     129        $uploadsBasePath = wp_parse_url( $this->uploadsInfo['baseurl'] ?? '', PHP_URL_PATH ) ?: '';
     130        if ( ! str_starts_with( $avifPath, $uploadsBasePath ) ) {
     131            return null;
     132        }
     133        $avifRelative = substr( $avifPath, strlen( $uploadsBasePath ) );
     134        $avifLocal    = trailingslashit( $this->uploadsInfo['basedir'] ?? '' ) . ltrim( $avifRelative, '/' );
     135
     136        if ( ! $this->avifExists( $avifLocal ) ) {
     137            return null;
     138        }
     139
     140        // Reconstruct AVIF URL (preserve query string for cache-busting if present).
     141        $avifUrl  = ( $parts['scheme'] ?? '' ) !== '' ? ( $parts['scheme'] . '://' ) : '';
     142        $avifUrl .= $parts['host'] ?? '';
     143        $avifUrl .= ! empty( $parts['port'] ) ? ( ':' . $parts['port'] ) : '';
     144        $avifUrl .= $avifPath;
     145        if ( ! empty( $parts['query'] ) ) {
     146            $avifUrl .= '?' . $parts['query'];
     147        }
     148        return $avifUrl;
     149    }
     150
     151    private function isUploadsImage( string $src ): bool {
    160152        $uploadsUrl = $this->uploadsInfo['baseurl'] ?? '';
    161         return '' !== $uploadsUrl && str_starts_with($src, $uploadsUrl);
    162     }
    163 
    164     private function avifExists(string $filePath): bool
    165     {
     153        return '' !== $uploadsUrl && str_starts_with( $src, $uploadsUrl );
     154    }
     155
     156    private function avifExists( string $filePath ): bool {
    166157        // Only cache positive (true) results. Negative results should always be re-checked
    167158        // because files may be converted after the cache was populated.
    168         if (isset($this->fileCache[$filePath]) && true === $this->fileCache[$filePath]) {
     159        if ( isset( $this->fileCache[ $filePath ] ) && true === $this->fileCache[ $filePath ] ) {
    169160            return true;
    170161        }
    171         $exists = file_exists($filePath);
    172         if ($exists) {
    173             $this->fileCache[$filePath] = true;
     162        $exists = file_exists( $filePath );
     163        if ( $exists ) {
     164            $this->fileCache[ $filePath ] = true;
    174165        }
    175166        return $exists;
    176167    }
    177168
    178     private function convertSrcsetToAvif(string $srcset): string
    179     {
    180         $parts = array_map('trim', explode(',', $srcset));
    181         $out = array();
    182         foreach ($parts as $part) {
    183             if ('' === $part) {
     169    private function convertSrcsetToAvif( string $srcset ): string {
     170        $parts = array_map( 'trim', explode( ',', $srcset ) );
     171        $out   = array();
     172        foreach ( $parts as $part ) {
     173            if ( '' === $part ) {
    184174                continue;
    185175            }
    186             $pieces = preg_split('/\s+/', trim($part), 2);
    187             $url = $pieces[0];
     176            $pieces     = preg_split( '/\s+/', trim( $part ), 2 );
     177            $url        = $pieces[0];
    188178            $descriptor = $pieces[1] ?? '';
    189             $avifUrl = $this->avifUrlFor($url);
    190             if ($avifUrl) {
    191                 $out[] = trim($avifUrl . ' ' . $descriptor);
    192             }
    193         }
    194         return implode(', ', $out);
    195     }
    196 
    197     private function pictureMarkup(string $originalHtml, ?string $avifSrc, string $avifSrcset = '', string $sizes = '', ?string $thumbhash = null): string
    198     {
    199         if ((!$avifSrc || '' === $avifSrc) && (!$thumbhash || '' === $thumbhash)) {
     179            $avifUrl    = $this->avifUrlFor( $url );
     180            if ( $avifUrl ) {
     181                $out[] = trim( $avifUrl . ' ' . $descriptor );
     182            }
     183        }
     184        return implode( ', ', $out );
     185    }
     186
     187    private function pictureMarkup( string $originalHtml, ?string $avifSrc, string $avifSrcset = '', string $sizes = '', ?string $thumbhash = null ): string {
     188        if ( ( ! $avifSrc || '' === $avifSrc ) && ( ! $thumbhash || '' === $thumbhash ) ) {
    200189            return $originalHtml;
    201190        }
    202         $srcset = '' !== $avifSrcset ? $avifSrcset : $avifSrc;
    203         $sizesAttr = '' !== $sizes ? sprintf(' sizes="%s"', \esc_attr($sizes)) : '';
     191        $srcset    = '' !== $avifSrcset ? $avifSrcset : $avifSrc;
     192        $sizesAttr = '' !== $sizes ? sprintf( ' sizes="%s"', \esc_attr( $sizes ) ) : '';
    204193
    205194        // Add ThumbHash data attribute to img tag if available.
    206195        $imgHtml = $originalHtml;
    207         if ($thumbhash !== null && $thumbhash !== '') {
     196        if ( $thumbhash !== null && $thumbhash !== '' ) {
    208197            $imgHtml = preg_replace(
    209198                '/<img\s/',
    210                 '<img data-thumbhash="' . \esc_attr($thumbhash) . '" ',
     199                '<img data-thumbhash="' . \esc_attr( $thumbhash ) . '" ',
    211200                $originalHtml,
    212201                1
    213202            );
    214             if ($imgHtml === null) {
     203            if ( $imgHtml === null ) {
    215204                $imgHtml = $originalHtml;
    216205            }
    217206        }
    218207
    219         if (!$avifSrc || '' === $avifSrc) {
     208        if ( ! $avifSrc || '' === $avifSrc ) {
    220209            return $imgHtml;
    221210        }
    222211
    223212        // Only wrap in <picture> if AVIF serving is enabled.
    224         $avifEnabled = (bool) \get_option('aviflosu_enable_support', true);
    225         if (!$avifEnabled) {
     213        $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true );
     214        if ( ! $avifEnabled ) {
    226215            return $imgHtml;
    227216        }
    228217
    229         return sprintf('<picture><source type="image/avif" srcset="%s"%s>%s</picture>', \esc_attr($srcset), $sizesAttr, $imgHtml);
    230     }
    231 
    232     private function isInsidePicture(\DOMNode $node): bool
    233     {
     218        return sprintf( '<picture><source type="image/avif" srcset="%s"%s>%s</picture>', \esc_attr( $srcset ), $sizesAttr, $imgHtml );
     219    }
     220
     221    private function isInsidePicture( \DOMNode $node ): bool {
    234222        $parent = $node->parentNode;
    235         while ($parent) {
    236             if ($parent instanceof \DOMElement && 'picture' === strtolower($parent->nodeName)) {
     223        while ( $parent ) {
     224            if ( $parent instanceof \DOMElement && 'picture' === strtolower( $parent->nodeName ) ) {
    237225                return true;
    238226            }
     
    242230    }
    243231
    244     private function wrapImgNodeToPicture(\DOMDocument $dom, \DOMElement $img, string $avifSrcset, string $sizes, ?string $thumbhash = null): void
    245     {
     232    private function wrapImgNodeToPicture( \DOMDocument $dom, \DOMElement $img, string $avifSrcset, string $sizes, ?string $thumbhash = null ): void {
    246233        // Add ThumbHash data attribute if available
    247         if ($thumbhash !== null && $thumbhash !== '') {
    248             $img->setAttribute('data-thumbhash', $thumbhash);
    249         }
    250 
    251         if ('' === $avifSrcset) {
     234        if ( $thumbhash !== null && $thumbhash !== '' ) {
     235            $img->setAttribute( 'data-thumbhash', $thumbhash );
     236        }
     237
     238        if ( '' === $avifSrcset ) {
    252239            return;
    253240        }
    254241
    255242        // Only wrap in <picture> if AVIF serving is enabled.
    256         $avifEnabled = (bool) \get_option('aviflosu_enable_support', true);
    257         if (!$avifEnabled) {
     243        $avifEnabled = (bool) \get_option( 'aviflosu_enable_support', true );
     244        if ( ! $avifEnabled ) {
    258245            return;
    259246        }
    260247
    261         $picture = $dom->createElement('picture');
    262         $source = $dom->createElement('source');
    263         $source->setAttribute('type', 'image/avif');
    264         $source->setAttribute('srcset', $avifSrcset);
    265         if ('' !== $sizes) {
    266             $source->setAttribute('sizes', $sizes);
    267         }
    268         $picture->appendChild($source);
    269         $img->parentNode?->replaceChild($picture, $img);
    270         $picture->appendChild($img);
    271     }
    272 
    273     private function wrapHtmlImages(string $htmlInput): string
    274     {
     248        $picture = $dom->createElement( 'picture' );
     249        $source  = $dom->createElement( 'source' );
     250        $source->setAttribute( 'type', 'image/avif' );
     251        $source->setAttribute( 'srcset', $avifSrcset );
     252        if ( '' !== $sizes ) {
     253            $source->setAttribute( 'sizes', $sizes );
     254        }
     255        $picture->appendChild( $source );
     256        $img->parentNode?->replaceChild( $picture, $img );
     257        $picture->appendChild( $img );
     258    }
     259
     260    private function wrapHtmlImages( string $htmlInput ): string {
    275261        $html = '<?xml encoding="utf-8" ?>' . $htmlInput;
    276         $dom = new \DOMDocument();
    277         \libxml_use_internal_errors(true);
    278         if (!$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD)) {
     262        $dom  = new \DOMDocument();
     263        \libxml_use_internal_errors( true );
     264        if ( ! $dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ) ) {
    279265            \libxml_clear_errors();
    280266            return $htmlInput;
     
    282268        \libxml_clear_errors();
    283269
    284         $imgs = $dom->getElementsByTagName('img');
     270        $imgs      = $dom->getElementsByTagName( 'img' );
    285271        $toProcess = array();
    286         foreach ($imgs as $img) {
     272        foreach ( $imgs as $img ) {
    287273            $toProcess[] = $img;
    288274        }
    289         foreach ($toProcess as $img) {
    290             if (!($img instanceof \DOMElement)) {
     275        foreach ( $toProcess as $img ) {
     276            if ( ! ( $img instanceof \DOMElement ) ) {
    291277                continue;
    292278            }
    293             if ($this->isInsidePicture($img)) {
     279            if ( $this->isInsidePicture( $img ) ) {
    294280                continue;
    295281            }
    296             $src = (string) $img->getAttribute('src');
    297             $avifUrl = $this->avifUrlFor($src);
     282            $src     = (string) $img->getAttribute( 'src' );
     283            $avifUrl = $this->avifUrlFor( $src );
    298284
    299285            // Extract attachment ID from wp-image-{ID} class for ThumbHash lookup
    300286            $thumbhash = null;
    301             if (ThumbHash::isEnabled()) {
    302                 $class = (string) $img->getAttribute('class');
    303                 if (preg_match('/wp-image-(\d+)/', $class, $matches)) {
     287            if ( ThumbHash::isEnabled() ) {
     288                $class = (string) $img->getAttribute( 'class' );
     289                if ( preg_match( '/wp-image-(\d+)/', $class, $matches ) ) {
    304290                    $attachmentId = (int) $matches[1];
    305                     $thumbhash = ThumbHash::getForAttachment($attachmentId, 'full');
     291                    $thumbhash    = ThumbHash::getForAttachment( $attachmentId, 'full' );
    306292                }
    307293            }
    308294
    309             if (!$avifUrl && !$thumbhash) {
     295            if ( ! $avifUrl && ! $thumbhash ) {
    310296                continue;
    311297            }
     
    313299            // Check if the image is wrapped in a link to a JPEG that also has an AVIF version.
    314300            $parent = $img->parentNode;
    315             if ($parent instanceof \DOMElement && strtolower($parent->nodeName) === 'a') {
    316                 $href = (string) $parent->getAttribute('href');
     301            if ( $parent instanceof \DOMElement && strtolower( $parent->nodeName ) === 'a' ) {
     302                $href = (string) $parent->getAttribute( 'href' );
    317303                // Only process if href looks like a JPEG
    318                 if (preg_match('/\.(jpe?g)$/i', $href)) {
    319                     $avifHref = $this->avifUrlFor($href);
    320                     if ($avifHref) {
    321                         $parent->setAttribute('href', $avifHref);
     304                if ( preg_match( '/\.(jpe?g)$/i', $href ) ) {
     305                    $avifHref = $this->avifUrlFor( $href );
     306                    if ( $avifHref ) {
     307                        $parent->setAttribute( 'href', $avifHref );
    322308                    }
    323309                }
    324310            }
    325311
    326             $srcset = (string) $img->getAttribute('srcset');
    327             $sizes = (string) $img->getAttribute('sizes');
    328             $avifSrcset = ('' !== $srcset && $avifUrl) ? $this->convertSrcsetToAvif($srcset) : ($avifUrl ?: '');
    329 
    330             $this->wrapImgNodeToPicture($dom, $img, $avifSrcset, $sizes, $thumbhash);
     312            $srcset     = (string) $img->getAttribute( 'srcset' );
     313            $sizes      = (string) $img->getAttribute( 'sizes' );
     314            $avifSrcset = ( '' !== $srcset ) ? $this->convertSrcsetToAvif( $srcset ) : ( $avifUrl ?: '' );
     315
     316            // Ensure single AVIF candidates have width descriptors for responsive selection.
     317            if ( '' === $avifSrcset && $avifUrl ) {
     318                $w          = (int) $img->getAttribute( 'width' );
     319                $avifSrcset = $w > 0 ? ( $avifUrl . ' ' . $w . 'w' ) : $avifUrl;
     320            }
     321
     322            $this->wrapImgNodeToPicture( $dom, $img, $avifSrcset, $sizes, $thumbhash );
    331323        }
    332324
    333325        // Cleanup: Remove the XML declaration node we added to force UTF-8
    334         while ($dom->firstChild instanceof \DOMProcessingInstruction && $dom->firstChild->nodeName === 'xml') {
    335             $dom->removeChild($dom->firstChild);
     326        while ( $dom->firstChild instanceof \DOMProcessingInstruction && $dom->firstChild->nodeName === 'xml' ) {
     327            $dom->removeChild( $dom->firstChild );
    336328        }
    337329
    338330        $out = $dom->saveHTML();
    339         return \is_string($out) && $out !== '' ? $out : $htmlInput;
    340     }
    341 
    342     public function saveCache(): void
    343     {
     331        return \is_string( $out ) && $out !== '' ? $out : $htmlInput;
     332    }
     333
     334    public function saveCache(): void {
    344335        // Only save positive (true) entries - filter out any false entries that might have snuck in.
    345         $positiveOnly = array_filter($this->fileCache, fn($v) => true === $v);
    346         set_transient('aviflosu_file_cache', $positiveOnly, (int) get_option('aviflosu_cache_duration', 3600));
     336        $positiveOnly = array_filter( $this->fileCache, fn( $v ) => true === $v );
     337        set_transient( 'aviflosu_file_cache', $positiveOnly, (int) get_option( 'aviflosu_cache_duration', 3600 ) );
    347338    }
    348339}
  • avif-local-support/trunk/readme.txt

    r3433195 r3444709  
    44Requires at least: 6.8
    55Tested up to: 6.9
    6 Stable tag: 0.5.20
     6Stable tag: 0.5.21
    77Requires PHP: 8.3
    88License: GPLv2 or later
     
    198198
    199199== Changelog ==
     200
     201= 0.5.21 =
     202
     203- Enhancement: ThumbHash input increased to 100px for richer LQIP placeholders.
     204- Fix: AVIF srcset now correctly lists all responsive sizes by running after WordPress adds srcset attributes.
     205- Fix: Single AVIF images now include width descriptors for proper responsive selection.
     206- Refactor: Removed redundant `render_block` filter; unified image processing in `the_content` filter.
    200207
    201208= 0.5.20 =
Note: See TracChangeset for help on using the changeset viewer.