475 lines
15 KiB
PHP
475 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* Class: ThumbnailFile
|
|
* Creates and serves compressed image thumbnails.
|
|
*/
|
|
class ThumbnailFile {
|
|
# Integer: $orig_w
|
|
# Width of the original image.
|
|
private $orig_w = 0;
|
|
|
|
# Integer: $orig_h
|
|
# Height of the original image.
|
|
private $orig_h = 0;
|
|
|
|
# Integer: $thumb_w
|
|
# Width calculated for the thumbnail.
|
|
private $thumb_w = 1;
|
|
|
|
# Integer: $thumb_h
|
|
# Height calculated for the thumbnail.
|
|
private $thumb_h = 1;
|
|
|
|
# Integer: $crop_x
|
|
# Horizontal offset in pixels for cropping.
|
|
private $crop_x = 0;
|
|
|
|
# Integer: $crop_y
|
|
# Vertical offset in pixels for cropping.
|
|
private $crop_y = 0;
|
|
|
|
# Integer: $quality
|
|
# Quality factor for the thumbnail file.
|
|
private $quality = 80;
|
|
|
|
# Variable: $type
|
|
# The original image type detected by GD library.
|
|
private $type = 0;
|
|
|
|
# Boolean: $square
|
|
# Square thumbnail image requested?
|
|
private $square = false;
|
|
|
|
# String: $source
|
|
# The source filepath supplied to the constructor.
|
|
private $source = null;
|
|
|
|
# String: $name
|
|
# The unique destination name generated from parameters.
|
|
private $name = null;
|
|
|
|
# String: $destination
|
|
# The destination filepath.
|
|
private $destination = null;
|
|
|
|
# Boolean: $creatable
|
|
# Can a thumbnail be created?
|
|
private $creatable = null;
|
|
|
|
/**
|
|
* Function: __construct
|
|
* Receives the source filename and requested thumbnail parameters.
|
|
*
|
|
* Parameters:
|
|
* $filename - Filename relative to the uploads directory.
|
|
* $thumb_w - Requested thumbnail width (0 = auto).
|
|
* $thumb_h - Requested thumbnail height (0 = auto).
|
|
* $quality - Quality factor for the thumbnail file.
|
|
* $square - Create a square crop of the original image?
|
|
*/
|
|
public function __construct($filename, $thumb_w, $thumb_h, $quality, $square) {
|
|
$this->thumb_w = (int) $thumb_w;
|
|
$this->thumb_h = (int) $thumb_h;
|
|
$this->quality = (int) $quality;
|
|
$this->square = (bool) $square;
|
|
|
|
$filepath = uploaded($filename, false);
|
|
$image_info = @getimagesize($filepath);
|
|
|
|
if ($image_info === false)
|
|
return;
|
|
|
|
$this->source = $filepath;
|
|
$this->type = $image_info[2];
|
|
|
|
if ($image_info[0] != 0)
|
|
$this->orig_w = $image_info[0];
|
|
|
|
if ($image_info[1] != 0)
|
|
$this->orig_h = $image_info[1];
|
|
|
|
if ($this->thumb_w == 0 and $this->thumb_h == 0) {
|
|
$this->thumb_w = 1;
|
|
$this->thumb_h = 1;
|
|
}
|
|
|
|
if ($this->quality > 100 or $this->quality < 0)
|
|
$this->quality = 80;
|
|
|
|
$this->resize();
|
|
$this->destination = CACHES_DIR.DIR."thumbs".DIR.$this->name();
|
|
}
|
|
|
|
/**
|
|
* Function: upscaling
|
|
* Will the thumbnail be larger than the original?
|
|
*/
|
|
public function upscaling(): bool {
|
|
return (
|
|
($this->thumb_w == 0 or $this->orig_w <= $this->thumb_w) and
|
|
($this->thumb_h == 0 or $this->orig_h <= $this->thumb_h) and
|
|
(!$this->square or $this->orig_w == $this->orig_h)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Function: creatable
|
|
* Can the thumbnail file be created?
|
|
*/
|
|
public function creatable(): bool {
|
|
if (isset($this->creatable))
|
|
return $this->creatable;
|
|
|
|
if (!isset($this->source))
|
|
return $this->creatable = false;
|
|
|
|
if (!function_exists("gd_info"))
|
|
return $this->creatable = false;
|
|
|
|
$imagetypes = imagetypes();
|
|
|
|
if ($this->type == IMAGETYPE_GIF and ($imagetypes & IMG_GIF)) {
|
|
# If a square crop is requested, animation is irrelevant.
|
|
if ($this->square)
|
|
return $this->creatable = true;
|
|
|
|
# Probe the file and return false if GIF89a with animation
|
|
# because GD will only operate on the first frame of the file.
|
|
|
|
$data = @fopen(filename:$this->source, mode:"rb");
|
|
|
|
if ($data === false)
|
|
return $this->creatable = false;
|
|
|
|
$count = 0;
|
|
$contents = "";
|
|
|
|
# Count the number of graphic control extension blocks
|
|
# followed by an image descriptor or another extension:
|
|
# > Graphic Control Extension
|
|
# > Extension Introducer (0x21)
|
|
# > Graphic Control Label (0xf9)
|
|
# > Block Size (0x04)
|
|
# > <Packed Fields> (1 byte)
|
|
# > Delay Time (2 bytes)
|
|
# > Transparent Color Index (1 byte)
|
|
# > Block Terminator (0x00)
|
|
# > Image Descriptor / Graphic Control Extension
|
|
# > Extension Introducer (0x2c / 0x21)
|
|
#
|
|
# See also:
|
|
# https://www.w3.org/Graphics/GIF/spec-gif89a.txt
|
|
#
|
|
while (!feof($data) and $count < 2) {
|
|
$contents.= fread($data, 102400);
|
|
|
|
$count = (int) preg_match_all(
|
|
"/\\x21\\xf9\\x04.{4}\\x00(\\x2c|\\x21)/s",
|
|
$contents
|
|
);
|
|
}
|
|
|
|
return $this->creatable = ($count < 2) ?
|
|
true :
|
|
false ;
|
|
}
|
|
|
|
if ($this->type == IMAGETYPE_JPEG and ($imagetypes & IMG_JPEG))
|
|
return $this->creatable = true;
|
|
|
|
if ($this->type == IMAGETYPE_PNG and ($imagetypes & IMG_PNG))
|
|
return $this->creatable = true;
|
|
|
|
if ($this->type == IMAGETYPE_WEBP and ($imagetypes & IMG_WEBP)) {
|
|
# Probe the file and return false if WEBP VP8X with animation
|
|
# because GD will throw a PHP Fatal error on imagecreatefromwebp().
|
|
|
|
$data = @file_get_contents(filename:$this->source, length:21);
|
|
|
|
if ($data === false or strlen($data) < 21)
|
|
return $this->creatable = false;
|
|
|
|
# Unpack the following data from the first 21 bytes of the WEBP file:
|
|
# > File header (12 bytes)
|
|
# > First chunk:
|
|
# > Chunk header (4 bytes)
|
|
# > Chunk size (4 bytes)
|
|
# > Chunk payload (1 byte only)
|
|
#
|
|
# See also:
|
|
# https://developers.google.com/speed/webp/docs/riff_container
|
|
#
|
|
$header = unpack(
|
|
"A4riff/Vfilesize/A4webp/A4header/Vsize/Cpayload",
|
|
$data
|
|
);
|
|
|
|
# Discover if VP8X header is present and animation bit is set.
|
|
if ($header['header'] == "VP8X" and $header['payload'] & 0x02)
|
|
return $this->creatable = false;
|
|
|
|
return $this->creatable = true;
|
|
}
|
|
|
|
if (!defined('IMAGETYPE_AVIF'))
|
|
return $this->creatable = false;
|
|
|
|
if (!defined('IMG_AVIF'))
|
|
return $this->creatable = false;
|
|
|
|
if ($this->type == IMAGETYPE_AVIF and ($imagetypes & IMG_AVIF))
|
|
return $this->creatable = true;
|
|
|
|
return $this->creatable = false;
|
|
}
|
|
|
|
/**
|
|
* Function: extension
|
|
* Returns the correct extension for the image.
|
|
*/
|
|
public function extension(): string|false {
|
|
return image_type_to_extension($this->type);
|
|
}
|
|
|
|
/**
|
|
* Function: mime_type
|
|
* Returns the correct MIME type for the image.
|
|
*/
|
|
public function mime_type(): string|false {
|
|
return image_type_to_mime_type($this->type);
|
|
}
|
|
|
|
/**
|
|
* Function: name
|
|
* Generates and returns a unique name for the thumbnail file.
|
|
*/
|
|
public function name(): string|false {
|
|
if (isset($this->name))
|
|
return $this->name;
|
|
|
|
if (!isset($this->source))
|
|
return false;
|
|
|
|
$hash = md5(
|
|
basename($this->source).
|
|
$this->thumb_w.
|
|
$this->thumb_h.
|
|
$this->quality
|
|
);
|
|
|
|
return $this->name = $hash.$this->extension();
|
|
}
|
|
|
|
/**
|
|
* Function: create
|
|
* Creates a thumbnail file using the supplied parameters.
|
|
*
|
|
* Parameters:
|
|
* $overwrite - Overwrite an existing thumbnail file?
|
|
*/
|
|
public function create($overwrite = false): bool {
|
|
if (!$this->creatable())
|
|
return false;
|
|
|
|
# Check if a fresh image thumbnail already exists.
|
|
if (
|
|
!$overwrite and
|
|
file_exists($this->destination) and
|
|
filemtime($this->destination) >= filemtime($this->source)
|
|
) {
|
|
if (DEBUG)
|
|
error_log("IMAGE fresh ".$this->destination);
|
|
|
|
return true;
|
|
}
|
|
|
|
$thumb = imagecreatetruecolor($this->thumb_w, $this->thumb_h);
|
|
|
|
if ($thumb === false)
|
|
error(
|
|
__("Error"),
|
|
__("Failed to create image thumbnail.")
|
|
);
|
|
|
|
switch ($this->type) {
|
|
case IMAGETYPE_GIF:
|
|
$original = @imagecreatefromgif($this->source);
|
|
break;
|
|
case IMAGETYPE_JPEG:
|
|
$original = @imagecreatefromjpeg($this->source);
|
|
imageinterlace($thumb, true);
|
|
break;
|
|
case IMAGETYPE_PNG:
|
|
$original = @imagecreatefrompng($this->source);
|
|
imagealphablending($thumb, false);
|
|
imagesavealpha($thumb, true);
|
|
break;
|
|
case IMAGETYPE_WEBP:
|
|
$original = @imagecreatefromwebp($this->source);
|
|
imagealphablending($thumb, false);
|
|
imagesavealpha($thumb, true);
|
|
break;
|
|
case IMAGETYPE_AVIF:
|
|
$original = @imagecreatefromavif($this->source);
|
|
imagealphablending($thumb, false);
|
|
imagesavealpha($thumb, true);
|
|
break;
|
|
}
|
|
|
|
if ($original === false)
|
|
error(
|
|
__("Error"),
|
|
__("Failed to create image thumbnail.")
|
|
);
|
|
|
|
# Do the crop and resize.
|
|
imagecopyresampled(
|
|
$thumb,
|
|
$original,
|
|
0,
|
|
0,
|
|
$this->crop_x,
|
|
$this->crop_y,
|
|
$this->thumb_w,
|
|
$this->thumb_h,
|
|
$this->orig_w,
|
|
$this->orig_h
|
|
);
|
|
|
|
# Create the thumbnail file.
|
|
switch ($this->type) {
|
|
case IMAGETYPE_GIF:
|
|
$result = imagegif(
|
|
$thumb,
|
|
$this->destination
|
|
);
|
|
break;
|
|
case IMAGETYPE_JPEG:
|
|
$result = imagejpeg(
|
|
$thumb,
|
|
$this->destination,
|
|
$this->quality
|
|
);
|
|
break;
|
|
case IMAGETYPE_PNG:
|
|
$result = imagepng(
|
|
$thumb,
|
|
$this->destination
|
|
);
|
|
break;
|
|
case IMAGETYPE_WEBP:
|
|
$result = imagewebp(
|
|
$thumb,
|
|
$this->destination,
|
|
$this->quality
|
|
);
|
|
break;
|
|
case IMAGETYPE_AVIF:
|
|
$result = imageavif(
|
|
$thumb,
|
|
$this->destination,
|
|
$this->quality
|
|
);
|
|
break;
|
|
}
|
|
|
|
imagedestroy($thumb);
|
|
imagedestroy($original);
|
|
|
|
if ($result === false)
|
|
error(
|
|
__("Error"),
|
|
__("Failed to create image thumbnail.")
|
|
);
|
|
|
|
if (DEBUG)
|
|
error_log("IMAGE created ".$this->destination);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Function: serve
|
|
* Serves a thumbnail file with correct Content-Type header.
|
|
*/
|
|
public function serve(): bool {
|
|
if (!file_exists($this->destination))
|
|
return false;
|
|
|
|
header("Content-Type: ".$this->mime_type());
|
|
readfile($this->destination);
|
|
|
|
if (DEBUG)
|
|
error_log("IMAGE served ".$this->destination);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Function: resize
|
|
* Computes the final dimensions based on supplied parameters.
|
|
*/
|
|
private function resize(): void {
|
|
$scale_x = ($this->thumb_w > 0) ?
|
|
$this->thumb_w / $this->orig_w :
|
|
0 ;
|
|
|
|
$scale_y = ($this->thumb_h > 0) ?
|
|
$this->thumb_h / $this->orig_h :
|
|
0 ;
|
|
|
|
if ($this->square) {
|
|
if ($this->thumb_w > $this->thumb_h)
|
|
$this->thumb_h = $this->thumb_w;
|
|
|
|
if ($this->thumb_h > $this->thumb_w)
|
|
$this->thumb_w = $this->thumb_h;
|
|
|
|
# Portrait orientation.
|
|
if ($this->orig_w > $this->orig_h) {
|
|
$this->crop_x = round(
|
|
($this->orig_w - $this->orig_h) / 2
|
|
);
|
|
$this->orig_w = $this->orig_h;
|
|
}
|
|
|
|
# Landscape orientation.
|
|
if ($this->orig_h > $this->orig_w) {
|
|
$this->crop_y = round(
|
|
($this->orig_h - $this->orig_w) / 2
|
|
);
|
|
$this->orig_h = $this->orig_w;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if ($this->thumb_h == 0) {
|
|
$this->thumb_h = round(
|
|
($this->thumb_w / $this->orig_w) * $this->orig_h
|
|
);
|
|
return;
|
|
}
|
|
|
|
if ($this->thumb_w == 0) {
|
|
$this->thumb_w = round(
|
|
($this->thumb_h / $this->orig_h) * $this->orig_w
|
|
);
|
|
return;
|
|
}
|
|
|
|
# Recompute to retain aspect ratio and stay within bounds.
|
|
if ($scale_x != $scale_y) {
|
|
if ($this->orig_w * $scale_y <= $this->thumb_w) {
|
|
$this->thumb_w = round($this->orig_w * $scale_y);
|
|
return;
|
|
}
|
|
|
|
if ($this->orig_h * $scale_x <= $this->thumb_h) {
|
|
$this->thumb_h = round($this->orig_h * $scale_x);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|