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