X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/85dd71507e7d88eb94f5d59b865bcc4e1c1cae4f..refs/pull/5689/head:/app/Uploads/ImageResizer.php diff --git a/app/Uploads/ImageResizer.php b/app/Uploads/ImageResizer.php index d09177fff..8d7571c82 100644 --- a/app/Uploads/ImageResizer.php +++ b/app/Uploads/ImageResizer.php @@ -6,12 +6,15 @@ use BookStack\Exceptions\ImageUploadException; use Exception; use GuzzleHttp\Psr7\Utils; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Intervention\Image\Decoders\BinaryImageDecoder; +use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder; use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\Encoders\AutoEncoder; use Intervention\Image\Encoders\PngEncoder; use Intervention\Image\Interfaces\ImageInterface as InterventionImage; use Intervention\Image\ImageManager; +use Intervention\Image\Origin; class ImageResizer { @@ -91,15 +94,15 @@ class ImageResizer $imageData = $disk->get($imagePath); - // Do not resize apng images where we're not cropping - if ($keepRatio && $this->isApngData($image, $imageData)) { + // Do not resize animated images where we're not cropping + if ($keepRatio && $this->isAnimated($image, $imageData)) { Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME); return $this->storage->getPublicUrl($image->path); } // If not in cache and thumbnail does not exist, generate thumb and cache path - $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio); + $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio, $this->getExtension($image)); $disk->put($thumbFilePath, $thumbData, true); Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME); @@ -120,7 +123,7 @@ class ImageResizer ?string $format = null, ): string { try { - $thumb = $this->interventionFromImageData($imageData); + $thumb = $this->interventionFromImageData($imageData, $format); } catch (Exception $e) { throw new ImageUploadException(trans('errors.cannot_create_thumbs')); } @@ -154,11 +157,26 @@ class ImageResizer * Performs some manual library usage to ensure image is specifically loaded * from given binary data instead of data being misinterpreted. */ - protected function interventionFromImageData(string $imageData): InterventionImage + protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage { - $manager = new ImageManager(new Driver()); + $manager = new ImageManager( + new Driver(), + autoOrientation: false, + ); - return $manager->read($imageData, BinaryImageDecoder::class); + // Ensure gif images are decoded natively instead of deferring to intervention GIF + // handling since we don't need the added animation support. + $isGif = $fileType === 'gif'; + $decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class; + $input = $isGif ? @imagecreatefromstring($imageData) : $imageData; + + $image = $manager->read($input, $decoder); + + if ($isGif) { + $image->setOrigin(new Origin('image/gif')); + } + + return $image; } /** @@ -209,21 +227,64 @@ class ImageResizer */ protected function isGif(Image $image): bool { - return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif'; + return $this->getExtension($image) === 'gif'; + } + + /** + * Get the extension for the given image, normalised to lower-case. + */ + protected function getExtension(Image $image): string + { + return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)); } /** * Check if the given image and image data is apng. */ - protected function isApngData(Image $image, string &$imageData): bool + protected function isApngData(string &$imageData): bool + { + $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT')); + + return str_contains($initialHeader, 'acTL'); + } + + /** + * Check if the given avif image data represents an animated image. + * This is based up the answer here: https://stackoverflow.com/a/79457313 + */ + protected function isAnimatedAvifData(string &$imageData): bool { - $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png'; - if (!$isPng) { + $stszPos = strpos($imageData, 'stsz'); + if ($stszPos === false) { return false; } - $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT')); + // Look 12 bytes after the start of 'stsz' + $start = $stszPos + 12; + $end = $start + 4; + if ($end > strlen($imageData) - 1) { + return false; + } - return str_contains($initialHeader, 'acTL'); + $data = substr($imageData, $start, 4); + $count = unpack('Nvalue', $data)['value']; + return $count > 1; + } + + /** + * Check if the given image is animated. + */ + protected function isAnimated(Image $image, string &$imageData): bool + { + $extension = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)); + if ($extension === 'png') { + return $this->isApngData($imageData); + } + + if ($extension === 'avif') { + return $this->isAnimatedAvifData($imageData); + } + + return false; } }