3 namespace BookStack\Uploads;
5 use BookStack\Exceptions\ImageUploadException;
7 use GuzzleHttp\Psr7\Utils;
8 use Illuminate\Support\Facades\Cache;
9 use Intervention\Image\Decoders\BinaryImageDecoder;
10 use Intervention\Image\Drivers\Gd\Driver;
11 use Intervention\Image\Encoders\AutoEncoder;
12 use Intervention\Image\Encoders\PngEncoder;
13 use Intervention\Image\Interfaces\ImageInterface as InterventionImage;
14 use Intervention\Image\ImageManager;
18 protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
20 public function __construct(
21 protected ImageStorage $storage,
26 * Load gallery thumbnails for a set of images.
27 * @param iterable<Image> $images
29 public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
31 foreach ($images as $image) {
32 $this->loadGalleryThumbnailsForImage($image, $shouldCreate);
37 * Load gallery thumbnails into the given image instance.
39 public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
41 $thumbs = ['gallery' => null, 'display' => null];
44 $thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate);
45 $thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate);
46 } catch (Exception $exception) {
47 // Prevent thumbnail errors from stopping execution
50 $image->setAttribute('thumbs', $thumbs);
54 * Get the thumbnail for an image.
55 * If $keepRatio is true only the width will be used.
56 * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
60 public function resizeToThumbnailUrl(
64 bool $keepRatio = false,
65 bool $shouldCreate = false
67 // Do not resize GIF images where we're not cropping
68 if ($keepRatio && $this->isGif($image)) {
69 return $this->storage->getPublicUrl($image->path);
72 $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
73 $imagePath = $image->path;
74 $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
76 $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
78 // Return path if in cache
79 $cachedThumbPath = Cache::get($thumbCacheKey);
80 if ($cachedThumbPath && !$shouldCreate) {
81 return $this->storage->getPublicUrl($cachedThumbPath);
84 // If thumbnail has already been generated, serve that and cache path
85 $disk = $this->storage->getDisk($image->type);
86 if (!$shouldCreate && $disk->exists($thumbFilePath)) {
87 Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
89 return $this->storage->getPublicUrl($thumbFilePath);
92 $imageData = $disk->get($imagePath);
94 // Do not resize apng images where we're not cropping
95 if ($keepRatio && $this->isApngData($image, $imageData)) {
96 Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
98 return $this->storage->getPublicUrl($image->path);
101 // If not in cache and thumbnail does not exist, generate thumb and cache path
102 $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
103 $disk->put($thumbFilePath, $thumbData, true);
104 Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
106 return $this->storage->getPublicUrl($thumbFilePath);
110 * Resize the image of given data to the specified size, and return the new image data.
111 * Format will remain the same as the input format, unless specified.
113 * @throws ImageUploadException
115 public function resizeImageData(
120 ?string $format = null,
123 $thumb = $this->interventionFromImageData($imageData);
124 } catch (Exception $e) {
125 throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
128 $this->orientImageToOriginalExif($thumb, $imageData);
131 $thumb->scaleDown($width, $height);
133 $thumb->cover($width, $height);
136 $encoder = match ($format) {
137 'png' => new PngEncoder(),
138 default => new AutoEncoder(),
141 $thumbData = (string) $thumb->encode($encoder);
143 // Use original image data if we're keeping the ratio
144 // and the resizing does not save any space.
145 if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
153 * Create an intervention image instance from the given image data.
154 * Performs some manual library usage to ensure image is specifically loaded
155 * from given binary data instead of data being misinterpreted.
157 protected function interventionFromImageData(string $imageData): InterventionImage
159 $manager = new ImageManager(new Driver());
161 return $manager->read($imageData, BinaryImageDecoder::class);
165 * Orientate the given intervention image based upon the given original image data.
166 * Intervention does have an `orientate` method but the exif data it needs is lost before it
167 * can be used (At least when created using binary string data) so we need to do some
168 * implementation on our side to use the original image data.
169 * Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
170 * Copyright (c) Oliver Vogel, MIT License.
172 protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
174 if (!extension_loaded('exif')) {
178 $stream = Utils::streamFor($originalData)->detach();
179 $exif = @exif_read_data($stream);
180 $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
182 switch ($orientation) {
190 $image->rotate(180)->flip();
193 $image->rotate(270)->flip();
199 $image->rotate(90)->flip();
208 * Checks if the image is a gif. Returns true if it is, else false.
210 protected function isGif(Image $image): bool
212 return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
216 * Check if the given image and image data is apng.
218 protected function isApngData(Image $image, string &$imageData): bool
220 $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
225 $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
227 return str_contains($initialHeader, 'acTL');