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