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:
2026-08-29 16:37:22 -04:00
parent 41308e3b69
commit b01b4bdbd2
8 changed files with 554 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
/lib/vendor/
/vendor/
coverage/
*.cache
.phpunit.coverage
.phpactor/
.DS_Store
.vscode/
.idea/
*.log
+23
View File
@@ -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
View File
@@ -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"
}
+46
View File
@@ -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);
}
}
+201
View File
@@ -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',
};
}
}
+225
View File
@@ -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;
}
}
+6
View File
@@ -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/');
+19
View File
@@ -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>