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