available, format, version and self-test result. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function capabilities( bool $refresh = false ): array { if ( null !== self::$caps && ! $refresh ) { return self::$caps; } $signature = hash( 'sha256', VERSION . '|' . PHP_VERSION . '|' . ( class_exists( '\Imagick' ) ? wp_json_encode( \Imagick::getVersion() ) : 'no-imagick' ) . '|' . ( function_exists( 'gd_info' ) ? wp_json_encode( gd_info() ) : 'no-gd' ) . '|' . ( new JpegTransform() )->version() . '|' . Ghostscript::version() ); $cached = get_transient( 'afsd_im_codec_caps' ); if ( ! $refresh && is_array( $cached ) && ( $cached['signature'] ?? '' ) === $signature ) { return self::$caps = $cached['results']; } $out = array(); try { $root = Settings::root( true ); } catch ( \Throwable $e ) { return self::$caps = array( 'storage' => array( 'available' => false, 'format' => 'none', 'selftest' => 'storage unavailable', ), ); }$tmp = $root . '/cap-' . bin2hex( random_bytes( 16 ) ) . '.tmp'; // Deterministic public test swatch; contains no customer media. $raw = ''; for ( $y = 0; $y < 16; ++$y ) { $raw .= "\0";for ( $x = 0; $x < 16; ++$x ) { $raw .= chr( 48 + $x ) . chr( 104 + $y ) . chr( 164 ); } } $png = "\x89PNG\r\n\x1a\n" . ImageInput::png_chunk( 'IHDR', pack( 'NNCCCCC', 16, 16, 8, 2, 0, 0, 0 ) ) . ImageInput::png_chunk( 'IDAT', gzcompress( $raw, 0 ) ) . ImageInput::png_chunk( 'IEND', '' ); $input = array( 'width' => 16, 'height' => 16, 'bytes' => strlen( $png ), 'data' => $png, 'mime' => 'image/png', 'alpha' => false, 'metadata' => false, 'modern_safe' => true, 'imagick_safe' => true, ); $adapters = array( new PngEncoder() ); foreach ( array( 'imagick', 'gd' ) as $backend ) { if ( 'imagick' === $backend && ! class_exists( '\Imagick' ) ) { continue; } if ( 'gd' === $backend && ! function_exists( 'imagecreatefromstring' ) ) { continue; } foreach ( array( 'jpeg', 'webp', 'avif' ) as $format ) { if ( 'imagick' === $backend && ! \Imagick::queryFormats( strtoupper( $format ) ) ) { continue; } if ( 'gd' === $backend && ( ! function_exists( 'image' . $format ) || ( 'webp' === $format && ! defined( 'IMG_WEBP_LOSSLESS' ) ) ) ) { continue; } $adapters[] = new RasterEncoder( $backend, $format ); } } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Convert codec warnings into a controlled conversion failure; the previous handler is restored in finally and no debug logging is emitted. set_error_handler( static function () { throw new \RuntimeException( 'codec_warning' ); } ); try { foreach ( $adapters as $adapter ) { $fidelity = in_array( $adapter->format(), array( 'png', 'webp' ), true ) ? 'lossless' : 'high'; try { $r = $adapter->encode( $input, $tmp, Policy::defaults(), $fidelity ); $i = wp_getimagesize( $tmp ); $ok = $i && $i[0] === 16 && $i[1] === 16 && ( $i['mime'] ?? '' ) === 'image/' . ( 'jpeg' === $adapter->format() ? 'jpeg' : $adapter->format() ); $out[ $adapter->id() ] = array( 'available' => (bool) $ok, 'format' => $adapter->format(), 'version' => $adapter->version(), 'selftest' => $ok ? 'encode/decode passed' : 'output rejected', ); } catch ( \Throwable $e ) { $out[ $adapter->id() ] = array( 'available' => false, 'format' => $adapter->format(), 'selftest' => sanitize_key( $e->getMessage() ), ); } finally { if ( is_file( $tmp ) && ! is_link( $tmp ) ) { Reviewed_Store::wp_delete_file( $tmp ); } } } $jpeg = new JpegTransform(); $ok = false; $why = 'not installed or safe execution unavailable'; if ( JpegTransform::path() ) { try { if ( function_exists( 'imagejpeg' ) ) { $im = imagecreatefromstring( $png ); $j = Plugin::capture( static function () use ( $im ): void { imagejpeg( $im, null, 95 ); } ); unset( $im ); } elseif ( class_exists( '\Imagick' ) ) { $im = new \Imagick(); $im->readImageBlob( $png ); $im->setImageFormat( 'jpeg' ); $j = $im->getImageBlob(); $im->clear(); } else { $j = ''; } if ( ! $j ) { throw new \RuntimeException( 'jpeg_verifier_unavailable' ); } $ji = array_merge( $input, array( 'data' => $j, 'mime' => 'image/jpeg', 'bytes' => strlen( $j ), ) ); $jpeg->encode( $ji, $tmp, Policy::defaults(), 'lossless' ); $ok = true; $why = 'coefficient transform and exact decoded check passed'; } catch ( \Throwable $e ) { $why = sanitize_key( $e->getMessage() ); } finally { if ( is_file( $tmp ) && ! is_link( $tmp ) ) { Reviewed_Store::wp_delete_file( $tmp ); } } } $out['jpegtran'] = array( 'available' => $ok, 'format' => 'jpeg', 'version' => $jpeg->version(), 'selftest' => $why, ); if ( ! empty( $out['imagick-webp']['available'] ) ) { $out['imagick-webp-animated'] = self::animation_selftest( $tmp ); } // 1.9.0 (D-500 gap 15): the PDF tools, tested as they are used: Ghostscript writing a PDF's smaller copy, and // Imagick reading a PDF page (which needs Ghostscript too) for a PDF page as a picture. $out['ghostscript-pdf'] = PDF_Copy::selftest(); $out['imagick-pdf'] = PDF::selftest(); } finally { restore_error_handler(); if ( is_file( $tmp ) && ! is_link( $tmp ) ) { Reviewed_Store::wp_delete_file( $tmp ); } } Reviewed_Store::set_transient( 'afsd_im_codec_caps', array( 'signature' => $signature, 'results' => $out, ), 43200 ); return self::$caps = $out; } /** * 1.9.0 (D-500 gap 7 step 2): Imagick's animated WebP, tested as copies are made: a two-image GIF that repeats for * ever (a deterministic swatch, no customer media) through AnimatedWebpEncoder and all its checks. * * \param string $tmp A temporary path inside Image Manager's own folder. * \return array * * @param string $tmp A temporary path inside Image Manager's own folder. * @return array The animated WebP encoder's self-test result. */ private static function animation_selftest( string $tmp ): array { $adapter = new AnimatedWebpEncoder(); $frames = null; try { $frames = new \Imagick(); foreach ( array( '#3068a4', '#c06030' ) as $colour ) { $frame = new \Imagick(); $frame->newImage( 16, 16, new \ImagickPixel( $colour ) ); $frame->setImageFormat( 'gif' ); $frame->setImageDelay( 10 ); $frame->setImageIterations( 0 ); $frames->addImage( $frame ); $frame->clear(); } $gif = $frames->getImagesBlob(); $adapter->encode( ImageInput::from_bytes( $gif, true ), $tmp, Policy::defaults(), 'lossless' ); $i = wp_getimagesize( $tmp ); $ok = $i && 16 === $i[0] && 16 === $i[1] && 'image/webp' === ( $i['mime'] ?? '' ); return array( 'available' => (bool) $ok, 'format' => 'webp', 'version' => $adapter->version(), 'selftest' => $ok ? 'animated encode/decode passed' : 'output rejected', ); } catch ( \Throwable $e ) { return array( 'available' => false, 'format' => 'webp', 'selftest' => sanitize_key( $e->getMessage() ), ); } finally { if ( $frames instanceof \Imagick ) { $frames->clear(); } if ( is_file( $tmp ) && ! is_link( $tmp ) ) { Reviewed_Store::wp_delete_file( $tmp ); } } } /** * Whether any image encoder passed its self-test (the PDF tools do not count). * * @return bool True when one did. */ public static function available(): bool { foreach ( self::capabilities() as $c ) { // The PDF tools are not image encoders. if ( ! empty( $c['available'] ) && 'pdf' !== ( $c['format'] ?? '' ) ) { return true; } }return false; } /** * The encoder for a format: Imagick's when it passed, otherwise GD's. * * @param string $format jpeg, webp or avif. * @return EncoderAdapter|null The encoder, or null when neither passed. */ private static function raster( string $format ): ?EncoderAdapter { $caps = self::capabilities(); foreach ( array( 'imagick', 'gd' ) as $b ) { if ( ! empty( $caps[ $b . '-' . $format ]['available'] ) ) { return new RasterEncoder( $b, $format ); } }return null; } /** * The candidates to try for a picture under a recipe: which formats and encoders, at which fidelity. * * @param array $input The picture's input record (ImageInput::read()). * @param array $recipe The compression recipe. * @param string $source The source's address or name (for the content hint). * @param string $display_mime The type the page shows, when it differs from the file's. * @return array The candidates, the content (photo or graphics) and the fidelity. */ public static function plan( array $input, array $recipe, string $source, string $display_mime = '' ): array { $content = Policy::content( $input, $recipe, $source ); $fidelity = Policy::fidelity( $recipe, $content, $input['alpha'] ); $plan = array(); $caps = self::capabilities(); if ( ! empty( $input['animated'] ) ) { // 1.9.0 (D-500 gap 7 step 2): an animated GIF has one possible copy, an animated lossless WebP from Imagick. if ( in_array( $recipe['formats'], array( 'auto', 'webp' ), true ) && ! empty( $caps['imagick-webp-animated']['available'] ) ) { $plan[] = array( new AnimatedWebpEncoder(), 'lossless' ); } return array( $plan, $content, 'lossless' ); } if ( in_array( $recipe['formats'], array( 'auto', 'native' ), true ) ) { if ( 'image/png' === $input['mime'] ) { $plan[] = array( new PngEncoder(), 'lossless' ); } if ( 'image/jpeg' === $input['mime'] ) { if ( ! empty( $caps['jpegtran']['available'] ) ) { $plan[] = array( new JpegTransform(), 'lossless' ); } if ( 'lossless' !== $fidelity && ( $a = self::raster( 'jpeg' ) ) ) { $plan[] = array( $a, $fidelity ); } } } if ( 'image/jpeg' === $display_mime && 'image/png' === $input['mime'] && 'lossless' !== $fidelity && in_array( $recipe['formats'], array( 'auto', 'native' ), true ) && ( $a = self::raster( 'jpeg' ) ) ) { $plan[] = array( $a, $fidelity ); } if ( in_array( $recipe['formats'], array( 'auto', 'webp' ), true ) || ( 'native' === $recipe['formats'] && 'image/webp' === $input['mime'] ) ) { if ( $a = self::raster( 'webp' ) ) { $plan[] = array( $a, $fidelity ); } } if ( 'lossless' !== $fidelity && in_array( $recipe['formats'], array( 'auto', 'avif' ), true ) ) { if ( $a = self::raster( 'avif' ) ) { $plan[] = array( $a, $fidelity ); } } return array( $plan, $content, $fidelity ); } /** * Temporary paths are deterministic suffixes of Engine's recorded random receipt; interruption cleanup is bounded. * $keep_all (1.9.0, watermarked copies) keeps each format's best candidate whether or not it saves anything. * * @param string $source The source file. * @param string $temporary Where the chosen candidate is written. * @param array|null $recipe The recipe (the source's current one when null). * @param string $display_mime The type the page shows, when it differs from the file's. * @param bool $keep_all True to keep each format's best candidate even when it saves nothing (watermarked * copies). * @return array The best candidate, written to $temporary, with its measurements. * @throws \RuntimeException With a reason code as its message when the step cannot be done. * @throws \Throwable What a step inside threw, passed on after cleaning up. */ public static function encode( string $source, string $temporary, ?array $recipe = null, string $display_mime = '', bool $keep_all = false ): array { $recipe = Policy::clean( $recipe ?? Policy::current() ); $input = ImageInput::read( $source, true ); $source_hash = hash( 'sha256', $input['data'] ); if ( 'remove_all' === $recipe['metadata'] ) { // 1.9.0 (D-500 gap 4): the encoders get the source without its photo details, and every rule about what a // conversion may lose is judged on what is left (colour profile, orientation); the original keeps its identity. $input = ImageInput::from_bytes( ImageInput::sanitized( $input, $recipe ), true ); } list($plan, $content, $fidelity) = self::plan( $input, $recipe, $source, $display_mime ); if ( ! $plan ) { throw new \RuntimeException( empty( $input['animated'] ) ? 'requested_encoder_unavailable' : 'animated_image_held' ); } $winner = array(); $attempts = array(); $paths = array(); $start = microtime( true ); $peak = memory_get_peak_usage( true ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Convert codec warnings into a controlled conversion failure; the previous handler is restored in finally and no debug logging is emitted. set_error_handler( static function () { throw new \RuntimeException( 'codec_warning' ); } ); try { foreach ( $plan as $n => $item ) { list($adapter, $kind) = $item; $tmp = $temporary . '.c' . $n; $paths[] = $tmp; if ( is_link( $tmp ) || file_exists( $tmp ) ) { throw new \RuntimeException( 'unsafe_storage' ); } try { $t = microtime( true ); $r = $adapter->encode( $input, $tmp, $recipe, $kind ); clearstatcache( true, $tmp ); $dim = wp_getimagesize( $tmp ); $size = is_file( $tmp ) ? filesize( $tmp ) : 0; $mime = 'image/' . $adapter->format(); if ( ! $size || $size > Settings::get()['max_bytes'] || ! $dim || $dim[0] !== $input['width'] || $dim[1] !== $input['height'] || ( $dim['mime'] ?? '' ) !== $mime ) { throw new \RuntimeException( 'candidate_dimensions_or_mime' ); } $saving = $input['bytes'] - $size; $valid = $saving >= 1024 && $saving / $input['bytes'] * 100 >= Settings::get()['min_saving']; $attempts[] = array( 'encoder' => $adapter->id(), 'mime' => $mime, 'bytes' => $size, 'state' => $valid ? 'qualified' : 'no_saving', 'milliseconds' => (int) round( ( microtime( true ) - $t ) * 1000 ), ); $candidate = array_merge( array_intersect_key( $input, array_flip( array( 'width', 'height', 'bytes', 'mime' ) ) ), $r, array( 'output_bytes' => $size, 'saving_bytes' => $saving, 'worthwhile' => $valid, 'source_hash' => $source_hash, 'output_hash' => hash_file( 'sha256', $tmp ), 'policy' => Policy::VERSION, 'recipe_key' => Policy::key( $recipe ), 'recipe' => $recipe, 'content_policy' => $content, 'encoder' => $adapter->id(), 'encoder_version' => $adapter->version(), 'output_mime' => $mime, 'effort' => $recipe['effort'], '_temporary' => $tmp, ) ); if ( ! isset( $winner[ $mime ] ) || $size < $winner[ $mime ]['output_bytes'] ) { if ( isset( $winner[ $mime ] ) ) { Reviewed_Store::wp_delete_file( $winner[ $mime ]['_temporary'] ); }$winner[ $mime ] = $candidate; } else { Reviewed_Store::wp_delete_file( $tmp ); } } catch ( \Throwable $e ) { $attempts[] = array( 'encoder' => $adapter->id(), 'mime' => 'image/' . $adapter->format(), 'state' => 'not_used', 'reason' => sanitize_key( $e->getMessage() ), ); if ( is_file( $tmp ) && ! is_link( $tmp ) ) { Reviewed_Store::wp_delete_file( $tmp ); } } } clearstatcache( true, $source ); if ( ! is_file( $source ) || ! hash_equals( $source_hash, hash_file( 'sha256', $source ) ) ) { throw new \RuntimeException( 'pixel_or_source_mismatch' ); } if ( ! $winner ) { $reasons = array_column( $attempts, 'reason' ); throw new \RuntimeException( in_array( 'quality_gate_failed', $reasons, true ) ? 'quality_gate_failed' : ( $reasons[0] ?? 'requested_encoder_unavailable' ) ); } uasort( $winner, static fn( $a, $b ) => $a['output_bytes'] <=> $b['output_bytes'] ); $best = reset( $winner ); $best['attempted_encoders'] = $attempts; $best['encode_milliseconds'] = (int) round( ( microtime( true ) - $start ) * 1000 ); $best['peak_memory_bytes'] = memory_get_peak_usage( true ); $best['peak_growth_bytes'] = max( 0, memory_get_peak_usage( true ) - $peak ); $best['alternatives'] = array(); foreach ( $winner as $mime => $v ) { if ( $v['_temporary'] === $best['_temporary'] ) { continue; }if ( $v['worthwhile'] || $keep_all ) { $v['encode_milliseconds'] = $best['encode_milliseconds']; $v['peak_memory_bytes'] = $best['peak_memory_bytes']; $v['peak_growth_bytes'] = $best['peak_growth_bytes']; $best['alternatives'][] = $v; } else { Reviewed_Store::wp_delete_file( $v['_temporary'] ); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic rename publishes a validated local candidate on the same filesystem; a copy through an FTP/SSH transport would lose atomicity. if ( ! Reviewed_Store::rename( $best['_temporary'], $temporary ) ) { throw new \RuntimeException( 'candidate_write_failed' ); }$best['_temporary'] = $temporary; return $best; } catch ( \Throwable $e ) { foreach ( $paths as $p ) { if ( is_file( $p ) && ! is_link( $p ) ) { Reviewed_Store::wp_delete_file( $p ); } }throw $e; } finally { restore_error_handler(); } } /** * Remove the temporary files an interrupted encode may have left. * * @param string $temporary The temporary path the encode used. */ public static function cleanup_temporary( string $temporary ): void { foreach ( array_merge( array( $temporary, $temporary . '.resize.png' ), array_map( static fn( $i ) => $temporary . '.c' . $i, range( 0, 3 ) ) ) as $path ) { if ( is_file( $path ) && ! is_link( $path ) ) { Reviewed_Store::wp_delete_file( $path ); } } } }