generated from Nodarx/template
feat: add ImageMagick preview provider
Implements the shared preview provider contract using ImageMagick to generate thumbnails for supported source types.
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
/lib/vendor/
|
||||
/vendor/
|
||||
coverage/
|
||||
*.cache
|
||||
.phpunit.coverage
|
||||
.phpactor/
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+24
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderPreviewImageMagick;
|
||||
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
|
||||
final class Module extends ModuleInstanceAbstract
|
||||
{
|
||||
public function __construct(private readonly ProviderManager $providers)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
return 'provider_preview_imagemagick';
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
return '0.0.1';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'ImageMagick Preview Provider';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Generates image previews using ImageMagick';
|
||||
}
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
return 'Ktrix';
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->providers->register(ProviderInterface::TYPE_PREVIEW, 'imagemagick', Provider::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXM\ProviderPreviewImageMagick;
|
||||
|
||||
use Imagick;
|
||||
use ImagickException;
|
||||
use KTXF\Preview\MimeType;
|
||||
use KTXF\Preview\PreviewGenerationException;
|
||||
use KTXF\Preview\PreviewRequest;
|
||||
use KTXF\Preview\PreviewResult;
|
||||
use KTXF\Preview\PreviewSource;
|
||||
use KTXF\Preview\Provider\PreviewProviderInterface;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use Throwable;
|
||||
|
||||
final class Provider implements PreviewProviderInterface
|
||||
{
|
||||
private const INPUT_FORMATS = [
|
||||
'JPEG' => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\ProviderPreviewImageMagick\Unit;
|
||||
|
||||
use Imagick;
|
||||
use ImagickPixel;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXF\Preview\PreviewGenerationException;
|
||||
use KTXF\Preview\PreviewRequest;
|
||||
use KTXF\Preview\PreviewSource;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXM\ProviderPreviewImageMagick\Module;
|
||||
use KTXM\ProviderPreviewImageMagick\Provider;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ProviderTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function moduleRegistersThePreviewProvider(): void
|
||||
{
|
||||
$providers = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$loader = require dirname(__DIR__, 4) . '/vendor/autoload.php';
|
||||
$loader->addPsr4('KTXM\\ProviderPreviewImageMagick\\', dirname(__DIR__, 2) . '/lib/');
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="../../../../vendor/phpunit/phpunit/phpunit.xsd"
|
||||
colors="true"
|
||||
failOnDeprecation="true"
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="bootstrap.php">
|
||||
<testsuites>
|
||||
<testsuite name="Unit Tests">
|
||||
<directory>Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>../../lib</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
Reference in New Issue
Block a user