namespace BookStack\Uploads;
use BookStack\Exceptions\ImageUploadException;
+use Exception;
use GuzzleHttp\Psr7\Utils;
-use Intervention\Image\Exception\NotSupportedException;
-use Intervention\Image\Image as InterventionImage;
+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 ImageManager $intervention
+ 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
*/
- protected function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
- {
+ public function resizeImageData(
+ string $imageData,
+ ?int $width,
+ ?int $height,
+ bool $keepRatio,
+ ?string $format = null,
+ ): string {
try {
- $thumb = $this->intervention->make($imageData);
- } catch (NotSupportedException $e) {
+ $thumb = $this->interventionFromImageData($imageData, $format);
+ } catch (Exception $e) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
- $thumb->resize($width, $height, function ($constraint) {
- $constraint->aspectRatio();
- $constraint->upsize();
- });
+ $thumb->scaleDown($width, $height);
} else {
- $thumb->fit($width, $height);
+ $thumb->cover($width, $height);
}
- $thumbData = (string) $thumb->encode();
+ $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.
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());
+
+ // 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
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');
+ }
}