359 lines
12 KiB
PHP
359 lines
12 KiB
PHP
<?php
|
|
/*****************************************************************************
|
|
* Thumbnail.php
|
|
* Contains key class Thumbnail.
|
|
*
|
|
* Kabuki CMS (C) 2013-2020, Aaron van Geffen
|
|
*****************************************************************************/
|
|
|
|
class Thumbnail
|
|
{
|
|
private $image;
|
|
private $image_meta;
|
|
private $thumbnails;
|
|
|
|
private $properly_initialised;
|
|
private $width;
|
|
private $height;
|
|
private $crop_mode;
|
|
|
|
const CROP_MODE_NONE = 0;
|
|
const CROP_MODE_BOUNDARY = 1;
|
|
const CROP_MODE_CUSTOM_FILE = 2;
|
|
const CROP_MODE_SLICE_TOP = 3;
|
|
const CROP_MODE_SLICE_CENTRE = 4;
|
|
const CROP_MODE_SLICE_BOTTOM = 5;
|
|
|
|
public function __construct(Image $image)
|
|
{
|
|
$this->image = $image;
|
|
$this->image_meta = $image->getMeta();
|
|
$this->thumbnails = $image->getThumbnails();
|
|
}
|
|
|
|
/**
|
|
* @param width: width of the thumbnail.
|
|
* @param height: height of the thumbnail.
|
|
* @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom']
|
|
* @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimate [false].
|
|
* @param generate: whether or not to generate a thumbnail if no existing file was found.
|
|
*/
|
|
public function getUrl($width, $height, $crop = true, $fit = true, $generate = false)
|
|
{
|
|
$this->init($width, $height, $crop, $fit);
|
|
|
|
// Check whether we've already resized this earlier.
|
|
$thumb_selector = $this->width . 'x' . $this->height . $this->filename_suffix;
|
|
if (!empty($this->thumbnails[$thumb_selector]))
|
|
{
|
|
$thumb_filename = $this->image->getSubdir() . '/' . $this->thumbnails[$thumb_selector];
|
|
if (file_exists(THUMBSDIR . '/' . $thumb_filename))
|
|
return THUMBSURL . '/' . $thumb_filename;
|
|
}
|
|
|
|
// Do we have a custom thumbnail on file?
|
|
$custom_selector = 'custom_' . $this->width . 'x' . $this->height;
|
|
if (isset($this->image_meta[$custom_selector]))
|
|
{
|
|
$custom_filename = $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector];
|
|
if (file_exists(ASSETSDIR . '/' . $custom_filename))
|
|
{
|
|
// Copy the custom thumbail to the general thumbnail directory.
|
|
copy(ASSETSDIR . '/' . $custom_filename, THUMBSDIR . '/' . $custom_filename);
|
|
|
|
// Let's remember this for future reference.
|
|
$this->markAsGenerated($this->image_meta[$custom_selector]);
|
|
|
|
return THUMBSURL . '/' . $custom_filename;
|
|
}
|
|
else
|
|
throw new UnexpectedValueException('Custom thumbnail expected, but missing in file system!');
|
|
}
|
|
|
|
// Is this the right moment to generate a thumbnail, then?
|
|
if ($generate)
|
|
{
|
|
if (array_key_exists($thumb_selector, $this->thumbnails))
|
|
return $this->generate();
|
|
else
|
|
throw new Exception("Trying to generate a thumbnail not previously queued by the system\n" .
|
|
print_r(func_get_args(), true));
|
|
}
|
|
|
|
// If not, queue it for generation at another time, and return a URL to generate it with.
|
|
else
|
|
{
|
|
$this->markAsQueued();
|
|
return BASEURL . '/thumbnail/' . $this->image->getId() . '/' . $thumb_selector . '/';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param width: width of the thumbnail.
|
|
* @param height: height of the thumbnail.
|
|
* @param crop: whether and how to crop original image to fit. [false|true|'top'|'center'|'bottom']
|
|
* @param fit: whether to fit the image to given boundaries [true], or use them merely as an estimate [false].
|
|
*/
|
|
private function init($width, $height, $crop = true, $fit = true)
|
|
{
|
|
$this->properly_initialised = false;
|
|
|
|
// First, assert the image's dimensions are properly known in the database.
|
|
if ($this->image->width() === null || $this->image->height() === null)
|
|
throw new UnexpectedValueException('Image width or height is undefined -- inconsistent database?');
|
|
|
|
$this->width = $width;
|
|
$this->height = $height;
|
|
|
|
// Inferring width or height?
|
|
if (!$this->height)
|
|
$this->height = ceil($this->width / $this->image->ratio());
|
|
elseif (!$this->width)
|
|
$this->width = ceil($this->height * $this->image->ratio());
|
|
|
|
// Inferring the height from the original image's ratio?
|
|
if (!$fit)
|
|
$this->height = floor($this->width / $this->image->ratio());
|
|
|
|
// Assert we have both, now...
|
|
if (empty($this->width) || empty($this->height))
|
|
throw new InvalidArgumentException('Expecting at least either width or height as argument.');
|
|
|
|
// If we're cropping, verify we're in the right mode.
|
|
if ($crop)
|
|
{
|
|
// Do we have an exact crop boundary set for these dimensions?
|
|
$crop_selector = 'crop_' . $this->width . 'x' . $this->height;
|
|
if (isset($this->image_meta[$crop_selector]))
|
|
$this->crop_mode = self::CROP_MODE_BOUNDARY;
|
|
|
|
// We won't be cropping if the thumbnail is proportional to its original.
|
|
elseif (abs($this->ratio() - $this->image->ratio()) <= 0.025)
|
|
$this->crop_mode = self::CROP_MODE_NONE;
|
|
|
|
// If the original image's aspect ratio is much wider, take a slice instead.
|
|
elseif ($this->image->ratio() > $this->ratio())
|
|
$this->crop_mode = self::CROP_MODE_SLICE_CENTRE;
|
|
|
|
// Slice from the top?
|
|
elseif ($crop === 'top' || $crop === 'ct')
|
|
$this->crop_mode = self::CROP_MODE_SLICE_TOP;
|
|
|
|
// Slice from the bottom?
|
|
elseif ($crop === 'bottom' || $crop === 'cb')
|
|
$this->crop_mode = self::CROP_MODE_SLICE_BOTTOM;
|
|
|
|
// Slice from the centre?
|
|
elseif ($crop === 'centre' || $crop === 'center' || $crop === 'cs' || $crop === true)
|
|
$this->crop_mode = self::CROP_MODE_SLICE_CENTRE;
|
|
|
|
// Unexpected value? Assume no crop.
|
|
else
|
|
$this->crop_mode = self::CROP_MODE_NONE;
|
|
}
|
|
else
|
|
$this->crop_mode = self::CROP_MODE_NONE;
|
|
|
|
// Now, do we need to suffix the filename?
|
|
if ($this->crop_mode !== self::CROP_MODE_NONE)
|
|
{
|
|
$this->filename_suffix = '_c';
|
|
if ($this->crop_mode === self::CROP_MODE_SLICE_TOP)
|
|
$this->filename_suffix .= 't';
|
|
elseif ($this->crop_mode === self::CROP_MODE_SLICE_CENTRE)
|
|
$this->filename_suffix .= 's';
|
|
elseif ($this->crop_mode === self::CROP_MODE_SLICE_BOTTOM)
|
|
$this->filename_suffix .= 'b';
|
|
elseif ($this->crop_mode === self::CROP_MODE_BOUNDARY)
|
|
$this->filename_suffix .= 'e';
|
|
}
|
|
else
|
|
$this->filename_suffix = '';
|
|
|
|
$this->properly_initialised = true;
|
|
}
|
|
|
|
private function generate()
|
|
{
|
|
if (!$this->properly_initialised)
|
|
throw new UnexpectedValueException('The thumbnail factory was not intialised before use!');
|
|
|
|
// Let's try some arcane stuff...
|
|
try
|
|
{
|
|
if (!class_exists('Imagick'))
|
|
throw new Exception("The PHP module 'imagick' appears to be disabled. Please enable it to use image resampling functions.");
|
|
|
|
$thumb = new Imagick(ASSETSDIR . '/' . $this->image->getSubdir() . '/' . $this->image->getFilename());
|
|
|
|
// The image might have some orientation set through EXIF. Let's apply this first.
|
|
self::applyRotation($thumb);
|
|
|
|
// Just resizing? Easy peasy.
|
|
if ($this->crop_mode === self::CROP_MODE_NONE)
|
|
$thumb->resizeImage($this->width, $this->height, Imagick::FILTER_LANCZOS, 1);
|
|
|
|
// // Cropping in the center?
|
|
elseif ($this->crop_mode === self::CROP_MODE_SLICE_CENTRE)
|
|
$thumb->cropThumbnailImage($this->width, $this->height);
|
|
|
|
// Exact cropping? We can do that.
|
|
elseif ($this->crop_mode === self::CROP_MODE_BOUNDARY)
|
|
{
|
|
$crop_selector = 'crop_' . $this->width . 'x' . $this->height;
|
|
list($crop_width, $crop_height, $crop_x_pos, $crop_y_pos) = explode(',', $this->image_meta[$crop_selector]);
|
|
$thumb->cropImage($crop_width, $crop_height, $crop_x_pos, $crop_y_pos);
|
|
$thumb->resizeImage($this->width, $this->height, Imagick::FILTER_LANCZOS, 1);
|
|
}
|
|
|
|
// Advanced cropping? Fun!
|
|
else
|
|
{
|
|
$size = $thumb->getImageGeometry();
|
|
|
|
// Taking a horizontal slice from the top or bottom of the original image?
|
|
if ($this->crop_mode === self::CROP_MODE_SLICE_TOP || $this->crop_mode === self::CROP_MODE_SLICE_BOTTOM)
|
|
{
|
|
$crop_width = $size['width'];
|
|
$crop_height = floor($size['width'] / $this->width * $this->height);
|
|
$target_x = 0;
|
|
$target_y = $this->crop_mode === self::CROP_MODE_SLICE_TOP ? 0 : $size['height'] - $crop_height;
|
|
}
|
|
// Otherwise, we're taking a vertical slice from the centre.
|
|
else
|
|
{
|
|
$crop_width = floor($size['height'] / $this->height * $this->width);
|
|
$crop_height = $size['height'];
|
|
$target_x = floor(($size['width'] - $crop_width) / 2);
|
|
$target_y = 0;
|
|
}
|
|
|
|
$thumb->cropImage($crop_width, $crop_height, $target_x, $target_y);
|
|
$thumb->resizeImage($this->width, $this->height, Imagick::FILTER_LANCZOS, 1);
|
|
}
|
|
|
|
// What sort of image is this? Fall back to PNG if we must.
|
|
switch ($thumb->getImageFormat())
|
|
{
|
|
case 'JPEG':
|
|
$ext = 'jpg';
|
|
break;
|
|
|
|
case 'GIF':
|
|
$ext = 'gif';
|
|
break;
|
|
|
|
case 'PNG':
|
|
default:
|
|
$thumb->setFormat('PNG');
|
|
$ext = 'png';
|
|
break;
|
|
}
|
|
|
|
// So, how do we name this?
|
|
$thumb_filename = substr($this->image->getFilename(), 0, strrpos($this->image->getFilename(), '.')) .
|
|
'_' . $this->width . 'x' . $this->height . $this->filename_suffix . '.' . $ext;
|
|
|
|
// Ensure the thumbnail subdirectory exists.
|
|
$target_dir = THUMBSDIR . '/' . $this->image->getSubdir();
|
|
if (!is_dir($target_dir))
|
|
mkdir($target_dir, 0755, true);
|
|
|
|
if (!is_writable($target_dir))
|
|
throw new Exception('Thumbnail directory is not writable!');
|
|
|
|
// No need to preserve every detail.
|
|
$thumb->setImageCompressionQuality(80);
|
|
|
|
// Save it in a public spot.
|
|
$thumb->writeImage($target_dir . '/' . $thumb_filename);
|
|
|
|
// Let's remember this for future reference...
|
|
$this->markAsGenerated($thumb_filename);
|
|
|
|
$thumb->clear();
|
|
$thumb->destroy();
|
|
|
|
// Finally, return the URL for the generated thumbnail image.
|
|
return THUMBSURL . '/' . $this->image->getSubdir() . '/' . $thumb_filename;
|
|
}
|
|
catch (ImagickException $e)
|
|
{
|
|
throw new Exception('ImageMagick error occurred while generating thumbnail. Output: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
private static function applyRotation(Imagick $image)
|
|
{
|
|
switch ($image->getImageOrientation())
|
|
{
|
|
// Clockwise rotation
|
|
case Imagick::ORIENTATION_RIGHTTOP:
|
|
$image->rotateImage("#000", 90);
|
|
break;
|
|
|
|
// Counter-clockwise rotation
|
|
case Imagick::ORIENTATION_LEFTBOTTOM:
|
|
$image->rotateImage("#000", 270);
|
|
break;
|
|
|
|
// Upside down?
|
|
case Imagick::ORIENTATION_BOTTOMRIGHT:
|
|
$image->rotateImage("#000", 180);
|
|
}
|
|
|
|
// Having rotated the image, make sure the EXIF data is set properly.
|
|
$image->setImageOrientation(Imagick::ORIENTATION_TOPLEFT);
|
|
}
|
|
|
|
private function ratio()
|
|
{
|
|
return $this->width / $this->height;
|
|
}
|
|
|
|
private function updateDb($filename)
|
|
{
|
|
if (!$this->properly_initialised)
|
|
throw new UnexpectedValueException('The thumbnail factory was not intialised before use!');
|
|
|
|
$mode = !empty($this->filename_suffix) ? substr($this->filename_suffix, 1) : '';
|
|
$success = Registry::get('db')->insert('replace', 'assets_thumbs', [
|
|
'id_asset' => 'int',
|
|
'width' => 'int',
|
|
'height' => 'int',
|
|
'mode' => 'string-3',
|
|
'filename' => 'string-255',
|
|
], [
|
|
'id_asset' => $this->image->getId(),
|
|
'width' => $this->width,
|
|
'height' => $this->height,
|
|
'mode' => $mode,
|
|
'filename' => $filename,
|
|
]);
|
|
|
|
if ($success)
|
|
{
|
|
$thumb_selector = $this->width . 'x' . $this->height . $this->filename_suffix;
|
|
$this->thumbnails[$thumb_selector] = $filename !== 'NULL' ? $filename : null;
|
|
|
|
// For consistency, write new thumbnail filename to parent Image object.
|
|
// TODO: there could still be an inconsistency if multiple objects exists for the same image asset.
|
|
$this->image->getThumbnails()[$thumb_selector] = $this->thumbnails[$thumb_selector];
|
|
|
|
return $success;
|
|
}
|
|
else
|
|
throw new UnexpectedValueException('Thumbnail queuing query failed');
|
|
}
|
|
|
|
private function markAsQueued()
|
|
{
|
|
$this->updateDb('NULL');
|
|
}
|
|
|
|
private function markAsGenerated($filename)
|
|
{
|
|
$this->updateDb($filename);
|
|
}
|
|
}
|