diff --git a/composer.json b/composer.json index 73d1c47..4d092ae 100755 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ "nette/http": "^3.1", "nette/schema": "^1.0", "nette/utils": "^3.2 || ^4.0", - "imagine/imagine": "^1.2.3" + "imagine/imagine": "^1.3.3" }, "require-dev": { "phpstan/extension-installer": "^1.0", diff --git a/src/DI/ResizerConfig.php b/src/DI/ResizerConfigDTO.php similarity index 56% rename from src/DI/ResizerConfig.php rename to src/DI/ResizerConfigDTO.php index db04350..67a9356 100644 --- a/src/DI/ResizerConfig.php +++ b/src/DI/ResizerConfigDTO.php @@ -3,16 +3,25 @@ namespace Nelson\Resizer\DI; -final class ResizerConfig +final class ResizerConfigDTO { + /** @var string 'Gd'|'Imagick'|'Gmagick' */ public string $library; public bool $interlace = true; public string $wwwDir; public string $tempDir; public string $cache = '/resizer/'; public bool $upgradeJpg2Webp = true; + public bool $upgradePng2Webp = true; + public bool $upgradeJpg2Avif = true; + public bool $upgradePng2Avif = true; + public bool $isWebpSupportedByServer = false; + public bool $isAvifSupportedByServer = false; public bool $strip = true; + /** @var int<0, 100> */ + public int $qualityAvif; + /** @var int<0, 100> */ public int $qualityWebp; diff --git a/src/DI/ResizerExtension.php b/src/DI/ResizerExtension.php index 2fef1cb..821be7a 100755 --- a/src/DI/ResizerExtension.php +++ b/src/DI/ResizerExtension.php @@ -8,7 +8,9 @@ use Imagick; use Latte\Engine; use Nelson\Resizer\Latte\ResizerExtension as LatteResizerExtension; +use Nelson\Resizer\OutputFormat; use Nelson\Resizer\Resizer; +use Nelson\Resizer\ResizerConfig; use Nette\Application\IPresenterFactory; use Nette\DI\CompilerExtension; use Nette\DI\Definitions\Definition; @@ -26,7 +28,7 @@ final class ResizerExtension extends CompilerExtension public function getConfigSchema(): Schema { - return Expect::from(new ResizerConfig, [ + return Expect::from(new ResizerConfigDTO, [ 'library' => Expect::anyOf('Gd', 'Imagick', 'Gmagick')->default('Imagick'), 'qualityWebp' => Expect::int(75)->min(0)->max(100), 'qualityJpeg' => Expect::int(75)->min(0)->max(100), @@ -38,13 +40,20 @@ public function getConfigSchema(): Schema public function loadConfiguration(): void { $builder = $this->getContainerBuilder(); - /** @var ResizerConfig $config */ + /** @var ResizerConfigDTO $config */ $config = $this->getConfig(); + $config->isWebpSupportedByServer = $this->isWebpSupported($config); + $config->isAvifSupportedByServer = $this->isAvifSupported($config); + + $builder->addDefinition($this->prefix('config')) + ->setFactory(ResizerConfig::class) + ->setArgument('config', $config); + + $builder->addDefinition($this->prefix('output.format')) + ->setFactory(OutputFormat::class); $builder->addDefinition($this->prefix('default')) - ->setType(Resizer::class) - ->setArgument('config', $config) - ->setArgument('isWebpSupportedByServer', $this->isWebpSupported($config)); + ->setType(Resizer::class); } @@ -76,21 +85,21 @@ public static function getResizerLink(?bool $absolute = true): string } - private function isWebpSupported(ResizerConfig $config): bool + private function isFormatSupported(ResizerConfigDTO $config, string $gd, string $imagick, string $gmagick): bool { $support = false; switch ($config->library) { case 'Gd': - $support = function_exists('gd_info') && !empty(gd_info()['WebP Support']); + $support = function_exists('gd_info') && !empty(gd_info()[$gd]); break; case 'Imagick': - $support = extension_loaded('imagick') && in_array('WEBP', Imagick::queryFormats(), true); + $support = extension_loaded('imagick') && in_array($imagick, Imagick::queryFormats(), true); break; case 'Gmagick': - $support = extension_loaded('gmagick') && in_array('WEBP', (new Gmagick)->queryformats(), true); + $support = extension_loaded('gmagick') && in_array($gmagick, (new Gmagick)->queryformats(), true); break; } @@ -98,6 +107,18 @@ private function isWebpSupported(ResizerConfig $config): bool } + private function isWebpSupported(ResizerConfigDTO $config): bool + { + return $this->isFormatSupported($config, 'WebP Support', 'WEBP', 'WEBP'); + } + + + private function isAvifSupported(ResizerConfigDTO $config): bool + { + return $this->isFormatSupported($config, 'AVIF Support', 'AVIF', 'AVIF'); + } + + /** * @return ServiceDefinition * @throws Exception diff --git a/src/IResizer.php b/src/IResizer.php index 8c938c7..0af5310 100644 --- a/src/IResizer.php +++ b/src/IResizer.php @@ -9,7 +9,4 @@ public function process(string $path, ?string $params, ?string $format = null): public function getSourceImagePath(string $path): string; - public function canUpgradeJpg2Webp(): bool; - - public function isWebpSupportedByServer(): bool; } diff --git a/src/OutputFormat.php b/src/OutputFormat.php new file mode 100644 index 0000000..11e22a7 --- /dev/null +++ b/src/OutputFormat.php @@ -0,0 +1,129 @@ +browserSupportsAvif = $this->browserSupports(Resizer::MIME_TYPE_AVIF); + $this->browserSupportsWebp = $this->browserSupports(Resizer::MIME_TYPE_WEBP); + } + + + public function getOutputFormat(string $file, ?string $format = null): ?string + { + if ($format === null) { + $suffix = $this->getFileFormat($file); + + $format = match ($suffix) { + Resizer::FORMAT_SUFFIX_JPG => $this->getOutputFormatForJpg(), + Resizer::FORMAT_SUFFIX_PNG => $this->getOutputFormatForPng(), + default => $suffix, + }; + } + + $this->isFormatSupported($format); + return $format; + } + + + private function getOutputFormatForJpg(): string + { + return match (true) { + $this->canServeAvif() && $this->config->canUpgradeJpg2Avif() => Resizer::FORMAT_SUFFIX_AVIF, + $this->canServeWebp() && $this->config->canUpgradeJpg2Webp() => Resizer::FORMAT_SUFFIX_WEBP, + default => Resizer::FORMAT_SUFFIX_JPG, + }; + } + + + private function getOutputFormatForPng(): string + { + return match (true) { + $this->canServeAvif() && $this->config->canUpgradePng2Avif() => Resizer::FORMAT_SUFFIX_AVIF, + $this->canServeWebp() && $this->config->canUpgradePng2Webp() => Resizer::FORMAT_SUFFIX_WEBP, + default => Resizer::FORMAT_SUFFIX_PNG, + }; + } + + + private function canServeWebp(): bool + { + dump($this->config->isWebpSupportedByServer()); + return $this->config->isWebpSupportedByServer() && $this->browserSupportsWebp; + } + + + private function canServeAvif(): bool + { + return $this->config->isAvifSupportedByServer() && $this->browserSupportsAvif; + } + + + private function isFormatSupported(string $format): void + { + if (!in_array(strtolower($format), Resizer::SUPPORTED_FORMATS, true)) { + throw new Exception(sprintf( + "Format '%s' not supported (%s).", + $format, implode(', ', Resizer::SUPPORTED_FORMATS), + )); + } + } + + + private function browserSupports(string $format): bool + { + $accept = (string) $this->request->getHeader('accept'); + return str_contains($accept, $format); + } + + + /** @param string|array $suffixes */ + private function isFileOfFormat(string $path, string|array $suffixes): bool + { + if (is_string($suffixes)) { + $suffixes = [$suffixes]; + } + + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + return in_array($ext, $suffixes, true); + } + + + private function isFileJpg(string $path): bool + { + return $this->isFileOfFormat($path, Resizer::FORMAT_SUFFIXES_JPG); + } + + + private function isFilePng(string $path): bool + { + return $this->isFileOfFormat($path, Resizer::FORMAT_SUFFIX_PNG); + } + + + private function getFileFormat(string $file): string + { + return match (true) { + $this->isFileJpg($file) => Resizer::FORMAT_SUFFIX_JPG, + $this->isFilePng($file) => Resizer::FORMAT_SUFFIX_PNG, + default => pathinfo($file, PATHINFO_EXTENSION), + }; + } + +} diff --git a/src/Presenters/ResizePresenter.php b/src/Presenters/ResizePresenter.php index 298b306..e3013cc 100644 --- a/src/Presenters/ResizePresenter.php +++ b/src/Presenters/ResizePresenter.php @@ -19,7 +19,9 @@ final class ResizePresenter extends Presenter private IResponse $response; - public function __construct(private IResizer $resizer) + public function __construct( + private IResizer $resizer, + ) { parent::__construct(); } @@ -34,6 +36,7 @@ public function startup(): void // Get rid of troublemaking headers $this->response->setHeader('Pragma', ''); $this->response->setHeader('Cache-Control', ''); + $this->getSession()->close(); } @@ -46,7 +49,7 @@ public function actionDefault( $image = $this->resizer->process( $file, $params, - $this->getOutputFormat($file, $format), + $format, ); } catch (Exception $e) { $this->error($e->getMessage()); @@ -91,37 +94,4 @@ private function getEtag(string $srcFile, string $dstFile): string return filemtime($srcFile) . '-' . md5($dstFile); } - - private function getOutputFormat(string $file, ?string $format = null): ?string - { - if ( - empty($format) && - $this->resizer->canUpgradeJpg2Webp() && - $this->resizer->isWebpSupportedByServer() && - $this->browserSupportsWebp() && - $this->isFormatJpg($this->getImageFormat($file)) - ) { - return 'webp'; - } - return $format; - } - - - private function browserSupportsWebp(): bool - { - $accept = (string) $this->request->getHeader('accept'); - return is_int(strpos($accept, 'image/webp')); - } - - - private function getImageFormat(string $path): string - { - return pathinfo($path, PATHINFO_EXTENSION); - } - - - private function isFormatJpg(string $format): bool - { - return in_array(strtolower($format), ['jpeg', 'jpg'], true); - } } diff --git a/src/Resizer.php b/src/Resizer.php index b1fdae5..8cb6cc3 100644 --- a/src/Resizer.php +++ b/src/Resizer.php @@ -7,8 +7,6 @@ use Imagine\Image\AbstractImagine; use Imagine\Image\Box; use Imagine\Image\ImageInterface; -use LogicException; -use Nelson\Resizer\DI\ResizerConfig; use Nelson\Resizer\Exceptions\ImageNotFoundOrReadableException; use Nelson\Resizer\Exceptions\SecurityException; use Nette\SmartObject; @@ -18,7 +16,21 @@ final class Resizer implements IResizer { use SmartObject; - private const SUPPORTED_FORMATS = [ + public const FORMAT_SUFFIX_JPG = 'jpg'; + public const FORMAT_SUFFIX_WEBP = 'webp'; + public const FORMAT_SUFFIX_AVIF = 'avif'; + public const FORMAT_SUFFIX_PNG = 'png'; + + public const FORMAT_SUFFIXES_JPG = [ + 'jpg', + 'jpeg', + 'jfif', + ]; + + public const MIME_TYPE_WEBP = 'image/webp'; + public const MIME_TYPE_AVIF = 'image/avif'; + + public const SUPPORTED_FORMATS = [ 'jpeg', 'jpg', 'gif', @@ -26,37 +38,24 @@ final class Resizer implements IResizer 'wbmp', 'xbm', 'webp', + 'avif', 'bmp', ]; - /** @var array{ - * webp_quality: int<0, 100>, - * jpeg_quality: int<0, 100>, - * png_compression_level: int<0, 9> - * } - */ - private array $options; - private ResizerConfig $config; private AbstractImagine $imagine; private string $cacheDir; - private bool $isWebpSupportedByServer; - public function __construct(ResizerConfig $config, bool $isWebpSupportedByServer) + public function __construct( + private ResizerConfig $config, + private OutputFormat $outputFormat, + ) { - $this->config = $config; - $this->isWebpSupportedByServer = $isWebpSupportedByServer; - $this->cacheDir = $config->tempDir . $config->cache; + $this->cacheDir = $config->getTempDir() . $config->getCache(); FileSystem::createDir($this->cacheDir); - $this->options = [ - 'webp_quality' => $config->qualityWebp, - 'jpeg_quality' => $config->qualityJpeg, - 'png_compression_level' => $config->compressionPng, - ]; - /** @var AbstractImagine $library */ - $library = implode('\\', ['Imagine', $config->library, 'Imagine']); + $library = implode('\\', ['Imagine', $config->getLibrary(), 'Imagine']); $this->imagine = new $library; } @@ -69,9 +68,7 @@ public function process( $params = $this->normalizeParams($params); $sourceImagePath = $this->getSourceImagePath($path); - $extension = pathinfo($path, PATHINFO_EXTENSION) ?: '.unknown'; - - $thumbnailFileName = $params . '.' . $this->getOutputFormat($extension, $format); + $thumbnailFileName = $params . '.' . $this->outputFormat->getOutputFormat($path, $format); $thumbnailPath = $this->getThumbnailDir($path) . $thumbnailFileName; $geometry = new Geometry($params); @@ -83,17 +80,17 @@ public function process( throw new ImageNotFoundOrReadableException('Unable to open image - wrong permissions, empty or corrupted.'); } - if ($this->config->strip) { + if ($this->config->isStrip()) { // remove all comments & metadata $thumbnail->strip(); } // use progressive/interlace mode? - if ($this->config->interlace) { + if ($this->config->isInterlace()) { $thumbnail->interlace(ImageInterface::INTERLACE_LINE); } - $thumbnail->save($thumbnailPath, $this->options); + $thumbnail->save($thumbnailPath, $this->config->getOptions()); } return $thumbnailPath; @@ -102,10 +99,10 @@ public function process( public function getSourceImagePath(string $path): string { - $fullPath = (string) realpath($this->config->wwwDir . DIRECTORY_SEPARATOR . $path); + $fullPath = (string) realpath($this->config->getWwwDir() . DIRECTORY_SEPARATOR . $path); // wonky, but better than nothing - if (strpos($path, '../') !== false) { + if (str_contains($path, '../')) { throw new SecurityException('Attempt to access files outside permitted path.'); } @@ -117,18 +114,6 @@ public function getSourceImagePath(string $path): string } - public function canUpgradeJpg2Webp(): bool - { - return $this->config->upgradeJpg2Webp; - } - - - public function isWebpSupportedByServer(): bool - { - return $this->isWebpSupportedByServer; - } - - private function getThumbnailDir(string $path): string { $dir = $this->cacheDir . $path . DIRECTORY_SEPARATOR; @@ -137,21 +122,6 @@ private function getThumbnailDir(string $path): string } - private function getOutputFormat(string $extension, ?string $format = null): string - { - if (!empty($format) && $this->isFormatSupported($format)) { - return $format; - } - return $extension; - } - - - private function isFormatSupported(string $format): bool - { - return in_array(strtolower($format), self::SUPPORTED_FORMATS, true); - } - - private function normalizeParams(?string $params): string { // skippable argument defaults "hack" & backwards compat diff --git a/src/ResizerConfig.php b/src/ResizerConfig.php new file mode 100644 index 0000000..d76d984 --- /dev/null +++ b/src/ResizerConfig.php @@ -0,0 +1,140 @@ +config->library; + } + + + public function isInterlace(): bool + { + return $this->config->interlace; + } + + + public function getWwwDir(): string + { + return $this->config->wwwDir; + } + + + public function getTempDir(): string + { + return $this->config->tempDir; + } + + + public function getCache(): string + { + return $this->config->cache; + } + + + public function canUpgradeJpg2Webp(): bool + { + return $this->config->upgradeJpg2Webp; + } + + + public function canUpgradePng2Avif(): bool + { + return $this->config->upgradePng2Avif; + } + + + public function canUpgradeJpg2Avif(): bool + { + return $this->config->upgradeJpg2Avif; + } + + + public function canUpgradePng2Webp(): bool + { + return $this->config->upgradePng2Webp; + } + + + public function isWebpSupportedByServer(): bool + { + return $this->config->isWebpSupportedByServer; + } + + + public function isAvifSupportedByServer(): bool + { + return $this->config->isAvifSupportedByServer; + } + + + public function isStrip(): bool + { + return $this->config->strip; + } + + + /** @return int<0, 100> */ + public function getQualityAvif(): int + { + return $this->config->qualityAvif; + } + + + /** @return int<0, 100> */ + public function getQualityWebp(): int + { + return $this->config->qualityWebp; + } + + + /** @return int<0, 100> */ + public function getQualityJpeg(): int + { + return $this->config->qualityJpeg; + } + + + /** @return int<0, 9> */ + public function getCompressionPng(): int + { + return $this->config->compressionPng; + } + + + /** + * @return array{ + * avif_quality: int<0, 100>, + * webp_quality: int<0, 100>, + * jpeg_quality: int<0, 100>, + * png_compression_level: int<0, 9> + * } + */ + public function getOptions(): array + { + return [ + 'avif_quality' => $this->getQualityAvif(), + 'webp_quality' => $this->getQualityWebp(), + 'jpeg_quality' => $this->getQualityJpeg(), + 'png_compression_level' => $this->getCompressionPng(), + ]; + } + +} diff --git a/tests/OutputFormatTest.php b/tests/OutputFormatTest.php new file mode 100644 index 0000000..ddcb4aa --- /dev/null +++ b/tests/OutputFormatTest.php @@ -0,0 +1,136 @@ +getConfig()); + + $this->assertSame( + $outputFormat->getOutputFormat('test.jpg'), + 'jpg', + ); + + $this->assertSame( + $outputFormat->getOutputFormat('test.jpeg'), + 'jpg', + ); + + $this->assertSame( + $outputFormat->getOutputFormat('test.jfif'), + 'jpg', + ); + } + + + public function testJpg2Avif(): void + { + $httpRequest = new Request(new UrlScript, headers: ['accept' => 'image/avif,image/webp']); + $outputFormat = new OutputFormat( + $httpRequest, + $this->getConfig(true, true) + ); + + $this->assertSame( + $outputFormat->getOutputFormat('test.jpg'), + 'avif', + ); + } + + + public function testJpg2Webp(): void + { + $httpRequest = new Request(new UrlScript, headers: ['accept' => 'image/webp']); + $outputFormat = new OutputFormat( + $httpRequest, + $this->getConfig(true, true) + ); + + $this->assertSame( + $outputFormat->getOutputFormat('test.jpg'), + 'webp', + ); + } + + + public function testPng2Avif(): void + { + $httpRequest = new Request(new UrlScript, headers: ['accept' => 'image/avif,image/webp']); + $outputFormat = new OutputFormat( + $httpRequest, + $this->getConfig(true, true) + ); + + $this->assertSame( + $outputFormat->getOutputFormat('test.png'), + 'avif', + ); + } + + + public function testPng2Webp(): void + { + $httpRequest = new Request(new UrlScript, headers: ['accept' => 'image/webp']); + $outputFormat = new OutputFormat( + $httpRequest, + $this->getConfig(true, true) + ); + + $this->assertSame( + $outputFormat->getOutputFormat('test.png'), + 'webp', + ); + } + + + public function testPng(): void + { + $httpRequest = new Request(new UrlScript); + $outputFormat = new OutputFormat($httpRequest, $this->getConfig()); + + $this->assertSame( + $outputFormat->getOutputFormat('test.png'), + 'png', + ); + + } + + + + private function getConfig(bool $webpSupported = false, bool $avifSupported = false): ResizerConfig + { + $config = new ResizerConfigDTO; + + $config->tempDir = __DIR__ . '/../temp'; + $config->wwwDir = __DIR__ . '/../tests'; + $config->qualityJpeg = 65; + $config->qualityAvif = 65; + $config->qualityWebp = 65; + $config->compressionPng = 9; + $config->library = 'Gd'; + + $config->isAvifSupportedByServer = $avifSupported; + $config->isWebpSupportedByServer = $webpSupported; + + return new ResizerConfig($config); + } + + +} diff --git a/tests/ResizerTest.php b/tests/ResizerTest.php index 63f2a0b..ad43134 100644 --- a/tests/ResizerTest.php +++ b/tests/ResizerTest.php @@ -3,10 +3,15 @@ namespace Nelson\Resizer\Tests; -use Nelson\Resizer\DI\ResizerConfig; +use Nelson\Resizer\DI\ResizerConfigDTO; use Nelson\Resizer\Exceptions\ImageNotFoundOrReadableException; use Nelson\Resizer\Exceptions\SecurityException; +use Nelson\Resizer\OutputFormat; use Nelson\Resizer\Resizer; +use Nelson\Resizer\ResizerConfig; +use Nette\Http\Request; +use Nette\Http\Response; +use Nette\Http\UrlScript; use Nette\Utils\FileSystem; use PHPUnit\Framework\TestCase; @@ -15,22 +20,28 @@ class ResizerTest extends TestCase private static Resizer $resizer; private static string $image; private static ResizerConfig $config; - private static ?string $thumbnail = null; public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); - static::$config = new ResizerConfig; - static::$config->tempDir = __DIR__ . '/../temp'; - static::$config->wwwDir = __DIR__ . '/../tests'; - static::$config->qualityJpeg = 65; - static::$config->qualityWebp = 65; - static::$config->compressionPng = 9; - static::$config->library = 'Gd'; + $config = new ResizerConfigDTO; + $config->tempDir = __DIR__ . '/../temp'; + $config->wwwDir = __DIR__ . '/../tests'; + $config->qualityJpeg = 65; + $config->qualityAvif = 65; + $config->qualityWebp = 65; + $config->compressionPng = 9; + $config->library = 'Gd'; - static::$resizer = new Resizer(static::$config, false); + $httpRequest = new Request(new UrlScript); + static::$config = new ResizerConfig($config); + + $outputFormat = new OutputFormat($httpRequest, static::$config); + + + static::$resizer = new Resizer(static::$config, $outputFormat); static::$image = 'fixtures/test.png'; } @@ -51,7 +62,7 @@ public function testSecurityException(): void public function testImageFound(): void { - $this->assertEquals( + $this->assertSame( __DIR__ . '/fixtures/test.png', static::$resizer->getSourceImagePath(static::$image), ); @@ -85,6 +96,6 @@ public function testGeneratedImage(): void public static function tearDownAfterClass(): void { parent::tearDownAfterClass(); - FileSystem::delete(static::$config->tempDir . static::$config->cache); + FileSystem::delete(static::$config->getTempDir() . static::$config->getCache()); } }