xref: /dokuwiki/vendor/splitbrain/slika/src/ImageInfo.php (revision 6a8e48eda246f872402bf5b85763f276cd4c319d)
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