<?php
|
declare(strict_types=1);
|
|
namespace App\Service;
|
|
/**
|
* Scales covers down to JPEG thumbnails. Smaller images are never enlarged.
|
*/
|
class CoverThumbnailer {
|
private const int JPEG_QUALITY = 85;
|
|
/**
|
* Sizes are twice the display size, for high-density screens.
|
*
|
* @var array<string, array{int, int}> maximum width and height by size name
|
*/
|
public const array SIZES = [
|
'list' => [150, 225],
|
'details' => [600, 900],
|
];
|
|
/**
|
* @param string $data the image file's contents
|
* @param callable(string): string $targetPath gives the file for a size name
|
*
|
* @throws CoverProcessingException
|
*/
|
public function createThumbnails(string $data, callable $targetPath): void {
|
$image = $data === '' ? false : @imagecreatefromstring($data);
|
|
if ($image === false) {
|
throw new CoverProcessingException('The file could not be read as an image.');
|
}
|
|
$image = self::applyExifOrientation($image, $data);
|
|
foreach (self::SIZES as $name => [$maxWidth, $maxHeight]) {
|
if (!imagejpeg(self::scale($image, $maxWidth, $maxHeight), $targetPath($name), self::JPEG_QUALITY)) {
|
throw new CoverProcessingException('The thumbnail could not be written.');
|
}
|
}
|
}
|
|
private static function scale(\GdImage $image, int $maxWidth, int $maxHeight): \GdImage {
|
$width = imagesx($image);
|
$height = imagesy($image);
|
$ratio = min(1, $maxWidth / $width, $maxHeight / $height);
|
$targetWidth = max(1, (int)round($width * $ratio));
|
$targetHeight = max(1, (int)round($height * $ratio));
|
|
// JPEG has no transparency, so transparent PNG and WebP covers go on a white background.
|
$thumbnail = imagecreatetruecolor($targetWidth, $targetHeight);
|
imagefill($thumbnail, 0, 0, (int)imagecolorallocate($thumbnail, 255, 255, 255));
|
imagecopyresampled($thumbnail, $image, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
|
|
return $thumbnail;
|
}
|
|
/**
|
* Phones store the rotation of a photo as metadata, which thumbnails would otherwise lose.
|
*/
|
private static function applyExifOrientation(\GdImage $image, string $data): \GdImage {
|
if (\IMAGETYPE_JPEG !== (@getimagesizefromstring($data)[2] ?? null)) {
|
return $image;
|
}
|
|
$orientation = @exif_read_data('data://image/jpeg;base64,' . base64_encode($data))['Orientation'] ?? 1;
|
|
if (\in_array($orientation, [2, 4, 5, 7], true)) {
|
imageflip($image, \IMG_FLIP_HORIZONTAL);
|
}
|
|
$angle = match ($orientation) {
|
3, 4 => 180,
|
5, 8 => 90,
|
6, 7 => 270,
|
default => 0,
|
};
|
|
return $angle === 0 ? $image : imagerotate($image, $angle, 0);
|
}
|
}
|