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