]> BookStack Code Mirror - bookstack/blob - app/Uploads/ImageResizer.php
Thumbnails: Fixed thumnail orientation
[bookstack] / app / Uploads / ImageResizer.php
1 <?php
2
3 namespace BookStack\Uploads;
4
5 use BookStack\Exceptions\ImageUploadException;
6 use Exception;
7 use GuzzleHttp\Psr7\Utils;
8 use Illuminate\Support\Facades\Cache;
9 use Intervention\Image\Decoders\BinaryImageDecoder;
10 use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder;
11 use Intervention\Image\Drivers\Gd\Driver;
12 use Intervention\Image\Encoders\AutoEncoder;
13 use Intervention\Image\Encoders\PngEncoder;
14 use Intervention\Image\Interfaces\ImageInterface as InterventionImage;
15 use Intervention\Image\ImageManager;
16 use Intervention\Image\Origin;
17
18 class ImageResizer
19 {
20     protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
21
22     public function __construct(
23         protected ImageStorage $storage,
24     ) {
25     }
26
27     /**
28      * Load gallery thumbnails for a set of images.
29      * @param iterable<Image> $images
30      */
31     public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
32     {
33         foreach ($images as $image) {
34             $this->loadGalleryThumbnailsForImage($image, $shouldCreate);
35         }
36     }
37
38     /**
39      * Load gallery thumbnails into the given image instance.
40      */
41     public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
42     {
43         $thumbs = ['gallery' => null, 'display' => null];
44
45         try {
46             $thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate);
47             $thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate);
48         } catch (Exception $exception) {
49             // Prevent thumbnail errors from stopping execution
50         }
51
52         $image->setAttribute('thumbs', $thumbs);
53     }
54
55     /**
56      * Get the thumbnail for an image.
57      * If $keepRatio is true only the width will be used.
58      * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
59      *
60      * @throws Exception
61      */
62     public function resizeToThumbnailUrl(
63         Image $image,
64         ?int $width,
65         ?int $height,
66         bool $keepRatio = false,
67         bool $shouldCreate = false
68     ): ?string {
69         // Do not resize GIF images where we're not cropping
70         if ($keepRatio && $this->isGif($image)) {
71             return $this->storage->getPublicUrl($image->path);
72         }
73
74         $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
75         $imagePath = $image->path;
76         $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
77
78         $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
79
80         // Return path if in cache
81         $cachedThumbPath = Cache::get($thumbCacheKey);
82         if ($cachedThumbPath && !$shouldCreate) {
83             return $this->storage->getPublicUrl($cachedThumbPath);
84         }
85
86         // If thumbnail has already been generated, serve that and cache path
87         $disk = $this->storage->getDisk($image->type);
88         if (!$shouldCreate && $disk->exists($thumbFilePath)) {
89             Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
90
91             return $this->storage->getPublicUrl($thumbFilePath);
92         }
93
94         $imageData = $disk->get($imagePath);
95
96         // Do not resize apng images where we're not cropping
97         if ($keepRatio && $this->isApngData($image, $imageData)) {
98             Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
99
100             return $this->storage->getPublicUrl($image->path);
101         }
102
103         // If not in cache and thumbnail does not exist, generate thumb and cache path
104         $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio, $this->getExtension($image));
105         $disk->put($thumbFilePath, $thumbData, true);
106         Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
107
108         return $this->storage->getPublicUrl($thumbFilePath);
109     }
110
111     /**
112      * Resize the image of given data to the specified size, and return the new image data.
113      * Format will remain the same as the input format, unless specified.
114      *
115      * @throws ImageUploadException
116      */
117     public function resizeImageData(
118         string $imageData,
119         ?int $width,
120         ?int $height,
121         bool $keepRatio,
122         ?string $format = null,
123     ): string {
124         try {
125             $thumb = $this->interventionFromImageData($imageData, $format);
126         } catch (Exception $e) {
127             throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
128         }
129
130         $this->orientImageToOriginalExif($thumb, $imageData);
131
132         if ($keepRatio) {
133             $thumb->scaleDown($width, $height);
134         } else {
135             $thumb->cover($width, $height);
136         }
137
138         $encoder = match ($format) {
139             'png' => new PngEncoder(),
140             default => new AutoEncoder(),
141         };
142
143         $thumbData = (string) $thumb->encode($encoder);
144
145         // Use original image data if we're keeping the ratio
146         // and the resizing does not save any space.
147         if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
148             return $imageData;
149         }
150
151         return $thumbData;
152     }
153
154     /**
155      * Create an intervention image instance from the given image data.
156      * Performs some manual library usage to ensure image is specifically loaded
157      * from given binary data instead of data being misinterpreted.
158      */
159     protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
160     {
161         $manager = new ImageManager(
162             new Driver(),
163             autoOrientation: false,
164         );
165
166         // Ensure gif images are decoded natively instead of deferring to intervention GIF
167         // handling since we don't need the added animation support.
168         $isGif = $fileType === 'gif';
169         $decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
170         $input = $isGif ? @imagecreatefromstring($imageData) : $imageData;
171
172         $image = $manager->read($input, $decoder);
173
174         if ($isGif) {
175             $image->setOrigin(new Origin('image/gif'));
176         }
177
178         return $image;
179     }
180
181     /**
182      * Orientate the given intervention image based upon the given original image data.
183      * Intervention does have an `orientate` method but the exif data it needs is lost before it
184      * can be used (At least when created using binary string data) so we need to do some
185      * implementation on our side to use the original image data.
186      * Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
187      * Copyright (c) Oliver Vogel, MIT License.
188      */
189     protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
190     {
191         if (!extension_loaded('exif')) {
192             return;
193         }
194
195         $stream = Utils::streamFor($originalData)->detach();
196         $exif = @exif_read_data($stream);
197         $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
198
199         switch ($orientation) {
200             case 2:
201                 $image->flip();
202                 break;
203             case 3:
204                 $image->rotate(180);
205                 break;
206             case 4:
207                 $image->rotate(180)->flip();
208                 break;
209             case 5:
210                 $image->rotate(270)->flip();
211                 break;
212             case 6:
213                 $image->rotate(270);
214                 break;
215             case 7:
216                 $image->rotate(90)->flip();
217                 break;
218             case 8:
219                 $image->rotate(90);
220                 break;
221         }
222     }
223
224     /**
225      * Checks if the image is a gif. Returns true if it is, else false.
226      */
227     protected function isGif(Image $image): bool
228     {
229         return $this->getExtension($image) === 'gif';
230     }
231
232     /**
233      * Get the extension for the given image, normalised to lower-case.
234      */
235     protected function getExtension(Image $image): string
236     {
237         return strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
238     }
239
240     /**
241      * Check if the given image and image data is apng.
242      */
243     protected function isApngData(Image $image, string &$imageData): bool
244     {
245         $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
246         if (!$isPng) {
247             return false;
248         }
249
250         $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
251
252         return str_contains($initialHeader, 'acTL');
253     }
254 }