]> BookStack Code Mirror - bookstack/blob - app/Uploads/ImageResizer.php
HTML: Aligned and standardised DOMDocument usage
[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      *
109      * @throws ImageUploadException
110      */
111     public function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
112     {
113         try {
114             $thumb = $this->intervention->make($imageData);
115         } catch (Exception $e) {
116             throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
117         }
118
119         $this->orientImageToOriginalExif($thumb, $imageData);
120
121         if ($keepRatio) {
122             $thumb->resize($width, $height, function ($constraint) {
123                 $constraint->aspectRatio();
124                 $constraint->upsize();
125             });
126         } else {
127             $thumb->fit($width, $height);
128         }
129
130         $thumbData = (string) $thumb->encode();
131
132         // Use original image data if we're keeping the ratio
133         // and the resizing does not save any space.
134         if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
135             return $imageData;
136         }
137
138         return $thumbData;
139     }
140
141     /**
142      * Orientate the given intervention image based upon the given original image data.
143      * Intervention does have an `orientate` method but the exif data it needs is lost before it
144      * can be used (At least when created using binary string data) so we need to do some
145      * implementation on our side to use the original image data.
146      * Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
147      * Copyright (c) Oliver Vogel, MIT License.
148      */
149     protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
150     {
151         if (!extension_loaded('exif')) {
152             return;
153         }
154
155         $stream = Utils::streamFor($originalData)->detach();
156         $exif = @exif_read_data($stream);
157         $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
158
159         switch ($orientation) {
160             case 2:
161                 $image->flip();
162                 break;
163             case 3:
164                 $image->rotate(180);
165                 break;
166             case 4:
167                 $image->rotate(180)->flip();
168                 break;
169             case 5:
170                 $image->rotate(270)->flip();
171                 break;
172             case 6:
173                 $image->rotate(270);
174                 break;
175             case 7:
176                 $image->rotate(90)->flip();
177                 break;
178             case 8:
179                 $image->rotate(90);
180                 break;
181         }
182     }
183
184     /**
185      * Checks if the image is a gif. Returns true if it is, else false.
186      */
187     protected function isGif(Image $image): bool
188     {
189         return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
190     }
191
192     /**
193      * Check if the given image and image data is apng.
194      */
195     protected function isApngData(Image $image, string &$imageData): bool
196     {
197         $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
198         if (!$isPng) {
199             return false;
200         }
201
202         $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
203
204         return str_contains($initialHeader, 'acTL');
205     }
206 }