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:
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user