1<?php /** @noinspection PhpComposerExtensionStubsInspection */
2
3
4namespace splitbrain\slika;
5
6/**
7 * Image processing adapter for PHP's libGD
8 */
9class GdAdapter extends Adapter
10{
11    /** @var resource libGD image */
12    protected $image;
13    /** @var int width of the current image */
14    protected $width = 0;
15    /** @var int height of the current image */
16    protected $height = 0;
17    /** @var string the extension of the file we're working with */
18    protected $extension;
19
20
21    /** @inheritDoc */
22    public function __construct($imagepath, $options = [])
23    {
24        parent::__construct($imagepath, $options);
25        $this->image = $this->loadImage($imagepath);
26    }
27
28    /**
29     * Clean up
30     */
31    public function __destruct()
32    {
33        if (is_resource($this->image)) {
34            imagedestroy($this->image);
35        }
36    }
37
38    /** @inheritDoc
39     * @throws Exception
40     * @link https://gist.github.com/EionRobb/8e0c76178522bc963c75caa6a77d3d37#file-imagecreatefromstring_autorotate-php-L15
41     */
42    public function autorotate()
43    {
44        if ($this->extension !== 'jpeg') {
45            return $this;
46        }
47
48        $orientation = 1;
49
50        if (function_exists('exif_read_data')) {
51            // use PHP's exif capablities
52            $exif = exif_read_data($this->imagepath);
53            if (!empty($exif['Orientation'])) {
54                $orientation = $exif['Orientation'];
55            }
56        } else {
57            // grep the exif info from the raw contents
58            // we read only the first 70k bytes
59            $data = file_get_contents($this->imagepath, false, null, 0, 70000);
60            if (preg_match('@\x12\x01\x03\x00\x01\x00\x00\x00(.)\x00\x00\x00@', $data, $matches)) {
61                // Little endian EXIF
62                $orientation = ord($matches[1]);
63            } else if (preg_match('@\x01\x12\x00\x03\x00\x00\x00\x01\x00(.)\x00\x00@', $data, $matches)) {
64                // Big endian EXIF
65                $orientation = ord($matches[1]);
66            }
67        }
68
69        return $this->rotate($orientation);
70    }
71
72    /**
73     * @inheritDoc
74     * @throws Exception
75     */
76    public function rotate($orientation)
77    {
78        $orientation = (int)$orientation;
79        if ($orientation < 0 || $orientation > 8) {
80            throw new Exception('Unknown rotation given');
81        }
82
83        if ($orientation <= 1) {
84            // no rotation wanted
85            return $this;
86        }
87
88        // fill color
89        $transparency = imagecolorallocatealpha($this->image, 0, 0, 0, 127);
90
91        // rotate
92        if (in_array($orientation, [3, 4])) {
93            $image = imagerotate($this->image, 180, $transparency);
94        }
95        if (in_array($orientation, [5, 6])) {
96            $image = imagerotate($this->image, -90, $transparency);
97            list($this->width, $this->height) = [$this->height, $this->width];
98        } elseif (in_array($orientation, [7, 8])) {
99            $image = imagerotate($this->image, 90, $transparency);
100            list($this->width, $this->height) = [$this->height, $this->width];
101        }
102        /** @var resource $image is now defined */
103
104        // additionally flip
105        if (in_array($orientation, [2, 5, 7, 4])) {
106            imageflip($image, IMG_FLIP_HORIZONTAL);
107        }
108
109        imagedestroy($this->image);
110        $this->image = $image;
111
112        //keep png alpha channel if possible
113        if ($this->extension == 'png' && function_exists('imagesavealpha')) {
114            imagealphablending($this->image, false);
115            imagesavealpha($this->image, true);
116        }
117
118        return $this;
119    }
120
121    /**
122     * @inheritDoc
123     * @throws Exception
124     */
125    public function resize($width, $height)
126    {
127        list($width, $height) = $this->boundingBox($width, $height);
128        $this->resizeOperation($width, $height);
129        return $this;
130    }
131
132    /**
133     * @inheritDoc
134     * @throws Exception
135     */
136    public function crop($width, $height)
137    {
138        list($this->width, $this->height, $offsetX, $offsetY) = $this->cropPosition($width, $height);
139        $this->resizeOperation($width, $height, $offsetX, $offsetY);
140        return $this;
141    }
142
143    /**
144     * @inheritDoc
145     * @throws Exception
146     */
147    public function save($path, $extension = '')
148    {
149        if ($extension === 'jpg') {
150            $extension = 'jpeg';
151        }
152        if ($extension === '') {
153            $extension = $this->extension;
154        }
155        $saver = 'image' . $extension;
156        if (!function_exists($saver)) {
157            throw new Exception('Can not save image format ' . $extension);
158        }
159
160        if ($extension == 'jpeg') {
161            imagejpeg($this->image, $path, $this->options['quality']);
162        } else {
163            $saver($this->image, $path);
164        }
165
166        imagedestroy($this->image);
167    }
168
169    /**
170     * Initialize libGD on the given image
171     *
172     * @param string $path
173     * @return resource
174     * @throws Exception
175     */
176    protected function loadImage($path)
177    {
178        // Figure out the file info
179        $info = getimagesize($path);
180        if ($info === false) {
181            throw new Exception('Failed to read image information');
182        }
183        $this->width = $info[0];
184        $this->height = $info[1];
185
186        // what type of image is it?
187        $this->extension = image_type_to_extension($info[2], false);
188        $creator = 'imagecreatefrom' . $this->extension;
189        if (!function_exists($creator)) {
190            throw new Exception('Can not work with image format ' . $this->extension);
191        }
192
193        // create the GD instance
194        $image = @$creator($path);
195
196        if ($image === false) {
197            throw new Exception('Failed to load image wiht libGD');
198        }
199
200        return $image;
201    }
202
203    /**
204     * Creates a new blank image to which we can copy
205     *
206     * Tries to set up alpha/transparency stuff correctly
207     *
208     * @param int $width
209     * @param int $height
210     * @return resource
211     * @throws Exception
212     */
213    protected function createImage($width, $height)
214    {
215        // create a canvas to copy to, use truecolor if possible (except for gif)
216        $canvas = false;
217        if (function_exists('imagecreatetruecolor') && $this->extension != 'gif') {
218            $canvas = @imagecreatetruecolor($width, $height);
219        }
220        if (!$canvas) {
221            $canvas = @imagecreate($width, $height);
222        }
223        if (!$canvas) {
224            throw new Exception('Failed to create new canvas');
225        }
226
227        //keep png alpha channel if possible
228        if ($this->extension == 'png' && function_exists('imagesavealpha')) {
229            imagealphablending($canvas, false);
230            imagesavealpha($canvas, true);
231        }
232
233        //keep gif transparent color if possible
234        if ($this->extension == 'gif') {
235            $this->keepGifTransparency($this->image, $canvas);
236        }
237
238        return $canvas;
239    }
240
241    /**
242     * Copy transparency from gif to gif
243     *
244     * If no transparency is found or the PHP does not support it, the canvas is filled with white
245     *
246     * @param resource $image Original image
247     * @param resource $canvas New, empty image
248     * @return void
249     */
250    protected function keepGifTransparency($image, $canvas)
251    {
252        if (!function_exists('imagefill') || !function_exists('imagecolorallocate')) {
253            return;
254        }
255
256        try {
257            if (!function_exists('imagecolorsforindex') || !function_exists('imagecolortransparent')) {
258                throw new \Exception('missing alpha methods');
259            }
260
261            $transcolorindex = @imagecolortransparent($image);
262            $transcolor = @imagecolorsforindex($image, $transcolorindex);
263            if (!$transcolor) {
264                // pre-PHP8 false is returned, in PHP8 an exception is thrown
265                throw new \ValueError('no valid alpha color');
266            }
267
268            $transcolorindex = @imagecolorallocate(
269                $canvas,
270                $transcolor['red'],
271                $transcolor['green'],
272                $transcolor['blue']
273            );
274            @imagefill($canvas, 0, 0, $transcolorindex);
275            @imagecolortransparent($canvas, $transcolorindex);
276
277        } catch (\Throwable $ignored) {
278            //filling with white
279            $whitecolorindex = @imagecolorallocate($canvas, 255, 255, 255);
280            @imagefill($canvas, 0, 0, $whitecolorindex);
281        }
282    }
283
284    /**
285     * Calculate new size
286     *
287     * If widht and height are given, the new size will be fit within this bounding box.
288     * If only one value is given the other is adjusted to match according to the aspect ratio
289     *
290     * @param int $width width of the bounding box
291     * @param int $height height of the bounding box
292     * @return array (width, height)
293     * @throws Exception
294     */
295    protected function boundingBox($width, $height)
296    {
297        $width = $this->cleanDimension($width, $this->width);
298        $height = $this->cleanDimension($height, $this->height);
299
300        if ($width == 0 && $height == 0) {
301            throw new Exception('You can not resize to 0x0');
302        }
303
304        if (!$height) {
305            // adjust to match width
306            $height = round(($width * $this->height) / $this->width);
307        } else if (!$width) {
308            // adjust to match height
309            $width = round(($height * $this->width) / $this->height);
310        } else {
311            // fit into bounding box
312            $scale = min($width / $this->width, $height / $this->height);
313            $width = $this->width * $scale;
314            $height = $this->height * $scale;
315        }
316
317        return [$width, $height];
318    }
319
320    /**
321     * Ensure the given Dimension is a proper pixel value
322     *
323     * When a percentage is given, the value is calculated based on the given original dimension
324     *
325     * @param int|string $dim New Dimension
326     * @param int $orig Original dimension
327     * @return int
328     */
329    protected function cleanDimension($dim, $orig)
330    {
331        if ($dim && substr($dim, -1) == '%') {
332            $dim = round($orig * ((float)$dim / 100));
333        } else {
334            $dim = (int)$dim;
335        }
336
337        return $dim;
338    }
339
340    /**
341     * Calculates crop position
342     *
343     * Given the wanted final size, this calculates which exact area needs to be cut
344     * from the original image to be then resized to the wanted dimensions.
345     *
346     * @param int $width
347     * @param int $height
348     * @return array (cropWidth, cropHeight, offsetX, offsetY)
349     * @throws Exception
350     */
351    protected function cropPosition($width, $height)
352    {
353        if ($width == 0 && $height == 0) {
354            throw new Exception('You can not crop to 0x0');
355        }
356
357        if (!$height) {
358            $height = $width;
359        }
360
361        if (!$width) {
362            $width = $height;
363        }
364
365        // calculate ratios
366        $oldRatio = $this->width / $this->height;
367        $newRatio = $width / $height;
368
369        // calulate new size
370        if ($newRatio >= 1) {
371            if ($newRatio > $oldRatio) {
372                $cropWidth = $this->width;
373                $cropHeight = (int)($this->width / $newRatio);
374            } else {
375                $cropWidth = (int)($this->height * $newRatio);
376                $cropHeight = $this->height;
377            }
378        } else {
379            if ($newRatio < $oldRatio) {
380                $cropWidth = (int)($this->height * $newRatio);
381                $cropHeight = $this->height;
382            } else {
383                $cropWidth = $this->width;
384                $cropHeight = (int)($this->width / $newRatio);
385            }
386        }
387
388        // calculate crop offset
389        $offsetX = (int)(($this->width - $cropWidth) / 2);
390        $offsetY = (int)(($this->height - $cropHeight) / 2);
391
392        return [$cropWidth, $cropHeight, $offsetX, $offsetY];
393    }
394
395    /**
396     * resize or crop images using PHP's libGD support
397     *
398     * @param int $toWidth desired width
399     * @param int $toHeight desired height
400     * @param int $offsetX offset of crop centre
401     * @param int $offsetY offset of crop centre
402     * @throws Exception
403     */
404    protected function resizeOperation($toWidth, $toHeight, $offsetX = 0, $offsetY = 0)
405    {
406        $newimg = $this->createImage($toWidth, $toHeight);
407
408        //try resampling first, fall back to resizing
409        if (
410            !function_exists('imagecopyresampled') ||
411            !@imagecopyresampled(
412                $newimg,
413                $this->image,
414                0,
415                0,
416                $offsetX,
417                $offsetY,
418                $toWidth,
419                $toHeight,
420                $this->width,
421                $this->height
422            )
423        ) {
424            imagecopyresized(
425                $newimg,
426                $this->image,
427                0,
428                0,
429                $offsetX,
430                $offsetY,
431                $toWidth,
432                $toHeight,
433                $this->width,
434                $this->height
435            );
436        }
437
438        // destroy original GD image ressource and replace with new one
439        imagedestroy($this->image);
440        $this->image = $newimg;
441        $this->width = $toWidth;
442        $this->height = $toHeight;
443    }
444
445}
446