3 namespace BookStack\Uploads;
5 use BookStack\Exceptions\ImageUploadException;
7 use GuzzleHttp\Psr7\Utils;
8 use Illuminate\Support\Facades\Cache;
9 use Illuminate\Support\Facades\Log;
10 use Intervention\Image\Decoders\BinaryImageDecoder;
11 use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder;
12 use Intervention\Image\Drivers\Gd\Driver;
13 use Intervention\Image\Encoders\AutoEncoder;
14 use Intervention\Image\Encoders\PngEncoder;
15 use Intervention\Image\Interfaces\ImageInterface as InterventionImage;
16 use Intervention\Image\ImageManager;
17 use Intervention\Image\Origin;
21 protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
23 public function __construct(
24 protected ImageStorage $storage,
29 * Load gallery thumbnails for a set of images.
30 * @param iterable<Image> $images
32 public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
34 foreach ($images as $image) {
35 $this->loadGalleryThumbnailsForImage($image, $shouldCreate);
40 * Load gallery thumbnails into the given image instance.
42 public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
44 $thumbs = ['gallery' => null, 'display' => null];
47 $thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate);
48 $thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate);
49 } catch (Exception $exception) {
50 // Prevent thumbnail errors from stopping execution
53 $image->setAttribute('thumbs', $thumbs);
57 * Get the thumbnail for an image.
58 * If $keepRatio is true only the width will be used.
59 * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
63 public function resizeToThumbnailUrl(
67 bool $keepRatio = false,
68 bool $shouldCreate = false
70 // Do not resize GIF images where we're not cropping
71 if ($keepRatio && $this->isGif($image)) {
72 return $this->storage->getPublicUrl($image->path);
75 $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
76 $imagePath = $image->path;
77 $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
79 $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
81 // Return path if in cache
82 $cachedThumbPath = Cache::get($thumbCacheKey);
83 if ($cachedThumbPath && !$shouldCreate) {
84 return $this->storage->getPublicUrl($cachedThumbPath);
87 // If thumbnail has already been generated, serve that and cache path
88 $disk = $this->storage->getDisk($image->type);
89 if (!$shouldCreate && $disk->exists($thumbFilePath)) {
90 Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
92 return $this->storage->getPublicUrl($thumbFilePath);
95 $imageData = $disk->get($imagePath);
97 // Do not resize animated images where we're not cropping
98 if ($keepRatio && $this->isAnimated($image, $imageData)) {
99 Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
101 return $this->storage->getPublicUrl($image->path);
104 // If not in cache and thumbnail does not exist, generate thumb and cache path
105 $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio, $this->getExtension($image));
106 $disk->put($thumbFilePath, $thumbData, true);
107 Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
109 return $this->storage->getPublicUrl($thumbFilePath);
113 * Resize the image of given data to the specified size, and return the new image data.
114 * Format will remain the same as the input format, unless specified.
116 * @throws ImageUploadException
118 public function resizeImageData(
123 ?string $format = null,
126 $thumb = $this->interventionFromImageData($imageData, $format);
127 } catch (Exception $e) {
128 throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
131 $this->orientImageToOriginalExif($thumb, $imageData);
134 $thumb->scaleDown($width, $height);
136 $thumb->cover($width, $height);
139 $encoder = match ($format) {
140 'png' => new PngEncoder(),
141 default => new AutoEncoder(),
144 $thumbData = (string) $thumb->encode($encoder);
146 // Use original image data if we're keeping the ratio
147 // and the resizing does not save any space.
148 if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
156 * Create an intervention image instance from the given image data.
157 * Performs some manual library usage to ensure image is specifically loaded
158 * from given binary data instead of data being misinterpreted.
160 protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
162 $manager = new ImageManager(
164 autoOrientation: false,
167 // Ensure gif images are decoded natively instead of deferring to intervention GIF
168 // handling since we don't need the added animation support.
169 $isGif = $fileType === 'gif';
170 $decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
171 $input = $isGif ? @imagecreatefromstring($imageData) : $imageData;
173 $image = $manager->read($input, $decoder);
176 $image->setOrigin(new Origin('image/gif'));
183 * Orientate the given intervention image based upon the given original image data.
184 * Intervention does have an `orientate` method but the exif data it needs is lost before it
185 * can be used (At least when created using binary string data) so we need to do some
186 * implementation on our side to use the original image data.
187 * Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
188 * Copyright (c) Oliver Vogel, MIT License.
190 protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
192 if (!extension_loaded('exif')) {
196 $stream = Utils::streamFor($originalData)->detach();
197 $exif = @exif_read_data($stream);
198 $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
200 switch ($orientation) {
208 $image->rotate(180)->flip();
211 $image->rotate(270)->flip();
217 $image->rotate(90)->flip();
226 * Checks if the image is a gif. Returns true if it is, else false.
228 protected function isGif(Image $image): bool
230 return $this->getExtension($image) === 'gif';
234 * Get the extension for the given image, normalised to lower-case.
236 protected function getExtension(Image $image): string
238 return strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
242 * Check if the given image and image data is apng.
244 protected function isApngData(string &$imageData): bool
246 $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
248 return str_contains($initialHeader, 'acTL');
252 * Check if the given avif image data represents an animated image.
253 * This is based up the answer here: https://stackoverflow.com/a/79457313
255 protected function isAnimatedAvifData(string &$imageData): bool
257 $stszPos = strpos($imageData, 'stsz');
258 if ($stszPos === false) {
262 // Look 12 bytes after the start of 'stsz'
263 $start = $stszPos + 12;
265 if ($end > strlen($imageData) - 1) {
269 $data = substr($imageData, $start, 4);
270 $count = unpack('Nvalue', $data)['value'];
275 * Check if the given image is animated.
277 protected function isAnimated(Image $image, string &$imageData): bool
279 $extension = strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
280 if ($extension === 'png') {
281 return $this->isApngData($imageData);
284 if ($extension === 'avif') {
285 return $this->isAnimatedAvifData($imageData);