Files
provider_preview_imagemagick/lib/Provider.php
T
Sebastian b01b4bdbd2 feat: add ImageMagick preview provider
Implements the shared preview provider contract using ImageMagick to
generate thumbnails for supported source types.
2026-08-29 16:37:22 -04:00

202 lines
6.3 KiB
PHP

<?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',
};
}
}