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