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