Settings > Advanced > Image Resizer
private $quality = 85; // default quality for jpeg/webp
private $sharpen = true; // sharpen images, only applies when resampling down
private $smart_crop = false; // crops image based on image entropy
private $progressive = false; // progressive for jpeg/webp
private $copy_icc_profile = false; // copy ICC color profile for jpeg
private $allowed_width = array(100, 200, 320, 480, 640, 800, 1024, 1280, 1600, 4096, 8192, 16384); // default allowed width requests. Includes sizes for panorama.
private $max_memory = 128; // max memory allowance when assigning MORE memory than in ini_get('memory_limit')
// private vars
private $min_resize_factor = .9; // minimum difference between resize and original for resampling to take place
private $memory_tweak = 2; // tweak factor assigned to estimating required memory to resize/crop images.
private $convert_legacy_cache = true; // convert and move image cache from X3.28.0 and earlier
private $legacy_quality = 90; // default for converting legacy image cache (must match q from legacy cache)
// object vars
function object_vars(){
$debug = get_object_vars($this);
foreach (array('x3_root', 'abs_path', 'cache_dir', 'cache_path') as $key) if(isset($debug[$key])) $debug[$key] = str_replace($_SERVER['DOCUMENT_ROOT'], '***', $debug[$key]);
return highlight_string('
' . $this->object_vars();
$this->msg('Error', $msg, $code);
}
// debug
private function debug(){
$render_path = $this->x3_root . DIRECTORY_SEPARATOR . 'render';
$this->render_exists = is_dir($render_path);
if($this->render_exists) {
$this->render_is_writeable = is_writable($render_path);
$this->cache_dir_exists = is_dir($this->cache_dir);
if($this->cache_dir_exists){
$this->cache_dir_is_writeable = is_writable($this->cache_dir);
$this->cache_file_exists = is_file($this->cache_path);
if($this->cache_file_exists) $this->cache_file_is_writeable = is_writable($this->cache_path);
}
}
$msg = $this->object_vars();
$this->msg('Debug', $msg, 200);
}
private function msg($title, $msg, $code = 400){
header('Expires: ' . gmdate('D, d M Y H:i:s') . ' GMT');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, s-maxage=0');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
if(function_exists('http_response_code')) http_response_code($code);
exit('
' . $title . '
' . $msg);
}
// get parameters
private function get_request(){
$request_array = explode('/render/', strtok($_SERVER['REQUEST_URI'], '?'), 2);
$request = count($request_array) === 2 && !empty($request_array[1]) ? array_filter(explode('/', $request_array[1], 2)) : false;
// error / must be params+path
if(empty($request) || count($request) !== 2) $this->error('Invalid request parameters.');
// params
$p = array();
foreach (explode('-', $request[0]) as $item) {
$val = substr($item, 1);
if($val) $p[$item[0]] = $val; // if $val, must be key also
}
// error / must have either w or c
if(!isset($p['w']) && !isset($p['c'])) $this->error('Must specify either w[width] or c[crop].');
// get width
$width = isset($p['w']) ? (int) $p['w'] : false;
// get crop
$crop = isset($p['c']) ? preg_split('/(\.|:)/', $p['c']) : false;
if($crop) {
$ratio = count($crop) === 2 ? array_filter(array_map('intval', $crop)) : array();
if(count($ratio) !== 2) $this->error('Crop must be in a valid width:height format with values larger than 0.');
// round crop ratio down to lowest full numbers (4.2 -> 2.1, 3.9 -> 1.3, etc)
function gcd($a, $b) { return $b ? gcd($b, $a % $b) : $a; }
$gcd_num = array_reduce($ratio, 'gcd');
if($gcd_num != 1) foreach ($ratio as $key => $val) $ratio[$key] = $val / $gcd_num;
$crop_ratio = $ratio[0] / $ratio[1];
if($crop_ratio > 10 || $crop_ratio < .1) $this->error('Crop ratio must be 10 or lower.');
}
// set request values
$this->request = array(
'width' => $width ?: false,
'crop_ratio' => $crop ? $crop_ratio : false,
'request' => implode('-', array_filter(array($width ? 'w' . $width : 0, $crop ? 'c' . $ratio[0] . '.' . $ratio[1] : 0)))
);
// set paths
$this->x3_root = PHP_MAJOR_VERSION >= 7 ? dirname(__DIR__, 3) : dirname(dirname(dirname(__DIR__)));
$this->x3_url_path = str_replace($_SERVER['DOCUMENT_ROOT'], '', $this->x3_root);
//$this->rel_path = trim(urldecode($request[1]), '/');
$this->rel_path = trim(rawurldecode($request[1]), '/');
$this->content_path = '/content/' . $this->rel_path;
//$abs_path = $this->x3_root . $this->content_path;
$this->abs_path = realpath($this->x3_root . $this->content_path); // <- follows symlinks
if(!$this->abs_path || !is_file($this->abs_path) || strpos(dirname($this->rel_path), ':') || preg_match('/(\.\.|<|>)/', $this->rel_path)) $this->error('Invalid path or file does not exist ' . $this->rel_path . '', 404);
}
// set debug
private function set_debug() {
$request_uri = $_SERVER['REQUEST_URI'];
$this->debug = (bool) strpos($request_uri, '?debug');
$this->force = $this->debug ? (bool) strpos($request_uri, '&force') : false;
if($this->force) $this->debug = false;
}
// set cache path and get cache
private function get_cache(){
$this->cache_path = $this->x3_root . DIRECTORY_SEPARATOR . 'render' . DIRECTORY_SEPARATOR . $this->request['request'] . DIRECTORY_SEPARATOR . $this->rel_path;
// return if debug or force
if($this->debug || $this->force) return;
// serve from cache if exists
if(file_exists($this->cache_path)) $this->serve_image($this->cache_path, 'Cache');
}
// get source image data
private function get_source(){
$types = array(1 => 'gif', 2 => 'jpeg', 3 => 'png');
if(defined('IMAGETYPE_WEBP')) $types[IMAGETYPE_WEBP] = 'webp';
$this->info = getimagesize($this->abs_path, $extra);
$this->iptc = is_array($extra) && isset($extra['APP13']) ? iptcparse($extra['APP13']) : null;
$this->source = !empty($this->info) && is_array($this->info) && count($this->info) > 3 ? array_filter(array(
'width' => (int) $this->info[0],
'height' => (int) $this->info[1],
'aspect' => $this->info[0] > 0 && $this->info[1] > 0 ? (float) $this->info[0] / $this->info[1] : false,
'type' => (int) $this->info[2],
'type_name' => $this->get_prop_val($this->info[2], $types),
'mime' => isset($this->info['mime']) && is_string($this->info['mime']) && substr($this->info['mime'], 0, 6) === 'image/' ? $this->info['mime'] : false
)) : array();
if(count($this->source) !== 6) $this->error('Invalid image ' . $this->rel_path . '', 400);
}
// get config
private function get_config() {
$path = $this->x3_root . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.user.json';
$content = is_file($path) ? file_get_contents($path) : false;
$config = $content ? json_decode($content, TRUE) : false;
$r = !empty($config) && isset($config['back']['image_resizer']) ? $config['back']['image_resizer'] : false;
if(!empty($r)){
if(isset($r['default_quality'])) {
$this->quality = $r['default_quality'];
$this->legacy_quality = $r['default_quality'];
}
if(isset($r['progressive']) && in_array($this->source['type'], array(2, 18))) $this->progressive = $r['progressive'];
if(isset($r['copy_icc_profile']) && $this->source['type'] === 2) $this->copy_icc_profile = $r['copy_icc_profile'];
if(isset($r['sharpen'])) $this->sharpen = $r['sharpen'];
if(isset($r['smart_crop'])) $this->smart_crop = $r['smart_crop'];
if(isset($r['allowed_width'])) {
$this->allowed_width = $r['allowed_width'] == '0' ? array() : array_unique(array_merge($this->allowed_width, array_filter(array_map('intval', explode(',', $r['allowed_width'])))));
sort($this->allowed_width, SORT_NUMERIC);
}
if(isset($r['max_memory'])) $this->max_memory = (int) $r['max_memory'];
if($this->debug) $this->config = $r;
}
if(!$this->debug && $this->request['width'] && !empty($this->allowed_width) && !in_array($this->request['width'], $this->allowed_width)) $this->error('Invalid width request ' . $this->request['width'] . '. Allowed widths:
[' . implode(', ', $this->allowed_width) . ']');
}
// calculate render
private function get_render(){
$src_width = $this->source['width'];
$src_height = $this->source['height'];
$src_aspect = $this->source['aspect'];
$request_width = $this->request['width'] ?: $src_width;
$request_aspect = $this->request['crop_ratio'] ?: $src_aspect;
$resize = $request_width < $src_width * $this->min_resize_factor;
$crop = $src_aspect != $request_aspect;
$crop_factor = $src_aspect / $request_aspect;
$crop_width = $crop_factor > 1 ? $src_width / $crop_factor : $src_width;
$crop_height = $crop_factor < 1 ? $src_height * $crop_factor : $src_height;
$dst_w = $request_width > $crop_width * $this->min_resize_factor ? $crop_width : $request_width;
$dst_h = $dst_w / $request_aspect;
$resample = $crop_width > $dst_w;// && $crop_height > $dst_h;
if(!$resample) $this->sharpen = false;
$resize_width = $crop_factor > 1 ? $dst_w * $crop_factor : $dst_w;
$resize_height = $crop_factor < 1 ? $dst_h / $crop_factor : $dst_h;
$this->render = array(
'crop' => $crop,
'crop_factor' => $crop_factor,
'original' => !$crop && !$resize,
'crop_width' => (int) round($crop_width),
'crop_height' => (int) round($crop_height),
'dst_w' => (int) round($dst_w),
'dst_h' => (int) round($dst_h),
'resize_width' => (int) round($resize_width),
'resize_height' => (int) round($resize_height),
'resample' => $resample,
'center_crop' => $crop ? array(
$crop_factor > 1 ? (int) round(($resize_width - $dst_w) / 2) : 0,
$crop_factor < 1 ? (int) round(($resize_height - $dst_h) / 2) : 0
) : false
);
}
// set cache dir
private function set_cache_dir($retry = false){
$this->cache_dir = dirname($this->cache_path);
// create cache_dir
if(!$this->debug && !file_exists($this->cache_dir) && !@mkdir($this->cache_dir, 0777, true)) {
// if we get to here, something is wrong.
// 1. Either cannot write (wrong permissions)
// 2. Or mkdir already processed by another request running simultaneously. Check again.
if($retry) $this->error('Failed to create cache directory ' . $this->cache_dir, 500);
usleep(200000); // wait 0.2 seconds
$this->set_cache_dir(true);
}
}
// sharpen (auto)
private function sharpen($image){
$final = sqrt($this->render['dst_w'] * $this->render['dst_h']) * (750.0 / sqrt($this->render['crop_width'] * $this->render['crop_height']));
$result = max(round(52 + -0.27810650887573124 * $final + .00047337278106508946 * $final * $final), 0);
if(!imageconvolution($image, array(
array(-1, -2, -1),
array(-2, $result + 12, -2),
array(-1, -2, -1)
), $result, 0)) $this->error('Failed to sharpen image.', 500);
}
// get non-empty prop val
private function get_prop_val($name, $array){
return isset($array[$name]) && !empty($array[$name]) ? $array[$name] : false;
}
// get mime. A bit overkill, but won't fail.
private function get_mime($path){
// mime should always match source (if assigned)
if(isset($this->source['mime'])) return $this->source['mime'];
// mime_content_type
if(function_exists('mime_content_type')){
$mime = mime_content_type($path);
if($mime && strtok($mime, '/') == 'image') return $mime;
}
// finfo_open
if(function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $path);
finfo_close($finfo);
if($mime && strtok($mime, '/') == 'image') return $mime;
}
// mime from extension
$mime_ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if($mime_ext && in_array($mime_ext, array('jpg', 'jpeg', 'webp', 'png', 'gif'))) return 'image/' . str_replace('jpg', 'jpeg', $mime_ext);
// last resort assume jpeg
return 'image/jpeg';
}
// serve image
private function serve_image($path, $msg = '', $data = false){
// header("Last-Modified: $lastModified");
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 315360000) . ' GMT');
header('Cache-Control: public, max-age=315360000, s-max-age=315360000');
header('content-type: ' . $this->get_mime($path));
header('content-length: ' . ($data ? strlen($data) : filesize($path)));
$request_time = $this->get_prop_val('REQUEST_TIME_FLOAT', $_SERVER);
$info = implode(', ', array_filter(array(
isset($this->render) ? $this->source['width'] . 'x' . $this->source['height'] . ' => ' . $this->render['dst_w'] . 'x' . $this->render['dst_h'] : false,
round(memory_get_peak_usage() / 1048576, 1) . 'M',
$request_time ? round((microtime(true) - $request_time) * 1000) . 'ms' : false
)));
header('X3-Resizer: ' . $msg . ' [' . $info . ']');
if($data) {
echo $data;
} else if(!readfile($path)){
header('content-type: text/html');
$url_path = str_replace($_SERVER['DOCUMENT_ROOT'], '', $path);
$this->error('Could not read file ' . $url_path . '', 404);
}
exit;
}
// copy ICC profile
private function copyICCProfile(){
require __DIR__ . '/icc/class.jpeg_icc.php';
try {
$o = new JPEG_ICC();
$o->LoadFromJPEG($this->abs_path);
$o->SaveToJPEG($this->cache_path);
} catch (Exception $e) {
// on fail, continue // $this->error('Failed to copy ICC color profile to ' . $this->cache_path, 500);
}
}
// set memory
private function set_memory(){
// get
$limit = function_exists('ini_get') ? (int) @ini_get('memory_limit') : 0;
if($limit < 1 || $limit >= $this->max_memory) return;
// destination resize/smart_crop or crop
$dst_w = $this->render['resample'] || $this->smart_crop ? $this->render['resize_width'] : $this->render['dst_w'];
$dst_h = $this->render['resample'] || $this->smart_crop ? $this->render['resize_height'] : $this->render['dst_h'];
// memory required / (src + resize) * tweak / MB
$src_area = $this->source['width'] * $this->source['height'];
$dst_area = $dst_w * $dst_h;
$mb = 1048576;
$desired = 2 * $mb + ($src_area * 3 + $dst_area * 3) * $this->memory_tweak;
if($this->sharpen) $desired += $dst_area * 4 / ($src_area/$dst_area);
$desired = (int) ceil($desired / $mb);
$new = min($desired, $this->max_memory);
// set ini
$changed = $new > $limit ? (bool) @ini_set('memory_limit', $new . 'M') : false;
// debug
if($this->debug) $this->memory = array('limit' => $limit . 'M', 'desired' => $desired . 'M', 'new' => $new . 'M', 'changed' => $changed);
}
// convert and serve from legacy cache (X3.28.0 and earlier)
private function legacy_cache(){
// return
if(!$this->convert_legacy_cache || $this->debug || $this->force) return;
// legacy cache dir
$legacy_cache_dir = $this->x3_root . DIRECTORY_SEPARATOR . '_cache' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'rendered';
// return
if(!is_dir($legacy_cache_dir)) return;
// array to match legacy cache file names in _cache/images/rendered directory
$arr = array(
'path' => '../../..' . $this->content_path,
'width' => (float) $this->render['resize_width'],
'height' => (float) $this->render['resize_height'],
'cropWidth' => $this->render['crop'] ? (float) $this->render['dst_w'] : null,
'cropHeight' => $this->render['crop'] ? (float) $this->render['dst_h'] : null,
'iptc' => $this->iptc,
'quality' => (int) $this->legacy_quality,
'progressive' => true,
'background' => null,
'cropper' => 'centered'
);
// legacy cache path
$legacy_cache_path = $legacy_cache_dir . DIRECTORY_SEPARATOR . md5($arr['path'] . serialize($arr));
// return if legacy cache does not exist
if(!is_file($legacy_cache_path)) return;
// attempt to move and rename legacy cache file to new cache path
if(!rename($legacy_cache_path, $this->cache_path)) $this->error('Failed to move legacy cache from ' . $legacy_cache_path . ' to ' . $this->cache_path, 500);
// success, serve converted image
$this->serve_image($this->cache_path, 'Legacy cache converted');
}
// construct
function __construct() {
// get request parameters from url
$this->get_request();
// set debug
$this->set_debug();
// set cache path and get cache if exists
$this->get_cache();
// get source;
$this->get_source();
// get config
$this->get_config();
// get render
$this->get_render();
// serve original image if appropriate
if(!$this->debug && $this->render['original']) $this->serve_image($this->abs_path, 'Original');
// cache dir
$this->set_cache_dir();
// legacy cache
$this->legacy_cache();
// set memory
$this->set_memory();
// debug
if($this->debug) $this->debug();
// IMAGE
// create src image (resize or crop required)
$image = call_user_func('imagecreatefrom' . $this->source['type_name'], $this->abs_path);
if(!$image) $this->error('Failed to call imagecreatefrom' . $this->source['type_name'] . '()', 500);
// resize required
if($this->render['resample']){
$resized_image = imagecreatetruecolor($this->render['resize_width'], $this->render['resize_height']);
if(!$resized_image || !imagecopyresampled($resized_image, $image, 0, 0, 0, 0, $this->render['resize_width'], $this->render['resize_height'], $this->source['width'], $this->source['height'])) $this->error('Failed to resize image.', 500);
imagedestroy($image);
$image = $resized_image;
unset($resized_image);
}
// crop
if($this->render['crop']){
// smart crop
if($this->smart_crop) {
require __DIR__ . '/smart_crop.php';
$cropxy = (new smart_crop($image))->get_resized($this->render['dst_w'], $this->render['dst_h']);
// fallback to center_crop if smart_crop fails (could be requested crop is same as aspect, or too small image)
list($crop_x, $crop_y) = $cropxy ?: $this->render['center_crop'];
} else {
list($crop_x, $crop_y) = $this->render['center_crop'];
}
// cropit!
$cropped_image = imagecreatetruecolor($this->render['dst_w'], $this->render['dst_h']);
if(!$cropped_image || !imagecopy($cropped_image, $image, 0, 0, $crop_x, $crop_y, $this->render['dst_w'], $this->render['dst_h'])) $this->error('Failed to crop image.', 500);
imagedestroy($image);
$image = $cropped_image;
unset($cropped_image);
}
// sharpen
if($this->sharpen) $this->sharpen($image);
// progressive / interlace
if($this->progressive && !imageinterlace($image, 1)) $this->error('Failed to interlace (progressive) image.', 500);
// change quality to format defaults if png or gif
if(!in_array($this->source['type'], array(2, 18))) $this->quality = $this->source['type'] == 1 ? null : -1;
// if copy ICC profile, save to cache before copying ICC profile, then serve from cached file
if($this->copy_icc_profile) {
if(!call_user_func('image' . $this->source['type_name'], $image, $this->cache_path, $this->quality)) $this->error('Failed to create image.', 500);
imagedestroy($image);
$data = false;
$this->copyICCProfile();
// create in memory, save data to cache, server from memory
} else {
ob_start(NULL);
if(!call_user_func('image' . $this->source['type_name'], $image, null, $this->quality)) $this->error('Failed to create image.', 500);
imagedestroy($image);
$data = ob_get_contents();
ob_end_clean();
if(!file_put_contents($this->cache_path, $data)) $this->error('Failed to save cache ' . $this->cache_path, 500);
}
// serve image, from data or cache_path
$this->serve_image($this->cache_path, 'Rendered and cached', $data);
}
}
// new resizer
new resizer;