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 * @return $this 166 * @throws Exception when both dimensions are zero 167 */ 168 public function resize($width, $height) 169 { 170 list($w, $h) = self::boundingBox($this->width, $this->height, $width, $height); 171 $this->width = (int)$w; 172 $this->height = (int)$h; 173 return $this; 174 } 175 176 /** 177 * Simulate Adapter::crop() at the dimension level. 178 * 179 * Result equals the output size of Adapter::crop(): exactly ($w, $h) 180 * when both are given, or a ($w, $w) / ($h, $h) square when only one is. 181 * 182 * @param int|string $width in pixels or % 183 * @param int|string $height in pixels or % 184 * @return $this 185 * @throws Exception when both dimensions are zero 186 */ 187 public function crop($width, $height) 188 { 189 $width = self::cleanDimension($width, $this->width); 190 $height = self::cleanDimension($height, $this->height); 191 192 if ($width == 0 && $height == 0) { 193 throw new Exception('You can not crop to 0x0'); 194 } 195 196 if (!$height) { 197 $height = $width; 198 } 199 if (!$width) { 200 $width = $height; 201 } 202 203 $this->width = (int)$width; 204 $this->height = (int)$height; 205 return $this; 206 } 207 208 /** 209 * Read the EXIF orientation tag of a JPEG file. 210 * 211 * Prefers exif_read_data() when available; otherwise falls back to a 212 * raw-byte scan of the first 70 KB of the file. Returns 1 when no 213 * orientation tag is found. 214 * 215 * @param string $path 216 * @return int 1..8 217 */ 218 public static function readExifOrientation($path) 219 { 220 if (function_exists('exif_read_data')) { 221 $exif = exif_read_data($path); 222 if (!empty($exif['Orientation'])) { 223 return (int)$exif['Orientation']; 224 } 225 return 1; 226 } 227 return self::readExifOrientationFromBytes($path); 228 } 229 230 /** 231 * Raw-byte fallback for reading the EXIF orientation tag. 232 * 233 * Exposed so the fallback path can be tested even on systems with the 234 * exif extension installed. 235 * 236 * @param string $path 237 * @return int 1..8 238 * @link https://gist.github.com/EionRobb/8e0c76178522bc963c75caa6a77d3d37#file-imagecreatefromstring_autorotate-php-L15 239 */ 240 public static function readExifOrientationFromBytes($path) 241 { 242 $data = @file_get_contents($path, false, null, 0, 70000); 243 if ($data === false) { 244 return 1; 245 } 246 if (preg_match('@\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00@', $data, $matches)) { 247 // little endian EXIF 248 return ord($matches[1]); 249 } 250 if (preg_match('@\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00@', $data, $matches)) { 251 // big endian EXIF 252 return ord($matches[1]); 253 } 254 return 1; 255 } 256 257 /** 258 * Calculate new size to fit into a bounding box, preserving aspect ratio. 259 * 260 * If width and height are given, the result is scaled to fit inside the 261 * bounding box. If only one dimension is given, the other is calculated 262 * from the aspect ratio. 263 * 264 * @param int $origW current width 265 * @param int $origH current height 266 * @param int|string $width target width (pixels or %) 267 * @param int|string $height target height (pixels or %) 268 * @return array [width, height] 269 * @throws Exception 270 */ 271 public static function boundingBox($origW, $origH, $width, $height) 272 { 273 $width = self::cleanDimension($width, $origW); 274 $height = self::cleanDimension($height, $origH); 275 276 if ($width == 0 && $height == 0) { 277 throw new Exception('You can not resize to 0x0'); 278 } 279 280 if (!$height) { 281 // adjust to match width 282 $height = round(($width * $origH) / $origW); 283 } else if (!$width) { 284 // adjust to match height 285 $width = round(($height * $origW) / $origH); 286 } else { 287 // fit into bounding box 288 $scale = min($width / $origW, $height / $origH); 289 $width = $origW * $scale; 290 $height = $origH * $scale; 291 } 292 293 return [$width, $height]; 294 } 295 296 /** 297 * Normalize a dimension value to a pixel count. 298 * 299 * Accepts an int or a percentage string ("50%"). The percentage is 300 * resolved against the given original dimension. 301 * 302 * @param int|string $dim 303 * @param int $orig 304 * @return int 305 */ 306 public static function cleanDimension($dim, $orig) 307 { 308 if ($dim && substr($dim, -1) == '%') { 309 $dim = round($orig * ((float)$dim / 100)); 310 } else { 311 $dim = (int)$dim; 312 } 313 return $dim; 314 } 315} 316