* 2. Optionally rotate based on EXIF Orientation * 3. Re-encode with ICC preserved and 4:4:4 chroma (the bug fix) * 4. Pass through jpegoptim/pngquant for lossless tail * 5. Queue WebP + AVIF sibling generation (Format_Generator) * * @package H4B\ImageOptim */ namespace H4B\ImageOptim; use Imagick; use ImagickException; if ( ! defined( 'ABSPATH' ) ) { exit; } final class Optimizer { public const ORIGINALS_DIRNAME = 'h4b-img-originals'; public static function ensure_dirs(): void { $root = self::originals_root(); if ( ! is_dir( $root ) ) { wp_mkdir_p( $root ); } // Prevent any web-side access; originals are operations-only. $ht = $root . '/.htaccess'; if ( ! file_exists( $ht ) ) { file_put_contents( $ht, "Order allow,deny\nDeny from all\n" ); } } public static function originals_root(): string { $uploads = wp_get_upload_dir(); return trailingslashit( $uploads['basedir'] ) . self::ORIGINALS_DIRNAME; } /** * Optimise one file in place. * * @param string $path Absolute path to a JPEG or PNG file under uploads/. * @param array $opts Override settings (jpeg_quality, etc). * @return array{ * status:string, * path:string, * mime:string, * before:int, * after:int, * percent:float, * icc_preserved:bool, * backup_path:?string, * tool_chain:array, * error:?string * } */ public static function optimise( string $path, array $opts = [] ): array { $result = [ 'status' => 'pending', 'path' => $path, 'mime' => '', 'before' => 0, 'after' => 0, 'percent' => 0.0, 'icc_preserved' => false, 'backup_path' => null, 'tool_chain' => [], 'error' => null, ]; if ( ! is_readable( $path ) || ! is_writable( $path ) ) { $result['status'] = 'error'; $result['error'] = 'not_readable_or_writable'; return $result; } $settings = array_replace( Settings::all(), $opts ); $result['before'] = filesize( $path ); $mime = self::detect_mime( $path ); $result['mime'] = $mime; if ( ! in_array( $mime, [ 'image/jpeg', 'image/png' ], true ) ) { $result['status'] = 'skipped'; $result['error'] = 'unsupported_mime:' . $mime; return $result; } // Don't re-encode small thumbnails. JPEG q=85 + ICC profile on a tiny // file INCREASES size (2.5KB profile is huge relative to <20KB images). // At those dimensions humans can't see chroma artefacts anyway, so the // Smush-bug fix isn't relevant. WebP/AVIF siblings still get made. // 20KB threshold matches WordPress's typical thumbnail/medium sizes. $threshold = (int) ( $settings['min_optimise_bytes'] ?? 20480 ); if ( $result['before'] < $threshold ) { $result['status'] = 'skipped'; $result['error'] = 'too_small_keep_as_is'; $result['after'] = $result['before']; return $result; } // 1. Backup the original (atomic copy, only once per file) if ( ! empty( $settings['backup_originals'] ) ) { $backup = self::backup( $path ); if ( $backup === null ) { $result['status'] = 'error'; $result['error'] = 'backup_failed'; return $result; } $result['backup_path'] = $backup; } // 2. Re-encode via Imagick (handles ICC + orientation cleanly) try { $reencoded = self::reencode( $path, $mime, $settings ); } catch ( \Throwable $e ) { $result['status'] = 'error'; $result['error'] = 'reencode_failed: ' . $e->getMessage(); return $result; } $result['icc_preserved'] = $reencoded['icc_preserved']; $result['tool_chain'][] = 'imagick'; // 3. Lossless tail pass to squeeze the last few percent if ( $mime === 'image/jpeg' && Tools::path( 'jpegoptim' ) ) { self::jpegoptim_pass( $path, $settings ); $result['tool_chain'][] = 'jpegoptim'; } if ( $mime === 'image/png' && Tools::path( 'pngquant' ) ) { self::pngquant_pass( $path, $settings ); $result['tool_chain'][] = 'pngquant'; } clearstatcache( true, $path ); $result['after'] = filesize( $path ); $result['percent'] = $result['before'] > 0 ? round( ( $result['before'] - $result['after'] ) / $result['before'] * 100, 2 ) : 0.0; $result['status'] = 'done'; return $result; } /** * Re-encode through Imagick to enforce: ICC preserved, 4:4:4 chroma, * EXIF orientation applied, sRGB colorspace. * * @return array{icc_preserved:bool} */ private static function reencode( string $path, string $mime, array $settings ): array { $img = new Imagick( $path ); // Apply EXIF orientation so the raw pixels are upright, then strip orientation tag. try { $orient = $img->getImageOrientation(); switch ( $orient ) { case Imagick::ORIENTATION_BOTTOMRIGHT: $img->rotateImage( 'transparent', 180 ); break; case Imagick::ORIENTATION_RIGHTTOP: $img->rotateImage( 'transparent', 90 ); break; case Imagick::ORIENTATION_LEFTBOTTOM: $img->rotateImage( 'transparent', -90 ); break; } if ( $orient !== Imagick::ORIENTATION_TOPLEFT ) { $img->setImageOrientation( Imagick::ORIENTATION_TOPLEFT ); } } catch ( ImagickException $e ) { // Some images have no orientation tag — that's fine. } // Preserve ICC profile — the critical fix $icc = ICC_Profile::preserve_or_inject( $img ); $icc_ok = $icc['result']; $had_source_profile = $icc['source'] === 'original'; // Strip EXIF privacy fields but KEEP ICC profile. // Imagick stripImage() removes everything including ICC, so we don't call it. // Instead we remove specific profiles selectively. if ( ! empty( $settings['strip_gps_exif'] ) ) { try { $img->removeImageProfile( 'exif' ); } catch ( ImagickException $e ) {} // Note: this drops ALL exif including camera. For art use-case we // might want to re-add ICC. ICC is already attached above. } if ( $mime === 'image/jpeg' ) { $quality = (int) ( $settings['jpeg_quality'] ?? 85 ); // For small thumbnails, the grey-wash bug doesn't matter (browser // renders them too small to see ringing). Use the standard 4:2:0 // subsampling to keep file size reasonable. For larger images use // 4:4:4 (the bug-fix subsampling). Threshold: 400x400 = 160000 px. $width = $img->getImageWidth(); $height = $img->getImageHeight(); $is_small = ( $width * $height ) < 160000; $subsamp = $is_small ? '4:2:0' : ( $settings['jpeg_chroma_subsampling'] ?? '4:4:4' ); $img->setImageCompressionQuality( $quality ); $img->setSamplingFactors( self::sampling_factors( $subsamp ) ); $img->setInterlaceScheme( Imagick::INTERLACE_NO ); $img->setImageColorspace( Imagick::COLORSPACE_SRGB ); // Skip ICC injection on tiny files — the profile costs 2.5KB which // dwarfs the actual image bytes for thumbnails under 20KB if ( $is_small && empty( $had_source_profile ) ) { try { $img->removeImageProfile( 'icc' ); } catch ( ImagickException $e ) {} } } elseif ( $mime === 'image/png' ) { // Lossless PNG via Imagick — actual compression happens in pngquant pass. $img->setImageCompressionQuality( 95 ); } // Write back to the same path atomically $tmp = $path . '.h4b.tmp'; if ( $mime === 'image/jpeg' ) { $img->writeImage( $tmp ); } else { $img->writeImage( $tmp ); } $img->clear(); $img->destroy(); // Preserve original ownership and mtime $stat = stat( $path ); if ( $stat !== false ) { @chown( $tmp, $stat['uid'] ); @chgrp( $tmp, $stat['gid'] ); @chmod( $tmp, $stat['mode'] & 0777 ); } rename( $tmp, $path ); return [ 'icc_preserved' => $icc_ok ]; } private static function sampling_factors( string $mode ): array { switch ( $mode ) { case '4:2:0': return [ '2x2', '1x1', '1x1' ]; case '4:2:2': return [ '2x1', '1x1', '1x1' ]; case '4:4:4': default: return [ '1x1', '1x1', '1x1' ]; } } private static function jpegoptim_pass( string $path, array $settings ): void { $bin = Tools::path( 'jpegoptim' ); if ( ! $bin ) { return; } // --strip-none keeps EXIF + ICC intact. -P preserves file timestamps. // -o overwrites only if smaller. -q quiet. $cmd = sprintf( '%s --strip-none -P -o -q -m%d %s 2>&1', escapeshellcmd( $bin ), (int) ( $settings['jpeg_quality'] ?? 85 ), escapeshellarg( $path ) ); @shell_exec( $cmd ); } private static function pngquant_pass( string $path, array $settings ): void { $bin = Tools::path( 'pngquant' ); if ( ! $bin ) { return; } $min = (int) ( $settings['png_quality_min'] ?? 70 ); $max = (int) ( $settings['png_quality_max'] ?? 90 ); $cmd = sprintf( '%s --force --skip-if-larger --speed 3 --quality=%d-%d --output %s -- %s 2>&1', escapeshellcmd( $bin ), $min, $max, escapeshellarg( $path . '.pngq.tmp' ), escapeshellarg( $path ) ); @shell_exec( $cmd ); if ( is_readable( $path . '.pngq.tmp' ) && filesize( $path . '.pngq.tmp' ) > 0 ) { $stat = stat( $path ); if ( $stat !== false ) { @chown( $path . '.pngq.tmp', $stat['uid'] ); @chgrp( $path . '.pngq.tmp', $stat['gid'] ); @chmod( $path . '.pngq.tmp', $stat['mode'] & 0777 ); } rename( $path . '.pngq.tmp', $path ); } } private static function detect_mime( string $path ): string { $info = @getimagesize( $path ); return $info['mime'] ?? ''; } /** * Copy the original to wp-content/h4b-img-originals/, preserving mtime. * Skips if a backup already exists (don't overwrite the *original* with a re-processed file). */ private static function backup( string $path ): ?string { $uploads = wp_get_upload_dir(); $basedir = trailingslashit( $uploads['basedir'] ); if ( strpos( $path, $basedir ) !== 0 ) { return null; // outside uploads/, refuse } $rel = substr( $path, strlen( $basedir ) ); $dest = trailingslashit( self::originals_root() ) . $rel; if ( file_exists( $dest ) ) { return $dest; // already backed up — don't overwrite } wp_mkdir_p( dirname( $dest ) ); if ( ! copy( $path, $dest ) ) { return null; } // Preserve mtime so prune_originals can age files reliably @touch( $dest, filemtime( $path ) ); return $dest; } /** * Cron callback: delete originals older than settings.backup_prune_days. */ public static function prune_originals(): void { $days = (int) Settings::get( 'backup_prune_days', 90 ); if ( $days <= 0 ) { return; } $cutoff = time() - $days * DAY_IN_SECONDS; $root = self::originals_root(); if ( ! is_dir( $root ) ) { return; } $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); $deleted = 0; foreach ( $it as $entry ) { /** @var \SplFileInfo $entry */ if ( $entry->isFile() && $entry->getMTime() < $cutoff ) { @unlink( $entry->getPathname() ); $deleted++; } elseif ( $entry->isDir() ) { @rmdir( $entry->getPathname() ); // succeeds only if empty } } do_action( 'h4b_img_originals_pruned', $deleted, $days ); } }