1 <?php /** @noinspection PhpComposerExtensionStubsInspection */
2 
3 
4 namespace splitbrain\slika;
5 
6 /**
7  * Image processing adapter for PHP's libGD
8  */
9 class 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