]> BookStack Code Mirror - bookstack/blob - app/Uploads/ImageResizer.php
Drawings: Added class to extract drawio data from png files
[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 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;
18
19 class ImageResizer
20 {
21     protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
22
23     public function __construct(
24         protected ImageStorage $storage,
25     ) {
26     }
27
28     /**
29      * Load gallery thumbnails for a set of images.
30      * @param iterable<Image> $images
31      */
32     public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
33     {
34         foreach ($images as $image) {
35             $this->loadGalleryThumbnailsForImage($image, $shouldCreate);
36         }
37     }
38
39     /**
40      * Load gallery thumbnails into the given image instance.
41      */
42     public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
43     {
44         $thumbs = ['gallery' => null, 'display' => null];
45
46         try {
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
51         }
52
53         $image->setAttribute('thumbs', $thumbs);
54     }
55
56     /**
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.
60      *
61      * @throws Exception
62      */
63     public function resizeToThumbnailUrl(
64         Image $image,
65         ?int $width,
66         ?int $height,
67         bool $keepRatio = false,
68         bool $shouldCreate = false
69     ): ?string {
70         // Do not resize GIF images where we're not cropping
71         if ($keepRatio && $this->isGif($image)) {
72             return $this->storage->getPublicUrl($image->path);
73         }
74
75         $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
76         $imagePath = $image->path;
77         $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
78
79         $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
80
81         // Return path if in cache
82         $cachedThumbPath = Cache::get($thumbCacheKey);
83         if ($cachedThumbPath && !$shouldCreate) {
84             return $this->storage->getPublicUrl($cachedThumbPath);
85         }
86
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);
91
92             return $this->storage->getPublicUrl($thumbFilePath);
93         }
94
95         $imageData = $disk->get($imagePath);
96
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);
100
101             return $this->storage->getPublicUrl($image->path);
102         }
103
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);
108
109         return $this->storage->getPublicUrl($thumbFilePath);
110     }
111
112     /**
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.
115      *
116      * @throws ImageUploadException
117      */
118     public function resizeImageData(
119         string $imageData,
120         ?int $width,
121         ?int $height,
122         bool $keepRatio,
123         ?string $format = null,
124     ): string {
125         try {
126             $thumb = $this->interventionFromImageData($imageData, $format);
127         } catch (Exception $e) {
128             throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
129         }
130
131         $this->orientImageToOriginalExif($thumb, $imageData);
132
133         if ($keepRatio) {
134             $thumb->scaleDown($width, $height);
135         } else {
136             $thumb->cover($width, $height);
137         }
138
139         $encoder = match ($format) {
140             'png' => new PngEncoder(),
141             default => new AutoEncoder(),
142         };
143
144         $thumbData = (string) $thumb->encode($encoder);
145
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)) {
149             return $imageData;
150         }
151
152         return $thumbData;
153     }
154
155     /**
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.
159      */
160     protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
161     {
162         $manager = new ImageManager(
163             new Driver(),
164             autoOrientation: false,
165         );
166
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;
172
173         $image = $manager->read($input, $decoder);
174
175         if ($isGif) {
176             $image->setOrigin(new Origin('image/gif'));
177         }
178
179         return $image;
180     }
181
182     /**
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.
189      */
190     protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
191     {
192         if (!extension_loaded('exif')) {
193             return;
194         }
195
196         $stream = Utils::streamFor($originalData)->detach();
197         $exif = @exif_read_data($stream);
198         $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
199
200         switch ($orientation) {
201             case 2:
202                 $image->flip();
203                 break;
204             case 3:
205                 $image->rotate(180);
206                 break;
207             case 4:
208                 $image->rotate(180)->flip();
209                 break;
210             case 5:
211                 $image->rotate(270)->flip();
212                 break;
213             case 6:
214                 $image->rotate(270);
215                 break;
216             case 7:
217                 $image->rotate(90)->flip();
218                 break;
219             case 8:
220                 $image->rotate(90);
221                 break;
222         }
223     }
224
225     /**
226      * Checks if the image is a gif. Returns true if it is, else false.
227      */
228     protected function isGif(Image $image): bool
229     {
230         return $this->getExtension($image) === 'gif';
231     }
232
233     /**
234      * Get the extension for the given image, normalised to lower-case.
235      */
236     protected function getExtension(Image $image): string
237     {
238         return strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
239     }
240
241     /**
242      * Check if the given image and image data is apng.
243      */
244     protected function isApngData(string &$imageData): bool
245     {
246         $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
247
248         return str_contains($initialHeader, 'acTL');
249     }
250
251     /**
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
254      */
255     protected function isAnimatedAvifData(string &$imageData): bool
256     {
257         $stszPos = strpos($imageData, 'stsz');
258         if ($stszPos === false) {
259             return false;
260         }
261
262         // Look 12 bytes after the start of 'stsz'
263         $start = $stszPos + 12;
264         $end = $start + 4;
265         if ($end > strlen($imageData) - 1) {
266             return false;
267         }
268
269         $data = substr($imageData, $start, 4);
270         $count = unpack('Nvalue', $data)['value'];
271         return $count > 1;
272     }
273
274     /**
275      * Check if the given image is animated.
276      */
277     protected function isAnimated(Image $image, string &$imageData): bool
278     {
279         $extension = strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
280         if ($extension === 'png') {
281             return $this->isApngData($imageData);
282         }
283
284         if ($extension === 'avif') {
285             return $this->isAnimatedAvifData($imageData);
286         }
287
288         return false;
289     }
290 }