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