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])) { if (file_exists(THUMBSDIR . '/' . $this->image->getSubdir() . '/' . $this->thumbnails[$thumb_selector])) return THUMBSURL . '/' . $this->image->getSubdir() . '/' . $this->thumbnails[$thumb_selector]; } // Do we have a custom thumbnail on file? $custom_selector = 'custom_' . $this->width . 'x' . $this->height; if (isset($this->image_meta[$custom_selector])) { if (file_exists(ASSETSDIR . '/' . $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector])) { // Copy the custom thumbail to the general thumbnail directory. copy(ASSETSDIR . '/' . $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector], THUMBSDIR . '/' . $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector]); // Let's remember this for future reference. $this->markAsGenerated($this->image_meta[$custom_selector]); return THUMBSURL . '/' . $this->image->getSubdir() . '/' . $this->image_meta[$custom_selector]; } else throw new UnexpectedValueException('Custom thumbnail expected, but missing in file system!'); } // Is this the right moment to generate a thumbnail, then? if ($generate && array_key_exists($thumb_selector, $this->thumbnails)) { return $this->generate(); } // If not, queue it for generation at another time, and return a URL to generate it with. elseif (!$generate) { $this->markAsQueued(); return BASEURL . '/thumbnail/' . $this->image->getId() . '/' . $this->width . 'x' . $this->height . $this->filename_suffix . '/'; } // Still here..? What are you up to? ..Sneaking? else { throw new Exception("Trying to generate a thumbnail for values that were not previously initialised by the system?\n" . print_r(func_get_args(), true)); } } /** * @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; // If the original image's aspect ratio is much wider, take a slice instead. if ($this->image->ratio() > $this->ratio()) $this->crop_mode = self::CROP_MODE_SLICE_CENTRE; // 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; // 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'; } 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. if (!is_dir(THUMBSDIR . '/' . $this->image->getSubdir())) mkdir(THUMBSDIR . '/' . $this->image->getSubdir(), 0755, true); // Save it in a public spot. $thumb->writeImage(THUMBSDIR . '/' . $this->image->getSubdir() . '/' . $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; } // Blast! Curse your sudden but inevitable betrayal! 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 : ''; } return $success; } private function markAsQueued() { $this->updateDb('NULL'); } private function markAsGenerated($filename) { $this->updateDb($filename); } }