]> BookStack Code Mirror - bookstack/blob - app/Uploads/ImageResizer.php
Meta: Added lexical licensing info and added TS/JS CI testing
[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\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;
15
16 class ImageResizer
17 {
18     protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
19
20     public function __construct(
21         protected ImageStorage $storage,
22     ) {
23     }
24
25     /**
26      * Load gallery thumbnails for a set of images.
27      * @param iterable<Image> $images
28      */
29     public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
30     {
31         foreach ($images as $image) {
32             $this->loadGalleryThumbnailsForImage($image, $shouldCreate);
33         }
34     }
35
36     /**
37      * Load gallery thumbnails into the given image instance.
38      */
39     public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
40     {
41         $thumbs = ['gallery' => null, 'display' => null];
42
43         try {
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
48         }
49
50         $image->setAttribute('thumbs', $thumbs);
51     }
52
53     /**
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.
57      *
58      * @throws Exception
59      */
60     public function resizeToThumbnailUrl(
61         Image $image,
62         ?int $width,
63         ?int $height,
64         bool $keepRatio = false,
65         bool $shouldCreate = false
66     ): ?string {
67         // Do not resize GIF images where we're not cropping
68         if ($keepRatio && $this->isGif($image)) {
69             return $this->storage->getPublicUrl($image->path);
70         }
71
72         $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
73         $imagePath = $image->path;
74         $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
75
76         $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
77
78         // Return path if in cache
79         $cachedThumbPath = Cache::get($thumbCacheKey);
80         if ($cachedThumbPath && !$shouldCreate) {
81             return $this->storage->getPublicUrl($cachedThumbPath);
82         }
83
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);
88
89             return $this->storage->getPublicUrl($thumbFilePath);
90         }
91
92         $imageData = $disk->get($imagePath);
93
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);
97
98             return $this->storage->getPublicUrl($image->path);
99         }
100
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);
105
106         return $this->storage->getPublicUrl($thumbFilePath);
107     }
108
109     /**
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.
112      *
113      * @throws ImageUploadException
114      */
115     public function resizeImageData(
116         string $imageData,
117         ?int $width,
118         ?int $height,
119         bool $keepRatio,
120         ?string $format = null,
121     ): string {
122         try {
123             $thumb = $this->interventionFromImageData($imageData);
124         } catch (Exception $e) {
125             throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
126         }
127
128         $this->orientImageToOriginalExif($thumb, $imageData);
129
130         if ($keepRatio) {
131             $thumb->scaleDown($width, $height);
132         } else {
133             $thumb->cover($width, $height);
134         }
135
136         $encoder = match ($format) {
137             'png' => new PngEncoder(),
138             default => new AutoEncoder(),
139         };
140
141         $thumbData = (string) $thumb->encode($encoder);
142
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)) {
146             return $imageData;
147         }
148
149         return $thumbData;
150     }
151
152     /**
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.
156      */
157     protected function interventionFromImageData(string $imageData): InterventionImage
158     {
159         $manager = new ImageManager(new Driver());
160
161         return $manager->read($imageData, BinaryImageDecoder::class);
162     }
163
164     /**
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.
171      */
172     protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
173     {
174         if (!extension_loaded('exif')) {
175             return;
176         }
177
178         $stream = Utils::streamFor($originalData)->detach();
179         $exif = @exif_read_data($stream);
180         $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
181
182         switch ($orientation) {
183             case 2:
184                 $image->flip();
185                 break;
186             case 3:
187                 $image->rotate(180);
188                 break;
189             case 4:
190                 $image->rotate(180)->flip();
191                 break;
192             case 5:
193                 $image->rotate(270)->flip();
194                 break;
195             case 6:
196                 $image->rotate(270);
197                 break;
198             case 7:
199                 $image->rotate(90)->flip();
200                 break;
201             case 8:
202                 $image->rotate(90);
203                 break;
204         }
205     }
206
207     /**
208      * Checks if the image is a gif. Returns true if it is, else false.
209      */
210     protected function isGif(Image $image): bool
211     {
212         return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
213     }
214
215     /**
216      * Check if the given image and image data is apng.
217      */
218     protected function isApngData(Image $image, string &$imageData): bool
219     {
220         $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
221         if (!$isPng) {
222             return false;
223         }
224
225         $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
226
227         return str_contains($initialHeader, 'acTL');
228     }
229 }