newImage( 4, 4, new \ImagickPixel( '#326ca9' ), 'png' ); $im->setImageDepth( 8 ); $im->setImageColorspace( \Imagick::COLORSPACE_SRGB ); $before = $im->exportImagePixels( 0, 0, 4, 4, 'RGBA', \Imagick::PIXEL_CHAR ); self::configure_imagick( $im ); $blob = $im->getImageBlob(); $out = new \Imagick(); $out->readImageBlob( $blob ); $same = $before === $out->exportImagePixels( 0, 0, 4, 4, 'RGBA', \Imagick::PIXEL_CHAR ); $im->clear(); $out->clear(); if ( $same && self::lossless_bitstream( $blob ) ) { return self::$backend = array( 'id' => 'imagick', 'selftest' => 'passed', 'alpha' => 'per-file exact RGBA validation', 'version' => \Imagick::getVersion()['versionString'] ?? 'unknown', ); } } } catch ( \Throwable $e ) { /* Try a narrower backend. */ } } if ( function_exists( 'imagewebp' ) && function_exists( 'imagecreatefromwebp' ) && defined( 'IMG_WEBP_LOSSLESS' ) ) { $im = null; $out = null; try { $im = imagecreatetruecolor( 4, 4 ); imagefilledrectangle( $im, 0, 0, 3, 3, imagecolorallocate( $im, 50, 108, 169 ) ); $ok = false; $blob = Plugin::capture( static function () use ( $im, &$ok ): void { $ok = imagewebp( $im, null, IMG_WEBP_LOSSLESS ); } ); $out = $ok && is_string( $blob ) ? imagecreatefromstring( $blob ) : false; if ( $out && self::gd_equal( $im, $out ) && self::lossless_bitstream( $blob ) ) { return self::$backend = array( 'id' => 'gd', 'selftest' => 'passed', 'alpha' => 'held: GD alpha precision is insufficient for strict source proof', 'version' => gd_info()['GD Version'] ?? 'unknown', ); } } catch ( \Throwable $e ) { /* Unsupported codecs remain a readable preflight result. */ } finally { unset( $im, $out ); } } return self::$backend = array( 'id' => 'none', 'selftest' => 'unavailable', 'alpha' => 'not supported', 'version' => '', ); } /** * Whether WebP bytes hold one lossless (VP8L) image in a well-formed RIFF container. * * @param string $bytes The WebP file. * @return bool True when they do. */ public static function lossless_bitstream( string $bytes ): bool { if ( strlen( $bytes ) < 21 || substr( $bytes, 0, 4 ) !== 'RIFF' || substr( $bytes, 8, 4 ) !== 'WEBP' ) { return false; } if ( unpack( 'V', substr( $bytes, 4, 4 ) )[1] !== strlen( $bytes ) - 8 ) { return false; } $pos = 12; $found = false; while ( $pos + 8 <= strlen( $bytes ) ) { $type = substr( $bytes, $pos, 4 ); $len = unpack( 'V', substr( $bytes, $pos + 4, 4 ) )[1]; if ( $len > strlen( $bytes ) - $pos - 8 ) { return false; } if ( in_array( $type, array( 'VP8 ', 'ANIM', 'ANMF', 'ICCP', 'EXIF', 'XMP ' ), true ) ) { return false; } if ( 'VP8L' === $type ) { $found = true; } $pos += 8 + $len + ( $len % 2 ); } return $found && $pos === strlen( $bytes ); } /** * Read a source's type, size and pixel count, checked against the limits and the format policy. * * @param string $path The source file. * @param string $backend The encoder (imagick or gd). * @return array Width, height, bytes and MIME type. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function inspect( string $path, string $backend ): array { $s = Settings::get(); $bytes = filesize( $path ); if ( false === $bytes || $bytes < 12 ) { throw new \RuntimeException( 'invalid_image' ); } if ( $bytes > $s['max_bytes'] ) { throw new \RuntimeException( 'source_byte_limit' ); } $info = wp_getimagesize( $path ); if ( ! $info || empty( $info[0] ) || empty( $info[1] ) ) { throw new \RuntimeException( 'invalid_image' ); } $pixels = $info[0] * $info[1]; if ( $pixels > $s['max_pixels'] || $info[0] > 8192 || $info[1] > 8192 ) { throw new \RuntimeException( 'pixel_limit' ); } if ( $pixels * ( 'imagick' === $backend ? 80 : 32 ) + $bytes * 3 > Settings::memory_budget() ) { throw new \RuntimeException( 'memory_limit' ); } $mime = $info['mime'] ?? ''; if ( 'image/webp' === $mime ) { throw new \RuntimeException( 'existing_webp_kept' ); } if ( ! in_array( $mime, array( 'image/png', 'image/jpeg' ), true ) ) { throw new \RuntimeException( 'unsupported_format' ); } // This read is bounded before decoding. Metadata parsers reject uncertain color/orientation cases. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Host encoder probe reads only its bounded locally generated test image; binary fidelity must be preserved. $data = file_get_contents( $path ); if ( ! is_string( $data ) || strlen( $data ) !== $bytes ) { throw new \RuntimeException( 'source_read_failed' ); } if ( 'image/png' === $mime ) { self::png_policy( $data, $backend ); } else { self::jpeg_policy( $data, $info ); } return array( 'width' => (int) $info[0], 'height' => (int) $info[1], 'bytes' => $bytes, 'mime' => $mime, ); } /** * Check a PNG's structure: its signature, every chunk's checksum, its bit depth (8 or less) and colour type, and no * colour profile, colour metadata or animation, which the copy would not keep. * * @param string $data The PNG. * @param string $backend The encoder (imagick or gd). * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function png_policy( string $data, string $backend ): void { if ( substr( $data, 0, 8 ) !== "\x89PNG\r\n\x1a\n" ) { throw new \RuntimeException( 'invalid_png' ); } $pos = 8; $ihdr = false; $iend = false; $alpha = false; while ( $pos + 12 <= strlen( $data ) ) { $len = unpack( 'N', substr( $data, $pos, 4 ) )[1]; $type = substr( $data, $pos + 4, 4 ); if ( $len > strlen( $data ) - $pos - 12 ) { throw new \RuntimeException( 'truncated_png' ); } $payload = substr( $data, $pos + 8, $len ); $crc = substr( $data, $pos + 8 + $len, 4 ); if ( hash( 'crc32b', $type . $payload, true ) !== $crc ) { throw new \RuntimeException( 'invalid_png_crc' ); } if ( 'IHDR' === $type ) { if ( $ihdr || 13 !== $len || 8 !== $pos ) { throw new \RuntimeException( 'invalid_png' ); } $ihdr = true; $depth = ord( $payload[8] ); $color = ord( $payload[9] ); if ( $depth > 8 || ! in_array( $color, array( 0, 2, 3, 4, 6 ), true ) ) { throw new \RuntimeException( 'unsupported_precision' ); } $alpha = in_array( $color, array( 4, 6 ), true ); } if ( 'tRNS' === $type ) { $alpha = true; } if ( in_array( $type, array( 'iCCP', 'gAMA', 'cHRM', 'eXIf', 'cICP', 'mDCv', 'cLLi' ), true ) ) { throw new \RuntimeException( 'profile_or_color_metadata_held' ); } if ( in_array( $type, array( 'acTL', 'fcTL', 'fdAT' ), true ) ) { throw new \RuntimeException( 'animated_image_held' ); } $pos += 12 + $len; if ( 'IEND' === $type ) { $iend = true; break; } } if ( ! $ihdr || ! $iend || $pos !== strlen( $data ) ) { throw new \RuntimeException( 'invalid_png' ); } if ( $alpha && 'gd' === $backend ) { throw new \RuntimeException( 'alpha_requires_exact_imagick' ); } } /** * Check a JPEG is complete and an 8-bit, three-channel picture. * * @param string $data The JPEG. * @param array $info Its getimagesize() answer. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ private static function jpeg_policy( string $data, array $info ): void { if ( substr( $data, 0, 2 ) !== "\xff\xd8" || substr( $data, -2 ) !== "\xff\xd9" ) { throw new \RuntimeException( 'truncated_jpeg' ); } if ( ( $info['bits'] ?? 8 ) !== 8 || ( $info['channels'] ?? 3 ) !== 3 ) { throw new \RuntimeException( 'unsupported_precision_or_cmyk' ); } // APP1/APP2/APP14 may define orientation/color. Conservatively reject them anywhere in the codestream, // including between progressive scans; byte-stuffed entropy data cannot contain these unescaped markers. foreach ( array( "\xff\xe1", "\xff\xe2", "\xff\xee" ) as $marker ) { if ( str_contains( $data, $marker ) ) { throw new \RuntimeException( 'jpeg_profile_or_orientation_held' ); } } } /** * Set Imagick to write exact lossless WebP. * * @param \Imagick $im The picture. */ private static function configure_imagick( \Imagick $im ): void { $im->setImageFormat( 'webp' ); $im->setOption( 'webp:lossless', 'true' ); $im->setOption( 'webp:exact', 'true' ); $im->setOption( 'webp:method', '3' ); $im->setImageCompressionQuality( 100 ); // Not the lossless proof: VP8L and exact decoded comparison are required. } /** * Whether two GD images have the same size and the same pixels. * * @param mixed $a A GD image. * @param mixed $b Another one. * @return bool True when they are equal. */ public static function gd_equal( $a, $b ): bool { if ( imagesx( $a ) !== imagesx( $b ) || imagesy( $a ) !== imagesy( $b ) ) { return false; } for ( $y = 0; $y < imagesy( $a ); $y++ ) { for ( $x = 0; $x < imagesx( $a ); $x++ ) { if ( imagecolorsforindex( $a, imagecolorat( $a, $x, $y ) ) !== imagecolorsforindex( $b, imagecolorat( $b, $x, $y ) ) ) { return false; } } } return true; } /** * Whether two Imagick pictures have the same size and the same RGBA pixels, compared a row at a time. * * @param \Imagick $a A picture. * @param \Imagick $b Another one. * @return bool True when they are equal. */ private static function imagick_equal( \Imagick $a, \Imagick $b ): bool { if ( $a->getImageWidth() !== $b->getImageWidth() || $a->getImageHeight() !== $b->getImageHeight() ) { return false; } // One row at a time avoids PHP arrays containing millions of pixel components. for ( $y = 0; $y < $a->getImageHeight(); $y++ ) { if ( $a->exportImagePixels( 0, $y, $a->getImageWidth(), 1, 'RGBA', \Imagick::PIXEL_CHAR ) !== $b->exportImagePixels( 0, $y, $b->getImageWidth(), 1, 'RGBA', \Imagick::PIXEL_CHAR ) ) { return false; } } return true; } /** * Writes a temporary candidate only; Engine owns atomic publication and source/revision fencing. * * @param string $source The source file. * @param string $temporary Where to write the candidate. * @return array The candidate's sizes, saving, hashes, encoder and verification. * @throws \RuntimeException With a reason code as its message when the step cannot be done. */ public static function encode( string $source, string $temporary ): array { $backend = self::backend(); if ( 'none' === $backend['id'] ) { throw new \RuntimeException( 'lossless_encoder_unavailable' ); } $info = self::inspect( $source, $backend['id'] ); $before = hash_file( 'sha256', $source ); // 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 ( $severity, $message ) { throw new \RuntimeException( 'codec_warning' ); } ); try { if ( 'imagick' === $backend['id'] ) { $a = new \Imagick(); $a->readImage( $source ); if ( 1 !== $a->getNumberImages() || $a->getImageDepth() > 8 || $a->getImageProfiles( '*' ) || ! in_array( $a->getImageColorspace(), array( \Imagick::COLORSPACE_SRGB, \Imagick::COLORSPACE_RGB, \Imagick::COLORSPACE_GRAY ), true ) ) { throw new \RuntimeException( 'unsupported_color_or_frames' ); } if ( ! in_array( $a->getImageOrientation(), array( \Imagick::ORIENTATION_UNDEFINED, \Imagick::ORIENTATION_TOPLEFT ), true ) ) { throw new \RuntimeException( 'orientation_held' ); } $b = clone $a; self::configure_imagick( $b ); if ( ! $b->writeImage( $temporary ) ) { throw new \RuntimeException( 'candidate_write_failed' ); } $b->clear(); $b->readImage( $temporary ); $same = self::imagick_equal( $a, $b ); $a->clear(); $b->clear(); } else { $a = 'image/png' === $info['mime'] ? imagecreatefrompng( $source ) : imagecreatefromjpeg( $source ); if ( ! $a ) { throw new \RuntimeException( 'decode_failed' ); } if ( ! imageistruecolor( $a ) && ! imagepalettetotruecolor( $a ) ) { throw new \RuntimeException( 'palette_decode_failed' ); } if ( ! imagewebp( $a, $temporary, IMG_WEBP_LOSSLESS ) ) { throw new \RuntimeException( 'candidate_write_failed' ); } $b = imagecreatefromwebp( $temporary ); $same = $b && self::gd_equal( $a, $b ); unset( $a, $b ); } } finally { restore_error_handler(); } if ( ! $same || ! hash_equals( $before, hash_file( 'sha256', $source ) ) ) { throw new \RuntimeException( 'pixel_or_source_mismatch' ); } clearstatcache( true, $temporary ); if ( ! is_file( $temporary ) || filesize( $temporary ) < 12 || filesize( $temporary ) > Settings::get()['max_bytes'] ) { throw new \RuntimeException( 'invalid_candidate' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Host encoder probe reads only its bounded locally generated test image; binary fidelity must be preserved. $bytes = file_get_contents( $temporary ); if ( ! is_string( $bytes ) || ! self::lossless_bitstream( $bytes ) ) { throw new \RuntimeException( 'not_verified_lossless_webp' ); } $dim = wp_getimagesize( $temporary ); if ( ! $dim || $dim[0] !== $info['width'] || $dim[1] !== $info['height'] || 'image/webp' !== $dim['mime'] ) { throw new \RuntimeException( 'candidate_dimensions_or_mime' ); } $saving = $info['bytes'] - strlen( $bytes ); return array_merge( $info, array( 'output_bytes' => strlen( $bytes ), 'saving_bytes' => $saving, 'worthwhile' => $saving >= 1024 && $saving / $info['bytes'] * 100 >= Settings::get()['min_saving'], 'source_hash' => $before, 'output_hash' => hash( 'sha256', $bytes ), 'policy' => self::POLICY, 'encoder' => $backend['id'], 'encoder_version' => $backend['version'], 'verification' => 'exact decoded RGBA at native dimensions; VP8L verified', ) ); } }