X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/7247e31936ebf630b28be5870a5760be920b0d90..refs/pull/5280/head:/app/Uploads/ImageResizer.php diff --git a/app/Uploads/ImageResizer.php b/app/Uploads/ImageResizer.php index 7a89b9d35..fa6b1cac2 100644 --- a/app/Uploads/ImageResizer.php +++ b/app/Uploads/ImageResizer.php @@ -3,43 +3,144 @@ namespace BookStack\Uploads; use BookStack\Exceptions\ImageUploadException; +use Exception; use GuzzleHttp\Psr7\Utils; -use Intervention\Image\Exception\NotSupportedException; -use Intervention\Image\Image as InterventionImage; +use Illuminate\Support\Facades\Cache; +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 { + protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week + public function __construct( - protected ImageManager $intervention + protected ImageStorage $storage, ) { } + /** + * Load gallery thumbnails for a set of images. + * @param iterable $images + */ + public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void + { + foreach ($images as $image) { + $this->loadGalleryThumbnailsForImage($image, $shouldCreate); + } + } + + /** + * Load gallery thumbnails into the given image instance. + */ + public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void + { + $thumbs = ['gallery' => null, 'display' => null]; + + try { + $thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate); + $thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate); + } catch (Exception $exception) { + // Prevent thumbnail errors from stopping execution + } + + $image->setAttribute('thumbs', $thumbs); + } + + /** + * Get the thumbnail for an image. + * If $keepRatio is true only the width will be used. + * Checks the cache then storage to avoid creating / accessing the filesystem on every check. + * + * @throws Exception + */ + public function resizeToThumbnailUrl( + Image $image, + ?int $width, + ?int $height, + bool $keepRatio = false, + bool $shouldCreate = false + ): ?string { + // Do not resize GIF images where we're not cropping + if ($keepRatio && $this->isGif($image)) { + return $this->storage->getPublicUrl($image->path); + } + + $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/'; + $imagePath = $image->path; + $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath); + + $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath; + + // Return path if in cache + $cachedThumbPath = Cache::get($thumbCacheKey); + if ($cachedThumbPath && !$shouldCreate) { + return $this->storage->getPublicUrl($cachedThumbPath); + } + + // If thumbnail has already been generated, serve that and cache path + $disk = $this->storage->getDisk($image->type); + if (!$shouldCreate && $disk->exists($thumbFilePath)) { + Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME); + + return $this->storage->getPublicUrl($thumbFilePath); + } + + $imageData = $disk->get($imagePath); + + // Do not resize apng images where we're not cropping + if ($keepRatio && $this->isApngData($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, $this->getExtension($image)); + $disk->put($thumbFilePath, $thumbData, true); + Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME); + + return $this->storage->getPublicUrl($thumbFilePath); + } + /** * Resize the image of given data to the specified size, and return the new image data. + * Format will remain the same as the input format, unless specified. * * @throws ImageUploadException */ - protected function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string - { + public function resizeImageData( + string $imageData, + ?int $width, + ?int $height, + bool $keepRatio, + ?string $format = null, + ): string { try { - $thumb = $this->intervention->make($imageData); - } catch (NotSupportedException $e) { + $thumb = $this->interventionFromImageData($imageData, $format); + } catch (Exception $e) { throw new ImageUploadException(trans('errors.cannot_create_thumbs')); } $this->orientImageToOriginalExif($thumb, $imageData); if ($keepRatio) { - $thumb->resize($width, $height, function ($constraint) { - $constraint->aspectRatio(); - $constraint->upsize(); - }); + $thumb->scaleDown($width, $height); } else { - $thumb->fit($width, $height); + $thumb->cover($width, $height); } - $thumbData = (string) $thumb->encode(); + $encoder = match ($format) { + 'png' => new PngEncoder(), + default => new AutoEncoder(), + }; + + $thumbData = (string) $thumb->encode($encoder); // Use original image data if we're keeping the ratio // and the resizing does not save any space. @@ -50,6 +151,30 @@ class ImageResizer return $thumbData; } + /** + * Create an intervention image instance from the given image data. + * 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, ?string $fileType): InterventionImage + { + $manager = new ImageManager(new Driver()); + + // 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; + } + /** * Orientate the given intervention image based upon the given original image data. * Intervention does have an `orientate` method but the exif data it needs is lost before it @@ -92,4 +217,35 @@ class ImageResizer break; } } + + /** + * Checks if the image is a gif. Returns true if it is, else false. + */ + protected function isGif(Image $image): bool + { + 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 + { + $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png'; + if (!$isPng) { + return false; + } + + $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT')); + + return str_contains($initialHeader, 'acTL'); + } }