diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fa0ca5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/lib/vendor/ +/vendor/ +coverage/ +*.cache +.phpunit.coverage +.phpactor/ +.DS_Store +.vscode/ +.idea/ +*.log diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..527eebd --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "name": "ktxm/provider-preview-imagemagick", + "description": "ImageMagick preview provider for Ktrix", + "type": "ktrix-module", + "license": "AGPL-3.0-or-later", + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.3" + }, + "autoloader-suffix": "ProviderPreviewImageMagick", + "vendor-dir": "lib/vendor" + }, + "require": { + "php": ">=8.3 <=8.5", + "ext-imagick": "*" + }, + "autoload": { + "psr-4": { + "KTXM\\ProviderPreviewImageMagick\\": "lib/" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..1c37977 --- /dev/null +++ b/composer.lock @@ -0,0 +1,24 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "d507794e977605dfd588f099f118506c", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.3 <=8.5", + "ext-imagick": "*" + }, + "platform-dev": {}, + "platform-overrides": { + "php": "8.3" + }, + "plugin-api-version": "2.6.0" +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..5d41820 --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,46 @@ +providers->register(ProviderInterface::TYPE_PREVIEW, 'imagemagick', Provider::class); + } +} diff --git a/lib/Provider.php b/lib/Provider.php new file mode 100644 index 0000000..95d7834 --- /dev/null +++ b/lib/Provider.php @@ -0,0 +1,201 @@ + 'image/jpeg', + 'PNG' => 'image/png', + 'WEBP' => 'image/webp', + 'GIF' => 'image/gif', + ]; + + private const OUTPUT_FORMATS = [ + 'image/jpeg' => 'JPEG', + 'image/png' => 'PNG', + 'image/webp' => 'WEBP', + ]; + + public function type(): string + { + return ProviderInterface::TYPE_PREVIEW; + } + + public function identifier(): string + { + return 'imagemagick'; + } + + public function label(): string + { + return 'ImageMagick'; + } + + public function description(): string + { + return 'Generates previews for common image formats'; + } + + public function icon(): string + { + return 'image'; + } + + public function supportedMimeTypes(): array + { + return array_values(self::INPUT_FORMATS); + } + + public function outputMimeTypes(): array + { + return array_keys(self::OUTPUT_FORMATS); + } + + public function supports(string $sourceMimeType, PreviewRequest $request): bool + { + try { + $sourceMimeType = MimeType::normalize($sourceMimeType); + } catch (Throwable) { + return false; + } + + return in_array($sourceMimeType, $this->supportedMimeTypes(), true) + && isset(self::OUTPUT_FORMATS[$request->preferredMimeType]) + && ($request->page === null || $request->page === 1) + && $request->timestamp === null; + } + + public function generate(PreviewSource $source, PreviewRequest $request): PreviewResult + { + if (!$this->supports($source->mimeType, $request)) { + throw new PreviewGenerationException('ImageMagick does not support the requested preview conversion'); + } + if ($source->size !== null && $source->size > $request->maxSourceSize) { + throw new PreviewGenerationException('Preview source exceeds the configured size limit'); + } + + $images = new Imagick(); + $image = null; + try { + $images->readImageBlob($this->readSource($source, $request->maxSourceSize)); + $images->setIteratorIndex(0); + $image = $images->getImage(); + + $actualMimeType = self::INPUT_FORMATS[strtoupper($image->getImageFormat())] ?? null; + if ($actualMimeType === null || $actualMimeType !== $source->mimeType) { + throw new PreviewGenerationException('Decoded image type does not match the source MIME type'); + } + + $image->autoOrient(); + $this->resize($image, $request->maxWidth, $request->maxHeight); + $image->stripImage(); + $image->setImagePage(0, 0, 0, 0); + $this->encode($image, $request->preferredMimeType, $request->quality); + + $content = $image->getImageBlob(); + if ($content === '') { + throw new PreviewGenerationException('ImageMagick produced an empty preview'); + } + + $mimeType = $request->preferredMimeType; + return new PreviewResult( + new BinaryResource( + 'preview.' . $this->extension($mimeType), + $mimeType, + (static function () use ($content): \Generator { yield $content; })(), + ), + $image->getImageWidth(), + $image->getImageHeight(), + ); + } catch (PreviewGenerationException $exception) { + throw $exception; + } catch (ImagickException $exception) { + throw new PreviewGenerationException('ImageMagick could not generate the preview', previous: $exception); + } finally { + $image?->clear(); + $images->clear(); + } + } + + public function generatorVersion(): string + { + $version = Imagick::getVersion(); + return $version['versionString'] ?? 'ImageMagick'; + } + + public function priority(): int + { + return 100; + } + + private function readSource(PreviewSource $source, int $limit): string + { + $content = ''; + foreach ($source->open()->stream() as $chunk) { + if (!is_string($chunk)) { + throw new PreviewGenerationException('Preview source yielded a non-string chunk'); + } + if (strlen($content) + strlen($chunk) > $limit) { + throw new PreviewGenerationException('Preview source exceeds the configured size limit'); + } + $content .= $chunk; + } + if ($content === '') { + throw new PreviewGenerationException('Preview source is empty'); + } + return $content; + } + + private function resize(Imagick $image, int $maxWidth, int $maxHeight): void + { + $width = $image->getImageWidth(); + $height = $image->getImageHeight(); + $scale = min($maxWidth / $width, $maxHeight / $height, 1); + if ($scale < 1) { + $image->resizeImage( + max(1, (int) floor($width * $scale)), + max(1, (int) floor($height * $scale)), + Imagick::FILTER_LANCZOS, + 1, + ); + } + } + + private function encode(Imagick $image, string $mimeType, int $quality): void + { + if ($mimeType === 'image/jpeg' && $image->getImageAlphaChannel()) { + $image->setImageBackgroundColor('white'); + $flattened = $image->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN); + $image->clear(); + $image->readImageBlob($flattened->getImageBlob()); + $flattened->clear(); + } + + $image->setImageFormat(self::OUTPUT_FORMATS[$mimeType]); + $image->setImageCompressionQuality($quality); + } + + private function extension(string $mimeType): string + { + return match ($mimeType) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + default => 'webp', + }; + } +} diff --git a/tests/php/Unit/ProviderTest.php b/tests/php/Unit/ProviderTest.php new file mode 100644 index 0000000..7f2132d --- /dev/null +++ b/tests/php/Unit/ProviderTest.php @@ -0,0 +1,225 @@ +createMock(ProviderManager::class); + $providers->expects(self::once())->method('register')->with( + ProviderInterface::TYPE_PREVIEW, + 'imagemagick', + Provider::class, + ); + + (new Module($providers))->boot(); + } + + public static function inputFormats(): iterable + { + yield 'JPEG' => ['JPEG', 'image/jpeg']; + yield 'PNG' => ['PNG', 'image/png']; + yield 'WebP' => ['WEBP', 'image/webp']; + yield 'GIF' => ['GIF', 'image/gif']; + } + + #[Test] + #[DataProvider('inputFormats')] + public function generatesBoundedWebpFromSupportedInputs(string $format, string $mimeType): void + { + $provider = new Provider(); + $result = $provider->generate( + $this->source($this->image($format, 800, 400), $mimeType), + $this->request(), + ); + $output = $this->content($result->resource); + $image = new Imagick(); + $image->readImageBlob($output); + + self::assertSame('image/webp', $result->resource->mimeType()); + self::assertSame(640, $result->width); + self::assertSame(320, $result->height); + self::assertSame('WEBP', $image->getImageFormat()); + self::assertSame(640, $image->getImageWidth()); + self::assertSame(320, $image->getImageHeight()); + $image->clear(); + } + + #[Test] + public function doesNotUpscaleSmallImages(): void + { + $result = (new Provider())->generate( + $this->source($this->image('PNG', 100, 50), 'image/png'), + $this->request(), + ); + + self::assertSame(100, $result->width); + self::assertSame(50, $result->height); + } + + public static function outputFormats(): iterable + { + yield 'JPEG' => ['image/jpeg', 'JPEG']; + yield 'PNG' => ['image/png', 'PNG']; + yield 'WebP' => ['image/webp', 'WEBP']; + } + + #[Test] + #[DataProvider('outputFormats')] + public function encodesEveryAdvertisedOutputFormat(string $mimeType, string $format): void + { + $request = new PreviewRequest('popover', 640, 440, $mimeType, 80); + $result = (new Provider())->generate( + $this->source($this->image('PNG', 20, 20, 'rgba(255, 0, 0, 0.25)'), 'image/png'), + $request, + ); + $image = new Imagick(); + $image->readImageBlob($this->content($result->resource)); + + self::assertSame($mimeType, $result->resource->mimeType()); + self::assertSame($format, $image->getImageFormat()); + $image->clear(); + } + + #[Test] + public function preservesTransparencyInPngOutput(): void + { + $request = new PreviewRequest('popover', 640, 440, 'image/png', 80); + $result = (new Provider())->generate( + $this->source($this->image('PNG', 20, 20, 'rgba(255, 0, 0, 0.25)'), 'image/png'), + $request, + ); + $image = new Imagick(); + $image->readImageBlob($this->content($result->resource)); + $color = $image->getImagePixelColor(0, 0)->getColor(1); + + self::assertSame('image/png', $result->resource->mimeType()); + self::assertLessThan(0.5, $color['a']); + $image->clear(); + } + + #[Test] + public function usesOnlyTheFirstAnimatedFrame(): void + { + $animation = new Imagick(); + foreach (['red', 'blue'] as $color) { + $frame = new Imagick(); + $frame->newImage(20, 20, new ImagickPixel($color)); + $frame->setImageFormat('GIF'); + $frame->setImageDelay(10); + $animation->addImage($frame); + $frame->clear(); + } + $content = $animation->getImagesBlob(); + $animation->clear(); + + $result = (new Provider())->generate($this->source($content, 'image/gif'), $this->request()); + $output = new Imagick(); + $output->readImageBlob($this->content($result->resource)); + + self::assertSame(1, $output->getNumberImages()); + $color = $output->getImagePixelColor(0, 0)->getColor(); + self::assertGreaterThan($color['b'], $color['r']); + $output->clear(); + } + + #[Test] + public function rejectsMalformedOrMismatchedImages(): void + { + $provider = new Provider(); + + try { + $provider->generate($this->source('not an image', 'image/png'), $this->request()); + self::fail('Malformed input should fail'); + } catch (PreviewGenerationException) { + self::addToAssertionCount(1); + } + + $this->expectException(PreviewGenerationException::class); + $this->expectExceptionMessage('does not match'); + $provider->generate( + $this->source($this->image('PNG', 20, 20), 'image/jpeg'), + $this->request(), + ); + } + + #[Test] + public function enforcesTheSourceSizeLimitWhileReading(): void + { + $content = $this->image('PNG', 20, 20); + $request = new PreviewRequest( + 'popover', + 640, + 440, + 'image/webp', + 80, + maxSourceSize: strlen($content) - 1, + ); + + $this->expectException(PreviewGenerationException::class); + $this->expectExceptionMessage('size limit'); + (new Provider())->generate($this->source($content, 'image/png', size: null), $request); + } + + private function request(): PreviewRequest + { + return new PreviewRequest('popover', 640, 440, 'image/webp', 80); + } + + private function source(string $content, string $mimeType, ?int $size = null): PreviewSource + { + return new PreviewSource( + 'document', + 'image', + hash('sha256', $content), + $mimeType, + $size ?? strlen($content), + static fn(): BinaryResource => new BinaryResource( + 'source.bin', + $mimeType, + (static function () use ($content): \Generator { + $middle = intdiv(strlen($content), 2); + yield substr($content, 0, $middle); + yield substr($content, $middle); + })(), + ), + ); + } + + private function image(string $format, int $width, int $height, string $color = 'red'): string + { + $image = new Imagick(); + $image->newImage($width, $height, new ImagickPixel($color)); + $image->setImageFormat($format); + $content = $image->getImageBlob(); + $image->clear(); + return $content; + } + + private function content(BinaryResource $resource): string + { + $content = ''; + foreach ($resource->stream() as $chunk) { + $content .= $chunk; + } + return $content; + } +} diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 0000000..e1a1426 --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,6 @@ +addPsr4('KTXM\\ProviderPreviewImageMagick\\', dirname(__DIR__, 2) . '/lib/'); diff --git a/tests/php/phpunit.xml b/tests/php/phpunit.xml new file mode 100644 index 0000000..bc9059d --- /dev/null +++ b/tests/php/phpunit.xml @@ -0,0 +1,19 @@ + + + + + Unit + + + + + ../../lib + + +