From 659b39a2e34e59153c8c9a8a07b25ebb370a5df3 Mon Sep 17 00:00:00 2001 From: "Boris D. Teoharov" Date: Sun, 26 Mar 2023 02:36:38 +0200 Subject: [PATCH] Implementing the "Flexible thumbnail formats" feature. See: https://github.com/BKWLD/croppa/issues/212. WIP --- .gitignore | 9 ++ src/Handler.php | 172 +++++++++++++++++++++++++++++++-------- src/Image.php | 23 ++---- src/ParameterBucket.php | 95 +++++++++++++++++++++ src/URL.php | 10 ++- tests/TestUrlParsing.php | 7 ++ 6 files changed, 263 insertions(+), 53 deletions(-) create mode 100644 src/ParameterBucket.php diff --git a/.gitignore b/.gitignore index 59a7578..91042a6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,12 @@ composer.lock /node_modules /yarn.lock .idea + +### PHPUnit ### +# Covers PHPUnit +# Reference: https://phpunit.de/ + +# Generated files +.phpunit.result.cache +.phpunit.cache + diff --git a/src/Handler.php b/src/Handler.php index 76ce173..4ff4c0f 100644 --- a/src/Handler.php +++ b/src/Handler.php @@ -2,8 +2,11 @@ namespace Bkwld\Croppa; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Routing\Redirector; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -35,7 +38,12 @@ class Handler extends Controller /** * Dependency injection. */ - public function __construct(URL $url, Storage $storage, Request $request, ?array $config = null) + public function __construct( + URL $url, + Storage $storage, + Request $request, + ?array $config = null + ) { $this->url = $url; $this->storage = $storage; @@ -46,9 +54,12 @@ public function __construct(URL $url, Storage $storage, Request $request, ?array /** * Handles a Croppa style route. * + * @param string $requestPath + * @return BinaryFileResponse|Application|Redirector|RedirectResponse * @throws Exception */ - public function handle(string $requestPath): mixed + public function handle(string $requestPath): + BinaryFileResponse|Application|Redirector|RedirectResponse { // Validate the signing token $token = $this->url->signingToken($requestPath); @@ -61,7 +72,12 @@ public function handle(string $requestPath): mixed // Redirect to remote crops ... if ($this->storage->cropsAreRemote()) { - return redirect(app('filesystem')->disk($this->config['crops_disk'])->url($cropPath), 301); + return redirect( + app('filesystem') + ->disk($this->config['crops_disk']) + ->url($cropPath), + 301 + ); // ... or echo the image data to the browser } $absolutePath = $this->storage->getLocalCropPath($cropPath); @@ -73,49 +89,140 @@ public function handle(string $requestPath): mixed /** * Render image. Return the path to the crop relative to the storage disk. + * @param string $requestPath + * @return string|null + * @throws Exception */ public function render(string $requestPath): ?string { - // Get crop path relative to it’s dir - $cropPath = $this->url->relativePath($requestPath); + $params = ParameterBucket::createFrom($requestPath); + $urlOptions = $params?->getUrlOptions() ?? []; + $configOptions = $this->url->config($urlOptions); + + $cropPath = $this->getRelativeCropPath( + $requestPath, + $configOptions + ); - // If the crops_disk is a remote disk and if the crop has already been - // created. If it has, just return that path. - if ($this->storage->cropsAreRemote() && $this->storage->cropExists($cropPath)) { + if ($this->shouldReturnExistingCrop($cropPath)) { return $cropPath; } - // Parse the path. In the case there is an error (the pattern on the route - // SHOULD have caught all errors with the pattern), return null. - if (!$params = $this->url->parse($requestPath)) { + if (!$params) { return null; } - list($path, $width, $height, $options) = $params; - // Check if there are too many crops already + $this->checkCropLimit($params->getPath()); + $this->increaseMemoryLimitIfNeeded(); + $image = $this->buildImage($params); + $this->processAndWriteImage($image, $cropPath, $params); + + return $cropPath; + } + + /** + * Get crop path relative to its directory. + * @throws Exception + */ + protected function getRelativeCropPath( + string $requestPath, + array $options + ): string + { + $relativePath = $this->url->relativePath($requestPath); + + $format = data_get($options, 'format'); + + if ($format) { + $relativePath = $this->replaceOriginalFileSuffix( + $relativePath, + $format + ); + } + + return $relativePath; + } + + protected function replaceOriginalFileSuffix( + string $path, + string $suffix + ): string + { + $dirname = pathinfo($path, PATHINFO_DIRNAME); + $fileName = pathinfo($path, PATHINFO_FILENAME); + + return sprintf( + '%s/%s.%s', + $dirname, + $fileName, + $suffix + ); + } + + /** + * Determine if the existing crop should be returned. + * @param string $cropPath + * @return bool + */ + protected function shouldReturnExistingCrop(string $cropPath): bool + { + return $this->storage->cropsAreRemote() && + $this->storage->cropExists($cropPath); + } + + /** + * Check if there are too many crops already. + * @param string $path + * @throws Exception + */ + protected function checkCropLimit(string $path): void + { if ($this->storage->tooManyCrops($path)) { throw new Exception('Croppa: Max crops'); } + } - // Increase memory limit, cause some images require a lot to resize + /** + * Increase memory limit if needed. + */ + protected function increaseMemoryLimitIfNeeded(): void + { if ($this->config['memory_limit'] !== null) { ini_set('memory_limit', $this->config['memory_limit']); } + } - // Build a new image using fetched image data - $image = new Image( - $this->storage->path($path), - $this->url->config($options) + /** + * Build a new image using fetched image data. + */ + protected function buildImage(ParameterBucket $params): Image + { + return new Image( + $this->storage->path($params->getPath()), + $params->config() + ); + } + + /** + * Process the image and write its data to disk. + * @throws Exception + */ + protected function processAndWriteImage( + Image $image, + string $cropPath, + ParameterBucket $params + ): void + { + $newImage = $image->process( + $params->getWidth(), + $params->getHeight(), + $params->getUrlOptions() ); - // Process the image and write its data to disk $this->storage->writeCrop( $cropPath, - $image->process($width, $height, $options)->get() + $newImage->get() ); - - // Return the path to the crop, relative to the storage disk - return $cropPath; } /** @@ -123,18 +230,11 @@ public function render(string $requestPath): ?string */ public function getContentType(string $path): string { - switch (pathinfo($path, PATHINFO_EXTENSION)) { - case 'gif': - return 'image/gif'; - - case 'png': - return 'image/png'; - - case 'webp': - return 'image/webp'; - - default: - return 'image/jpeg'; - } + return match (pathinfo($path, PATHINFO_EXTENSION)) { + 'gif' => 'image/gif', + 'png' => 'image/png', + 'webp' => 'image/webp', + default => 'image/jpeg', + }; } } diff --git a/src/Image.php b/src/Image.php index b55516f..0f62d40 100644 --- a/src/Image.php +++ b/src/Image.php @@ -67,7 +67,7 @@ public function process(?int $width, ?int $height, array $options = []): self } /** - * Turn on interlacing to make progessive JPEG files. + * Turn on interlacing to make progressive JPEG files. */ public function interlace(): self { @@ -245,7 +245,7 @@ public function pad(?int $width, ?int $height, array $options): self } /** - * Apply filters that have been defined in the config as seperate classes. + * Apply filters that have been defined in the config as separate classes. */ public function applyFilters(array $options): self { @@ -260,19 +260,12 @@ public function applyFilters(array $options): self private function getFormatFromPath(string $path): string { - switch (pathinfo($path, PATHINFO_EXTENSION)) { - case 'gif': - return 'gif'; - - case 'png': - return 'png'; - - case 'webp': - return 'webp'; - - default: - return 'jpg'; - } + return match (pathinfo($path, PATHINFO_EXTENSION)) { + 'gif' => 'gif', + 'png' => 'png', + 'webp' => 'webp', + default => 'jpg', + }; } /** diff --git a/src/ParameterBucket.php b/src/ParameterBucket.php new file mode 100644 index 0000000..6e19193 --- /dev/null +++ b/src/ParameterBucket.php @@ -0,0 +1,95 @@ +path = $path; + $this->width = $width; + $this->height = $height; + $this->urlOptions = $urlOptions; + } + + /** + * @param string $requestPath + * @return static|null + * @throws Exception + */ + public static function createFrom(string $requestPath): ?static + { + $url = app(URL::class); + $params = $url->parse($requestPath); + + if (!$params) { + return null; + } + + [$path, $width, $height, $options] = $params; + + return new self( + $path, + $width, + $height, + $options + ); + } + + /** + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * @return int|null + */ + public function getWidth(): ?int + { + return $this->width; + } + + /** + * @return int|null + */ + public function getHeight(): ?int + { + return $this->height; + } + + /** + * @return array + */ + public function getUrlOptions(): array + { + return $this->urlOptions; + } + + /** + * Take options in the URL and options from the config file + * and produce a config array. + */ + public function config(): array + { + return $this->getUrlHelper()->config($this->getUrlOptions()); + } + + protected function getUrlHelper() { + $this->urlHelper = $this->urlHelper ?? app(URL::class); + return $this->urlHelper; + } +} diff --git a/src/URL.php b/src/URL.php index 973faf7..87b309a 100644 --- a/src/URL.php +++ b/src/URL.php @@ -8,7 +8,7 @@ class URL { /** - * The pattern used to indetify a request path as a Croppa-style URL + * The pattern used to identify a request path as a Croppa-style URL * https://github.com/BKWLD/croppa/wiki/Croppa-regex-pattern. * * @return string @@ -128,6 +128,7 @@ public function routePattern(): string * Parse a request path into Croppa instructions. * * @return array|bool + * @throws Exception */ public function parse(string $request) { @@ -146,6 +147,7 @@ public function parse(string $request) /** * Take a URL or path to an image and get the path relative to the src and * crops dirs by using the `path` config regex. + * @throws Exception */ public function relativePath(string $url): string { @@ -188,12 +190,16 @@ public function options(string $optionParams): array unset($options['filters']); } + if (isset($options['format'])) { + $options['format'] = data_get($options, 'format.0'); + } + // Return new options array return $options; } /** - * Build filter class instancees. + * Build filter class instances. * * @return null|array Array of filter instances */ diff --git a/tests/TestUrlParsing.php b/tests/TestUrlParsing.php index 57d0125..509f597 100644 --- a/tests/TestUrlParsing.php +++ b/tests/TestUrlParsing.php @@ -102,4 +102,11 @@ public function testCropsInSubDirectory() 'file.jpg', 200, 100, [], ], $url->parse('images/crops/file-200x100.jpg')); } + + public function testFormat() + { + $this->assertEquals([ + '1/2/file.jpg', 200, 100, ['format' => 'png'], + ], $this->url->parse('uploads/1/2/file-200x100-format(png).jpg')); + } }