1*dd9e8e5eSAndreas Gohr<?php 2*dd9e8e5eSAndreas Gohr 3*dd9e8e5eSAndreas Gohr 4*dd9e8e5eSAndreas Gohrnamespace splitbrain\slika; 5*dd9e8e5eSAndreas Gohr 6*dd9e8e5eSAndreas Gohr/** 7*dd9e8e5eSAndreas Gohr * Lightweight, metadata-only inspection of an image. 8*dd9e8e5eSAndreas Gohr * 9*dd9e8e5eSAndreas Gohr * Uses only getimagesize() and EXIF parsing; never loads pixels or execs 10*dd9e8e5eSAndreas Gohr * ImageMagick. Mirrors Adapter's fluent API (autorotate/rotate/resize/crop) 11*dd9e8e5eSAndreas Gohr * at the dimension level, so callers can predict the dimensions an Adapter 12*dd9e8e5eSAndreas Gohr * chain would produce and emit correct width/height HTML attributes 13*dd9e8e5eSAndreas Gohr * without actually processing the image. 14*dd9e8e5eSAndreas Gohr */ 15*dd9e8e5eSAndreas Gohrclass ImageInfo 16*dd9e8e5eSAndreas Gohr{ 17*dd9e8e5eSAndreas Gohr /** @var string path to the image */ 18*dd9e8e5eSAndreas Gohr protected $imagepath; 19*dd9e8e5eSAndreas Gohr /** @var int raw width as stored on disk */ 20*dd9e8e5eSAndreas Gohr protected $rawWidth; 21*dd9e8e5eSAndreas Gohr /** @var int raw height as stored on disk */ 22*dd9e8e5eSAndreas Gohr protected $rawHeight; 23*dd9e8e5eSAndreas Gohr /** @var string image format as returned by image_type_to_extension (e.g. 'jpeg', 'png') */ 24*dd9e8e5eSAndreas Gohr protected $extension; 25*dd9e8e5eSAndreas Gohr /** @var int EXIF orientation 1..8; always 1 for non-JPEG */ 26*dd9e8e5eSAndreas Gohr protected $orientation; 27*dd9e8e5eSAndreas Gohr /** @var int currently tracked width (reflects chain operations) */ 28*dd9e8e5eSAndreas Gohr protected $width; 29*dd9e8e5eSAndreas Gohr /** @var int currently tracked height (reflects chain operations) */ 30*dd9e8e5eSAndreas Gohr protected $height; 31*dd9e8e5eSAndreas Gohr 32*dd9e8e5eSAndreas Gohr /** 33*dd9e8e5eSAndreas Gohr * @param string $imagepath 34*dd9e8e5eSAndreas Gohr * @throws Exception when the file cannot be read or is not an image 35*dd9e8e5eSAndreas Gohr */ 36*dd9e8e5eSAndreas Gohr public function __construct($imagepath) 37*dd9e8e5eSAndreas Gohr { 38*dd9e8e5eSAndreas Gohr if (!file_exists($imagepath)) { 39*dd9e8e5eSAndreas Gohr throw new Exception('image file does not exist'); 40*dd9e8e5eSAndreas Gohr } 41*dd9e8e5eSAndreas Gohr if (!is_readable($imagepath)) { 42*dd9e8e5eSAndreas Gohr throw new Exception('image file is not readable'); 43*dd9e8e5eSAndreas Gohr } 44*dd9e8e5eSAndreas Gohr 45*dd9e8e5eSAndreas Gohr $info = @getimagesize($imagepath); 46*dd9e8e5eSAndreas Gohr if ($info === false) { 47*dd9e8e5eSAndreas Gohr throw new Exception('Failed to read image information'); 48*dd9e8e5eSAndreas Gohr } 49*dd9e8e5eSAndreas Gohr 50*dd9e8e5eSAndreas Gohr $this->imagepath = $imagepath; 51*dd9e8e5eSAndreas Gohr $this->rawWidth = (int)$info[0]; 52*dd9e8e5eSAndreas Gohr $this->rawHeight = (int)$info[1]; 53*dd9e8e5eSAndreas Gohr $this->extension = image_type_to_extension($info[2], false); 54*dd9e8e5eSAndreas Gohr 55*dd9e8e5eSAndreas Gohr $this->width = $this->rawWidth; 56*dd9e8e5eSAndreas Gohr $this->height = $this->rawHeight; 57*dd9e8e5eSAndreas Gohr 58*dd9e8e5eSAndreas Gohr if ($this->extension === 'jpeg') { 59*dd9e8e5eSAndreas Gohr $this->orientation = self::readExifOrientation($imagepath); 60*dd9e8e5eSAndreas Gohr } else { 61*dd9e8e5eSAndreas Gohr $this->orientation = 1; 62*dd9e8e5eSAndreas Gohr } 63*dd9e8e5eSAndreas Gohr } 64*dd9e8e5eSAndreas Gohr 65*dd9e8e5eSAndreas Gohr /** 66*dd9e8e5eSAndreas Gohr * @return int width as stored on disk (stable regardless of chain ops) 67*dd9e8e5eSAndreas Gohr */ 68*dd9e8e5eSAndreas Gohr public function getRawWidth() 69*dd9e8e5eSAndreas Gohr { 70*dd9e8e5eSAndreas Gohr return $this->rawWidth; 71*dd9e8e5eSAndreas Gohr } 72*dd9e8e5eSAndreas Gohr 73*dd9e8e5eSAndreas Gohr /** 74*dd9e8e5eSAndreas Gohr * @return int height as stored on disk (stable regardless of chain ops) 75*dd9e8e5eSAndreas Gohr */ 76*dd9e8e5eSAndreas Gohr public function getRawHeight() 77*dd9e8e5eSAndreas Gohr { 78*dd9e8e5eSAndreas Gohr return $this->rawHeight; 79*dd9e8e5eSAndreas Gohr } 80*dd9e8e5eSAndreas Gohr 81*dd9e8e5eSAndreas Gohr /** 82*dd9e8e5eSAndreas Gohr * @return string 'jpeg', 'png', 'gif', 'webp', ... 83*dd9e8e5eSAndreas Gohr */ 84*dd9e8e5eSAndreas Gohr public function getExtension() 85*dd9e8e5eSAndreas Gohr { 86*dd9e8e5eSAndreas Gohr return $this->extension; 87*dd9e8e5eSAndreas Gohr } 88*dd9e8e5eSAndreas Gohr 89*dd9e8e5eSAndreas Gohr /** 90*dd9e8e5eSAndreas Gohr * @return int EXIF orientation 1..8, defaults to 1 for non-JPEG or missing tag 91*dd9e8e5eSAndreas Gohr */ 92*dd9e8e5eSAndreas Gohr public function getOrientation() 93*dd9e8e5eSAndreas Gohr { 94*dd9e8e5eSAndreas Gohr return $this->orientation; 95*dd9e8e5eSAndreas Gohr } 96*dd9e8e5eSAndreas Gohr 97*dd9e8e5eSAndreas Gohr /** 98*dd9e8e5eSAndreas Gohr * @return int currently tracked width (after any chain operations) 99*dd9e8e5eSAndreas Gohr */ 100*dd9e8e5eSAndreas Gohr public function getWidth() 101*dd9e8e5eSAndreas Gohr { 102*dd9e8e5eSAndreas Gohr return $this->width; 103*dd9e8e5eSAndreas Gohr } 104*dd9e8e5eSAndreas Gohr 105*dd9e8e5eSAndreas Gohr /** 106*dd9e8e5eSAndreas Gohr * @return int currently tracked height (after any chain operations) 107*dd9e8e5eSAndreas Gohr */ 108*dd9e8e5eSAndreas Gohr public function getHeight() 109*dd9e8e5eSAndreas Gohr { 110*dd9e8e5eSAndreas Gohr return $this->height; 111*dd9e8e5eSAndreas Gohr } 112*dd9e8e5eSAndreas Gohr 113*dd9e8e5eSAndreas Gohr /** 114*dd9e8e5eSAndreas Gohr * @return array [width, height] currently tracked 115*dd9e8e5eSAndreas Gohr */ 116*dd9e8e5eSAndreas Gohr public function getDimensions() 117*dd9e8e5eSAndreas Gohr { 118*dd9e8e5eSAndreas Gohr return [$this->width, $this->height]; 119*dd9e8e5eSAndreas Gohr } 120*dd9e8e5eSAndreas Gohr 121*dd9e8e5eSAndreas Gohr /** 122*dd9e8e5eSAndreas Gohr * Simulate Adapter::autorotate() at the dimension level. 123*dd9e8e5eSAndreas Gohr * 124*dd9e8e5eSAndreas Gohr * For JPEGs with EXIF orientation 5/6/7/8 the tracked width and height 125*dd9e8e5eSAndreas Gohr * are swapped; all other cases are no-ops. 126*dd9e8e5eSAndreas Gohr * 127*dd9e8e5eSAndreas Gohr * @return $this 128*dd9e8e5eSAndreas Gohr * @throws Exception 129*dd9e8e5eSAndreas Gohr */ 130*dd9e8e5eSAndreas Gohr public function autorotate() 131*dd9e8e5eSAndreas Gohr { 132*dd9e8e5eSAndreas Gohr if ($this->extension !== 'jpeg') { 133*dd9e8e5eSAndreas Gohr return $this; 134*dd9e8e5eSAndreas Gohr } 135*dd9e8e5eSAndreas Gohr return $this->rotate($this->orientation); 136*dd9e8e5eSAndreas Gohr } 137*dd9e8e5eSAndreas Gohr 138*dd9e8e5eSAndreas Gohr /** 139*dd9e8e5eSAndreas Gohr * Simulate Adapter::rotate() at the dimension level. 140*dd9e8e5eSAndreas Gohr * 141*dd9e8e5eSAndreas Gohr * @param int $orientation EXIF rotation flag 0..8 142*dd9e8e5eSAndreas Gohr * @return $this 143*dd9e8e5eSAndreas Gohr * @throws Exception on invalid orientation 144*dd9e8e5eSAndreas Gohr */ 145*dd9e8e5eSAndreas Gohr public function rotate($orientation) 146*dd9e8e5eSAndreas Gohr { 147*dd9e8e5eSAndreas Gohr $orientation = (int)$orientation; 148*dd9e8e5eSAndreas Gohr if ($orientation < 0 || $orientation > 8) { 149*dd9e8e5eSAndreas Gohr throw new Exception('Unknown rotation given'); 150*dd9e8e5eSAndreas Gohr } 151*dd9e8e5eSAndreas Gohr if (in_array($orientation, [5, 6, 7, 8])) { 152*dd9e8e5eSAndreas Gohr list($this->width, $this->height) = [$this->height, $this->width]; 153*dd9e8e5eSAndreas Gohr } 154*dd9e8e5eSAndreas Gohr return $this; 155*dd9e8e5eSAndreas Gohr } 156*dd9e8e5eSAndreas Gohr 157*dd9e8e5eSAndreas Gohr /** 158*dd9e8e5eSAndreas Gohr * Simulate Adapter::resize() at the dimension level. 159*dd9e8e5eSAndreas Gohr * 160*dd9e8e5eSAndreas Gohr * Fits the image into the given bounding box while preserving the 161*dd9e8e5eSAndreas Gohr * aspect ratio. Omitting one dimension (0 or empty) auto-calculates it. 162*dd9e8e5eSAndreas Gohr * 163*dd9e8e5eSAndreas Gohr * @param int|string $width in pixels or % 164*dd9e8e5eSAndreas Gohr * @param int|string $height in pixels or % 165*dd9e8e5eSAndreas Gohr * @return $this 166*dd9e8e5eSAndreas Gohr * @throws Exception when both dimensions are zero 167*dd9e8e5eSAndreas Gohr */ 168*dd9e8e5eSAndreas Gohr public function resize($width, $height) 169*dd9e8e5eSAndreas Gohr { 170*dd9e8e5eSAndreas Gohr list($w, $h) = self::boundingBox($this->width, $this->height, $width, $height); 171*dd9e8e5eSAndreas Gohr $this->width = (int)$w; 172*dd9e8e5eSAndreas Gohr $this->height = (int)$h; 173*dd9e8e5eSAndreas Gohr return $this; 174*dd9e8e5eSAndreas Gohr } 175*dd9e8e5eSAndreas Gohr 176*dd9e8e5eSAndreas Gohr /** 177*dd9e8e5eSAndreas Gohr * Simulate Adapter::crop() at the dimension level. 178*dd9e8e5eSAndreas Gohr * 179*dd9e8e5eSAndreas Gohr * Result equals the output size of Adapter::crop(): exactly ($w, $h) 180*dd9e8e5eSAndreas Gohr * when both are given, or a ($w, $w) / ($h, $h) square when only one is. 181*dd9e8e5eSAndreas Gohr * 182*dd9e8e5eSAndreas Gohr * @param int|string $width in pixels or % 183*dd9e8e5eSAndreas Gohr * @param int|string $height in pixels or % 184*dd9e8e5eSAndreas Gohr * @return $this 185*dd9e8e5eSAndreas Gohr * @throws Exception when both dimensions are zero 186*dd9e8e5eSAndreas Gohr */ 187*dd9e8e5eSAndreas Gohr public function crop($width, $height) 188*dd9e8e5eSAndreas Gohr { 189*dd9e8e5eSAndreas Gohr $width = self::cleanDimension($width, $this->width); 190*dd9e8e5eSAndreas Gohr $height = self::cleanDimension($height, $this->height); 191*dd9e8e5eSAndreas Gohr 192*dd9e8e5eSAndreas Gohr if ($width == 0 && $height == 0) { 193*dd9e8e5eSAndreas Gohr throw new Exception('You can not crop to 0x0'); 194*dd9e8e5eSAndreas Gohr } 195*dd9e8e5eSAndreas Gohr 196*dd9e8e5eSAndreas Gohr if (!$height) { 197*dd9e8e5eSAndreas Gohr $height = $width; 198*dd9e8e5eSAndreas Gohr } 199*dd9e8e5eSAndreas Gohr if (!$width) { 200*dd9e8e5eSAndreas Gohr $width = $height; 201*dd9e8e5eSAndreas Gohr } 202*dd9e8e5eSAndreas Gohr 203*dd9e8e5eSAndreas Gohr $this->width = (int)$width; 204*dd9e8e5eSAndreas Gohr $this->height = (int)$height; 205*dd9e8e5eSAndreas Gohr return $this; 206*dd9e8e5eSAndreas Gohr } 207*dd9e8e5eSAndreas Gohr 208*dd9e8e5eSAndreas Gohr /** 209*dd9e8e5eSAndreas Gohr * Read the EXIF orientation tag of a JPEG file. 210*dd9e8e5eSAndreas Gohr * 211*dd9e8e5eSAndreas Gohr * Prefers exif_read_data() when available; otherwise falls back to a 212*dd9e8e5eSAndreas Gohr * raw-byte scan of the first 70 KB of the file. Returns 1 when no 213*dd9e8e5eSAndreas Gohr * orientation tag is found. 214*dd9e8e5eSAndreas Gohr * 215*dd9e8e5eSAndreas Gohr * @param string $path 216*dd9e8e5eSAndreas Gohr * @return int 1..8 217*dd9e8e5eSAndreas Gohr */ 218*dd9e8e5eSAndreas Gohr public static function readExifOrientation($path) 219*dd9e8e5eSAndreas Gohr { 220*dd9e8e5eSAndreas Gohr if (function_exists('exif_read_data')) { 221*dd9e8e5eSAndreas Gohr $exif = exif_read_data($path); 222*dd9e8e5eSAndreas Gohr if (!empty($exif['Orientation'])) { 223*dd9e8e5eSAndreas Gohr return (int)$exif['Orientation']; 224*dd9e8e5eSAndreas Gohr } 225*dd9e8e5eSAndreas Gohr return 1; 226*dd9e8e5eSAndreas Gohr } 227*dd9e8e5eSAndreas Gohr return self::readExifOrientationFromBytes($path); 228*dd9e8e5eSAndreas Gohr } 229*dd9e8e5eSAndreas Gohr 230*dd9e8e5eSAndreas Gohr /** 231*dd9e8e5eSAndreas Gohr * Raw-byte fallback for reading the EXIF orientation tag. 232*dd9e8e5eSAndreas Gohr * 233*dd9e8e5eSAndreas Gohr * Exposed so the fallback path can be tested even on systems with the 234*dd9e8e5eSAndreas Gohr * exif extension installed. 235*dd9e8e5eSAndreas Gohr * 236*dd9e8e5eSAndreas Gohr * @param string $path 237*dd9e8e5eSAndreas Gohr * @return int 1..8 238*dd9e8e5eSAndreas Gohr * @link https://gist.github.com/EionRobb/8e0c76178522bc963c75caa6a77d3d37#file-imagecreatefromstring_autorotate-php-L15 239*dd9e8e5eSAndreas Gohr */ 240*dd9e8e5eSAndreas Gohr public static function readExifOrientationFromBytes($path) 241*dd9e8e5eSAndreas Gohr { 242*dd9e8e5eSAndreas Gohr $data = @file_get_contents($path, false, null, 0, 70000); 243*dd9e8e5eSAndreas Gohr if ($data === false) { 244*dd9e8e5eSAndreas Gohr return 1; 245*dd9e8e5eSAndreas Gohr } 246*dd9e8e5eSAndreas Gohr if (preg_match('@\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00@', $data, $matches)) { 247*dd9e8e5eSAndreas Gohr // little endian EXIF 248*dd9e8e5eSAndreas Gohr return ord($matches[1]); 249*dd9e8e5eSAndreas Gohr } 250*dd9e8e5eSAndreas Gohr if (preg_match('@\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00@', $data, $matches)) { 251*dd9e8e5eSAndreas Gohr // big endian EXIF 252*dd9e8e5eSAndreas Gohr return ord($matches[1]); 253*dd9e8e5eSAndreas Gohr } 254*dd9e8e5eSAndreas Gohr return 1; 255*dd9e8e5eSAndreas Gohr } 256*dd9e8e5eSAndreas Gohr 257*dd9e8e5eSAndreas Gohr /** 258*dd9e8e5eSAndreas Gohr * Calculate new size to fit into a bounding box, preserving aspect ratio. 259*dd9e8e5eSAndreas Gohr * 260*dd9e8e5eSAndreas Gohr * If width and height are given, the result is scaled to fit inside the 261*dd9e8e5eSAndreas Gohr * bounding box. If only one dimension is given, the other is calculated 262*dd9e8e5eSAndreas Gohr * from the aspect ratio. 263*dd9e8e5eSAndreas Gohr * 264*dd9e8e5eSAndreas Gohr * @param int $origW current width 265*dd9e8e5eSAndreas Gohr * @param int $origH current height 266*dd9e8e5eSAndreas Gohr * @param int|string $width target width (pixels or %) 267*dd9e8e5eSAndreas Gohr * @param int|string $height target height (pixels or %) 268*dd9e8e5eSAndreas Gohr * @return array [width, height] 269*dd9e8e5eSAndreas Gohr * @throws Exception 270*dd9e8e5eSAndreas Gohr */ 271*dd9e8e5eSAndreas Gohr public static function boundingBox($origW, $origH, $width, $height) 272*dd9e8e5eSAndreas Gohr { 273*dd9e8e5eSAndreas Gohr $width = self::cleanDimension($width, $origW); 274*dd9e8e5eSAndreas Gohr $height = self::cleanDimension($height, $origH); 275*dd9e8e5eSAndreas Gohr 276*dd9e8e5eSAndreas Gohr if ($width == 0 && $height == 0) { 277*dd9e8e5eSAndreas Gohr throw new Exception('You can not resize to 0x0'); 278*dd9e8e5eSAndreas Gohr } 279*dd9e8e5eSAndreas Gohr 280*dd9e8e5eSAndreas Gohr if (!$height) { 281*dd9e8e5eSAndreas Gohr // adjust to match width 282*dd9e8e5eSAndreas Gohr $height = round(($width * $origH) / $origW); 283*dd9e8e5eSAndreas Gohr } else if (!$width) { 284*dd9e8e5eSAndreas Gohr // adjust to match height 285*dd9e8e5eSAndreas Gohr $width = round(($height * $origW) / $origH); 286*dd9e8e5eSAndreas Gohr } else { 287*dd9e8e5eSAndreas Gohr // fit into bounding box 288*dd9e8e5eSAndreas Gohr $scale = min($width / $origW, $height / $origH); 289*dd9e8e5eSAndreas Gohr $width = $origW * $scale; 290*dd9e8e5eSAndreas Gohr $height = $origH * $scale; 291*dd9e8e5eSAndreas Gohr } 292*dd9e8e5eSAndreas Gohr 293*dd9e8e5eSAndreas Gohr return [$width, $height]; 294*dd9e8e5eSAndreas Gohr } 295*dd9e8e5eSAndreas Gohr 296*dd9e8e5eSAndreas Gohr /** 297*dd9e8e5eSAndreas Gohr * Normalize a dimension value to a pixel count. 298*dd9e8e5eSAndreas Gohr * 299*dd9e8e5eSAndreas Gohr * Accepts an int or a percentage string ("50%"). The percentage is 300*dd9e8e5eSAndreas Gohr * resolved against the given original dimension. 301*dd9e8e5eSAndreas Gohr * 302*dd9e8e5eSAndreas Gohr * @param int|string $dim 303*dd9e8e5eSAndreas Gohr * @param int $orig 304*dd9e8e5eSAndreas Gohr * @return int 305*dd9e8e5eSAndreas Gohr */ 306*dd9e8e5eSAndreas Gohr public static function cleanDimension($dim, $orig) 307*dd9e8e5eSAndreas Gohr { 308*dd9e8e5eSAndreas Gohr if ($dim && substr($dim, -1) == '%') { 309*dd9e8e5eSAndreas Gohr $dim = round($orig * ((float)$dim / 100)); 310*dd9e8e5eSAndreas Gohr } else { 311*dd9e8e5eSAndreas Gohr $dim = (int)$dim; 312*dd9e8e5eSAndreas Gohr } 313*dd9e8e5eSAndreas Gohr return $dim; 314*dd9e8e5eSAndreas Gohr } 315*dd9e8e5eSAndreas Gohr} 316