'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 supports(string $sourceMimeType, PreviewRequest $request): bool { try { $sourceMimeType = MimeType::normalize($sourceMimeType); } catch (Throwable) { return false; } return in_array($sourceMimeType, array_values(self::INPUT_FORMATS), true) && isset(self::OUTPUT_FORMATS[$request->preferredMimeType]); } 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 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', }; } }