'image/jpeg', IMAGETYPE_PNG => 'image/png', IMAGETYPE_WEBP => 'image/webp', ); /** * Memory check run before decoding. * * @var MemoryBudget */ private $budget; /** * Whether this GD build can decode WebP. * * @var bool */ private $webp_supported; /** * Creates a sanitizer. Both arguments exist for tests; production code passes nothing. * * @param MemoryBudget|null $budget Defaults to the runtime memory limit. * @param bool|null $webp_supported Defaults to what GD reports. */ public function __construct( ?MemoryBudget $budget = null, ?bool $webp_supported = null ) { $this->budget = $budget ?? MemoryBudget::from_runtime(); $this->webp_supported = $webp_supported ?? ( function_exists( 'imagetypes' ) && ( imagetypes() & IMG_WEBP ) ); } /** * What this server lacks to process photos at all. An empty list means try-on can be enabled. * * @return list Problem codes: gd_missing, gd_jpeg_missing, gd_png_missing. */ public static function environment_problems(): array { if ( ! function_exists( 'imagecreatefromstring' ) || ! function_exists( 'imagetypes' ) ) { return array( 'gd_missing' ); } $problems = array(); if ( ! ( imagetypes() & IMG_JPG ) ) { $problems[] = 'gd_jpeg_missing'; } if ( ! ( imagetypes() & IMG_PNG ) ) { $problems[] = 'gd_png_missing'; } return $problems; } /** * Returns the photo as a clean JPEG, upright, at most MAX_SIDE on its longest side. * * @param string $bytes Raw upload. * @throws InvalidImage When the photo is rejected. */ public function sanitize( string $bytes ): string { if ( '' === $bytes ) { throw new InvalidImage( InvalidImage::EMPTY ); } if ( strlen( $bytes ) > self::MAX_BYTES ) { throw new InvalidImage( InvalidImage::FILE_TOO_LARGE ); } [ $type, $width, $height ] = $this->read_header( $bytes ); if ( $width * $height > self::MAX_PIXELS ) { throw new InvalidImage( InvalidImage::TOO_MANY_PIXELS ); } if ( min( $width, $height ) < self::MIN_SIDE ) { throw new InvalidImage( InvalidImage::TOO_SMALL ); } if ( ! $this->budget->fits( $width, $height, self::MAX_SIDE ) ) { throw new InvalidImage( InvalidImage::NOT_ENOUGH_MEMORY ); } $image = @imagecreatefromstring( $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- GD warns on corrupt data; the false return is handled. if ( false === $image ) { throw new InvalidImage( InvalidImage::UNREADABLE ); } if ( ! imageistruecolor( $image ) ) { imagepalettetotruecolor( $image ); } if ( IMAGETYPE_JPEG === $type ) { $image = self::orient( $image, ExifOrientation::from_jpeg( $bytes ) ); } $image = self::fit_within( $image, self::MAX_SIDE ); $image = self::flatten_on_white( $image ); return self::encode_jpeg( $image ); } /** * Type and size from the header only, without decoding. * * @param string $bytes Raw upload. * @return array{0: int, 1: int, 2: int} Image type constant, width, height. * @throws InvalidImage When the type is not allowed. */ private function read_header( string $bytes ): array { $info = @getimagesizefromstring( $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Warns on non-images; the false return is handled. if ( false === $info || ! isset( self::MIME_BY_TYPE[ $info[2] ] ) ) { throw new InvalidImage( InvalidImage::UNSUPPORTED_TYPE ); } $type = (int) $info[2]; // A second, independent opinion on the type when fileinfo is available. if ( class_exists( 'finfo' ) ) { $mime = ( new \finfo( FILEINFO_MIME_TYPE ) )->buffer( $bytes ); if ( self::MIME_BY_TYPE[ $type ] !== $mime ) { throw new InvalidImage( InvalidImage::UNSUPPORTED_TYPE ); } } if ( IMAGETYPE_WEBP === $type && ! $this->webp_supported ) { throw new InvalidImage( InvalidImage::WEBP_UNSUPPORTED ); } // CMYK JPEGs decode with wrong colours in GD. if ( IMAGETYPE_JPEG === $type && isset( $info['channels'] ) && 4 === (int) $info['channels'] ) { throw new InvalidImage( InvalidImage::UNSUPPORTED_COLOR ); } return array( $type, (int) $info[0], (int) $info[1] ); } /** * Applies an EXIF orientation so the pixels are upright. * * @param \GdImage|resource $image Decoded image. * @param int $orientation EXIF value 1–8. * @return \GdImage|resource */ private static function orient( $image, int $orientation ) { switch ( $orientation ) { case 2: imageflip( $image, IMG_FLIP_HORIZONTAL ); return $image; case 3: return self::rotate( $image, 180 ); case 4: imageflip( $image, IMG_FLIP_VERTICAL ); return $image; case 5: $image = self::rotate( $image, 270 ); imageflip( $image, IMG_FLIP_HORIZONTAL ); return $image; case 6: return self::rotate( $image, 270 ); case 7: $image = self::rotate( $image, 270 ); imageflip( $image, IMG_FLIP_VERTICAL ); return $image; case 8: return self::rotate( $image, 90 ); default: return $image; } } /** * Rotates counter-clockwise by $degrees (GD's direction): 270 turns the image 90° clockwise. * * @param \GdImage|resource $image Image. * @param int $degrees 90, 180 or 270. * @return \GdImage|resource * @throws InvalidImage When GD fails. */ private static function rotate( $image, int $degrees ) { $rotated = imagerotate( $image, $degrees, 0 ); if ( false === $rotated ) { throw new InvalidImage( InvalidImage::UNREADABLE ); } return $rotated; } /** * Scales down so the longest side is at most $max_side. Never scales up. * * @param \GdImage|resource $image Image. * @param int $max_side Longest side in pixels. * @return \GdImage|resource */ private static function fit_within( $image, int $max_side ) { $width = imagesx( $image ); $height = imagesy( $image ); $longest = max( $width, $height ); if ( $longest <= $max_side ) { return $image; } $ratio = $max_side / $longest; $scaled = imagescale( $image, max( 1, (int) round( $width * $ratio ) ), max( 1, (int) round( $height * $ratio ) ), IMG_BICUBIC ); return false === $scaled ? $image : $scaled; } /** * Composites transparency onto white; JPEG has no alpha and would otherwise show black. * * @param \GdImage|resource $image Image. * @return \GdImage|resource * @throws InvalidImage When GD fails. */ private static function flatten_on_white( $image ) { $width = imagesx( $image ); $height = imagesy( $image ); $canvas = imagecreatetruecolor( $width, $height ); if ( false === $canvas ) { throw new InvalidImage( InvalidImage::NOT_ENOUGH_MEMORY ); } $white = (int) imagecolorallocate( $canvas, 255, 255, 255 ); imagefilledrectangle( $canvas, 0, 0, $width - 1, $height - 1, $white ); imagealphablending( $canvas, true ); imagecopy( $canvas, $image, 0, 0, 0, 0, $width, $height ); return $canvas; } /** * Encodes as JPEG with no metadata. * * @param \GdImage|resource $image Image. * @throws InvalidImage When GD fails. */ private static function encode_jpeg( $image ): string { ob_start(); $ok = imagejpeg( $image, null, self::JPEG_QUALITY ); $bytes = (string) ob_get_clean(); if ( ! $ok || '' === $bytes ) { throw new InvalidImage( InvalidImage::UNREADABLE ); } return $bytes; } }