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