-
-
Notifications
You must be signed in to change notification settings - Fork 383
/
SimpleImage.php
2409 lines (2130 loc) · 85.3 KB
/
SimpleImage.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
//
// SimpleImage
//
// A PHP class that makes working with images as simple as possible.
//
// Developed and maintained by Cory LaViska <https://github.com/claviska>.
//
// Copyright A Beautiful Site, LLC.
//
// Source: https://github.com/claviska/SimpleImage
//
// Licensed under the MIT license <http://opensource.org/licenses/MIT>
//
namespace claviska;
use Exception;
use GdImage;
use League\ColorExtractor\Color;
use League\ColorExtractor\ColorExtractor;
use League\ColorExtractor\Palette;
/**
* A PHP class that makes working with images as simple as possible.
*/
class SimpleImage
{
public const
ERR_FILE_NOT_FOUND = 1;
public const
ERR_FONT_FILE = 2;
public const
ERR_FREETYPE_NOT_ENABLED = 3;
public const
ERR_GD_NOT_ENABLED = 4;
public const
ERR_INVALID_COLOR = 5;
public const
ERR_INVALID_DATA_URI = 6;
public const
ERR_INVALID_IMAGE = 7;
public const
ERR_LIB_NOT_LOADED = 8;
public const
ERR_UNSUPPORTED_FORMAT = 9;
public const
ERR_WEBP_NOT_ENABLED = 10;
public const
ERR_WRITE = 11;
public const
ERR_INVALID_FLAG = 12;
protected array $flags;
protected $image = null;
protected string $mimeType;
protected null|array|false $exif = null;
//////////////////////////////////////////////////////////////////////////////////////////////////
// Magic methods
//////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Creates a new SimpleImage object.
*
* @param string $image An image file or a data URI to load.
* @param array $flags Optional override of default flags.
*
* @throws Exception Thrown if the GD library is not found; file|URI or image data is invalid.
*/
public function __construct(string $image = '', array $flags = [])
{
// Check for the required GD extension
if (extension_loaded('gd')) {
// Ignore JPEG warnings that cause imagecreatefromjpeg() to fail
ini_set('gd.jpeg_ignore_warning', '1');
} else {
throw new Exception('Required extension GD is not loaded.', self::ERR_GD_NOT_ENABLED);
}
// Associative array of flags.
$this->flags = [
'sslVerify' => true, // Skip SSL peer validation
];
// Override default flag values.
foreach ($flags as $flag => $value) {
$this->setFlag($flag, $value);
}
// Load an image through the constructor
if (preg_match('/^data:(.*?);/', $image)) {
$this->fromDataUri($image);
} elseif ($image) {
$this->fromFile($image);
}
}
/**
* Destroys the image resource.
*/
public function __destruct()
{
$this->reset();
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// Helper functions
//////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Checks if the SimpleImage object has loaded an image.
*/
public function hasImage(): bool
{
return $this->image instanceof GdImage;
}
/**
* Destroys the image resource.
*/
public function reset(): static
{
if ($this->hasImage()) {
imagedestroy($this->image);
}
return $this;
}
/**
* Set flag value.
*
* @param string $flag Name of the flag to set.
* @param bool $value State of the flag.
*
* @throws Exception Thrown if flag does not exist (no default value).
*/
public function setFlag(string $flag, bool $value): void
{
// Throw if flag does not exist
if (! in_array($flag, array_keys($this->flags))) {
throw new Exception('Invalid flag.', self::ERR_INVALID_FLAG);
}
// Set flag value by name
$this->flags[$flag] = $value;
}
/**
* Get flag value.
*
* @param string $flag Name of the flag to get.
*/
public function getFlag(string $flag): ?bool
{
return in_array($flag, array_keys($this->flags)) ? $this->flags[$flag] : null;
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// Loaders
//////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Loads an image from a data URI.
*
* @param string $uri A data URI.
* @return SimpleImage
*
* @throws Exception Thrown if URI or image data is invalid.
*/
public function fromDataUri(string $uri): static
{
// Basic formatting check
preg_match('/^data:(.*?);/', $uri, $matches);
if (! count($matches)) {
throw new Exception('Invalid data URI.', self::ERR_INVALID_DATA_URI);
}
// Determine mime type
$this->mimeType = $matches[1];
if (! preg_match('/^image\/(gif|jpeg|png)$/', $this->mimeType)) {
throw new Exception(
'Unsupported format: '.$this->mimeType,
self::ERR_UNSUPPORTED_FORMAT
);
}
// Get image data
$uri = base64_decode(strval(preg_replace('/^data:(.*?);base64,/', '', $uri)));
$this->image = imagecreatefromstring($uri);
if (! $this->image) {
throw new Exception('Invalid image data.', self::ERR_INVALID_IMAGE);
}
return $this;
}
/**
* Loads an image from a file.
*
* @param string $file The image file to load.
* @return SimpleImage
*
* @throws Exception Thrown if file or image data is invalid.
*/
public function fromFile(string $file): static
{
// Set fopen options.
$sslVerify = $this->getFlag('sslVerify'); // Don't perform peer validation when true
$opts = [
'ssl' => [
'verify_peer' => $sslVerify,
'verify_peer_name' => $sslVerify,
],
];
// Check if the file exists and is readable.
$file = @file_get_contents($file, false, stream_context_create($opts));
if ($file === false) {
throw new Exception("File not found: $file", self::ERR_FILE_NOT_FOUND);
}
// Create image object from string
$this->image = imagecreatefromstring($file);
// Get image info
$info = @getimagesizefromstring($file);
if ($info === false) {
throw new Exception("Invalid image file: $file", self::ERR_INVALID_IMAGE);
}
$this->mimeType = $info['mime'];
if (! $this->image) {
throw new Exception('Unsupported format: '.$this->mimeType, self::ERR_UNSUPPORTED_FORMAT);
}
switch($this->mimeType) {
case 'image/gif':
// Copy the gif over to a true color image to preserve its transparency. This is a
// workaround to prevent imagepalettetotruecolor() from borking transparency.
$width = imagesx($this->image);
$height = imagesx($this->image);
$gif = imagecreatetruecolor((int) $width, (int) $height);
$alpha = imagecolorallocatealpha($gif, 0, 0, 0, 127);
imagecolortransparent($gif, $alpha ?: null);
imagefill($gif, 0, 0, $alpha);
imagecopy($this->image, $gif, 0, 0, 0, 0, $width, $height);
imagedestroy($gif);
break;
case 'image/jpeg':
// Load exif data from JPEG images
if (function_exists('exif_read_data')) {
$this->exif = @exif_read_data('data://image/jpeg;base64,'.base64_encode($file));
}
break;
}
// Convert pallete images to true color images
imagepalettetotruecolor($this->image);
return $this;
}
/**
* Creates a new image.
*
* @param int $width The width of the image.
* @param int $height The height of the image.
* @param string|array $color Optional fill color for the new image (default 'transparent').
* @return SimpleImage
*
* @throws Exception
*/
public function fromNew(int $width, int $height, string|array $color = 'transparent'): static
{
$this->image = imagecreatetruecolor($width, $height);
// Use PNG for dynamically created images because it's lossless and supports transparency
$this->mimeType = 'image/png';
// Fill the image with color
$this->fill($color);
return $this;
}
/**
* Creates a new image from a string.
*
* @param string $string The raw image data as a string.
* @return SimpleImage
*
* @throws Exception
*
* @example
* $string = file_get_contents('image.jpg');
*/
public function fromString(string $string): SimpleImage|static
{
return $this->fromFile('data://;base64,'.base64_encode($string));
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// Savers
//////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Generates an image.
*
* @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
* @param array|int $options Array or Image quality as a percentage (default 100).
* @return array Returns an array containing the image data and mime type ['data' => '', 'mimeType' => ''].
*
* @throws Exception Thrown when WEBP support is not enabled or unsupported format.
*/
public function generate(string $mimeType = null, array|int $options = 100): array
{
// Format defaults to the original mime type
$mimeType = $mimeType ?: $this->mimeType;
$quality = null;
// allow $options to be an int for backwards compatibility to v3
if (is_int($options)) {
$quality = $options;
$options = [];
}
// get quality if passed as an option
if (is_array($options) && array_key_exists('quality', $options)) {
$quality = intval($options['quality']);
}
// Ensure quality is a valid integer
if ($quality === null) {
$quality = 100;
}
$quality = (int) round(self::keepWithin((int) $quality, 0, 100));
$alpha = true;
// get alpha if passed as an option
if (is_array($options) && array_key_exists('alpha', $options)) {
$alpha = boolval($options['alpha']);
}
$interlace = null; // keep the same
// get interlace if passed as an option
if (is_array($options) && array_key_exists('interlace', $options)) {
$interlace = boolval($options['interlace']);
}
// get raw stream from image* functions in providing no path
$file = null;
// Capture output
ob_start();
// Generate the image
switch($mimeType) {
case 'image/gif':
imagesavealpha($this->image, $alpha);
imagegif($this->image, $file);
break;
case 'image/jpeg':
imageinterlace($this->image, $interlace);
imagejpeg($this->image, $file, $quality);
break;
case 'image/png':
$filters = -1; // imagepng default
// get filters if passed as an option
if (is_array($options) && array_key_exists('filters', $options)) {
$filters = intval($options['filters']);
}
// compression param is called quality in imagepng but that would be
// misleading in context of SimpleImage
$compression = -1; // defaults to zlib default which is 6
// get compression if passed as an option
if (is_array($options) && array_key_exists('compression', $options)) {
$compression = intval($options['compression']);
}
if ($compression !== -1) {
$compression = (int) round(self::keepWithin($compression, 0, 10));
}
imagesavealpha($this->image, $alpha);
imagepng($this->image, $file, $compression, $filters);
break;
case 'image/webp':
// Not all versions of PHP will have webp support enabled
if (! function_exists('imagewebp')) {
throw new Exception(
'WEBP support is not enabled in your version of PHP.',
self::ERR_WEBP_NOT_ENABLED
);
}
// useless but recommended, see https://www.php.net/manual/en/function.imagesavealpha.php
imagesavealpha($this->image, $alpha);
imagewebp($this->image, $file, $quality);
break;
case 'image/bmp':
case 'image/x-ms-bmp':
case 'image/x-windows-bmp':
// Not all versions of PHP support bmp
if (! function_exists('imagebmp')) {
throw new Exception(
'BMP support is not available in your version of PHP.',
self::ERR_UNSUPPORTED_FORMAT
);
}
$compression = true; // imagebmp default
// get compression if passed as an option
if (is_array($options) && array_key_exists('compression', $options)) {
$compression = is_int($options['compression']) ?
$options['compression'] > 0 : boolval($options['compression']);
}
imageinterlace($this->image, $interlace);
imagebmp($this->image, $file, $compression);
break;
case 'image/avif':
// Not all versions of PHP support avif
if (! function_exists('imageavif')) {
throw new Exception(
'AVIF support is not available in your version of PHP.',
self::ERR_UNSUPPORTED_FORMAT
);
}
$speed = -1; // imageavif default
// get speed if passed as an option
if (is_array($options) && array_key_exists('speed', $options)) {
$speed = intval($options['speed']);
$speed = self::keepWithin($speed, 0, 10);
}
// useless but recommended, see https://www.php.net/manual/en/function.imagesavealpha.php
imagesavealpha($this->image, $alpha);
imageavif($this->image, $file, $quality, $speed);
break;
default:
throw new Exception('Unsupported format: '.$mimeType, self::ERR_UNSUPPORTED_FORMAT);
}
// Stop capturing
$data = ob_get_contents();
ob_end_clean();
return [
'data' => $data,
'mimeType' => $mimeType,
];
}
/**
* Generates a data URI.
*
* @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
* @param array|int $options Array or Image quality as a percentage (default 100).
* @return string Returns a string containing a data URI.
*
* @throws Exception
*/
public function toDataUri(string $mimeType = null, array|int $options = 100): string
{
$image = $this->generate($mimeType, $options);
return 'data:'.$image['mimeType'].';base64,'.base64_encode($image['data']);
}
/**
* Forces the image to be downloaded to the clients machine. Must be called before any output is sent to the screen.
*
* @param string $filename The filename (without path) to send to the client (e.g. 'image.jpeg').
* @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
* @param array|int $options Array or Image quality as a percentage (default 100).
* @return SimpleImage
*
* @throws Exception
*/
public function toDownload(string $filename, string $mimeType = null, array|int $options = 100): static
{
$image = $this->generate($mimeType, $options);
// Set download headers
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Content-Description: File Transfer');
header('Content-Length: '.strlen($image['data']));
header('Content-Transfer-Encoding: Binary');
header('Content-Type: application/octet-stream');
header("Content-Disposition: attachment; filename=\"$filename\"");
echo $image['data'];
return $this;
}
/**
* Writes the image to a file.
*
* @param string $file The image format to output as a mime type (defaults to the original mime type).
* @param string|null $mimeType Image quality as a percentage (default 100).
* @param array|int $options Array or Image quality as a percentage (default 100).
* @return SimpleImage
*
* @throws Exception Thrown if failed write to file.
*/
public function toFile(string $file, string $mimeType = null, array|int $options = 100): static
{
$image = $this->generate($mimeType, $options);
// Save the image to file
if (! file_put_contents($file, $image['data'])) {
throw new Exception("Failed to write image to file: $file", self::ERR_WRITE);
}
return $this;
}
/**
* Outputs the image to the screen. Must be called before any output is sent to the screen.
*
* @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
* @param array|int $options Array or Image quality as a percentage (default 100).
* @return SimpleImage
*
* @throws Exception
*/
public function toScreen(string $mimeType = null, array|int $options = 100): static
{
$image = $this->generate($mimeType, $options);
// Output the image to stdout
header('Content-Type: '.$image['mimeType']);
echo $image['data'];
return $this;
}
/**
* Generates an image string.
*
* @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
* @param array|int $options Array or Image quality as a percentage (default 100).
*
* @throws Exception
*/
public function toString(string $mimeType = null, array|int $options = 100): string
{
return $this->generate($mimeType, $options)['data'];
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// Utilities
//////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Ensures a numeric value is always within the min and max range.
*
* @param int|float $value A numeric value to test.
* @param int|float $min The minimum allowed value.
* @param int|float $max The maximum allowed value.
*/
protected static function keepWithin(int|float $value, int|float $min, int|float $max): int|float
{
if ($value < $min) {
return $min;
}
if ($value > $max) {
return $max;
}
return $value;
}
/**
* Gets the image's current aspect ratio.
*
* @return float|int Returns the aspect ratio as a float.
*/
public function getAspectRatio(): float|int
{
return $this->getWidth() / $this->getHeight();
}
/**
* Gets the image's exif data.
*
* @return array|null Returns an array of exif data or null if no data is available.
*/
public function getExif(): ?array
{
// returns null if exif value is falsy: null, false or empty array.
return $this->exif ?: null;
}
/**
* Gets the image's current height.
*/
public function getHeight(): int
{
return (int) imagesy($this->image);
}
/**
* Gets the mime type of the loaded image.
*/
public function getMimeType(): string
{
return $this->mimeType;
}
/**
* Gets the image's current orientation.
*
* @return string One of the values: 'landscape', 'portrait', or 'square'
*/
public function getOrientation(): string
{
$width = $this->getWidth();
$height = $this->getHeight();
if ($width > $height) {
return 'landscape';
}
if ($width < $height) {
return 'portrait';
}
return 'square';
}
/**
* Gets the resolution of the image
*
* @return array|bool The resolution as an array of integers: [96, 96]
*/
public function getResolution(): bool|array
{
return imageresolution($this->image);
}
/**
* Gets the image's current width.
*/
public function getWidth(): int
{
return (int) imagesx($this->image);
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// Manipulation
//////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Same as PHP's imagecopymerge, but works with transparent images. Used internally for overlay.
*
* @param GdImage $dstIm Destination image.
* @param GdImage $srcIm Source image.
* @param int $dstX x-coordinate of destination point.
* @param int $dstY y-coordinate of destination point.
* @param int $srcX x-coordinate of source point.
* @param int $srcY y-coordinate of source point.
* @param int $srcW Source width.
* @param int $srcH Source height.
* @return bool true if success.
*/
protected static function imageCopyMergeAlpha(GdImage $dstIm, GdImage $srcIm, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct): bool
{
// Are we merging with transparency?
if ($pct < 100) {
// Disable alpha blending and "colorize" the image using a transparent color
imagealphablending($srcIm, false);
imagefilter($srcIm, IMG_FILTER_COLORIZE, 0, 0, 0, round(127 * ((100 - $pct) / 100)));
}
imagecopy($dstIm, $srcIm, $dstX, $dstY, $srcX, $srcY, $srcW, $srcH);
return true;
}
/**
* Rotates an image so the orientation will be correct based on its exif data. It is safe to call
* this method on images that don't have exif data (no changes will be made).
*
* @return SimpleImage
*
* @throws Exception
*/
public function autoOrient(): static
{
$exif = $this->getExif();
if (! $exif || ! isset($exif['Orientation'])) {
return $this;
}
switch($exif['Orientation']) {
case 1: // Do nothing!
break;
case 2: // Flip horizontally
$this->flip('x');
break;
case 3: // Rotate 180 degrees
$this->rotate(180);
break;
case 4: // Flip vertically
$this->flip('y');
break;
case 5: // Rotate 90 degrees clockwise and flip vertically
$this->flip('y')->rotate(90);
break;
case 6: // Rotate 90 clockwise
$this->rotate(90);
break;
case 7: // Rotate 90 clockwise and flip horizontally
$this->flip('x')->rotate(90);
break;
case 8: // Rotate 90 counterclockwise
$this->rotate(-90);
break;
}
return $this;
}
/**
* Proportionally resize the image to fit inside a specific width and height.
*
* @param int $maxWidth The maximum width the image can be.
* @param int $maxHeight The maximum height the image can be.
* @return SimpleImage
*/
public function bestFit(int $maxWidth, int $maxHeight): static
{
// If the image already fits, there's nothing to do
if ($this->getWidth() <= $maxWidth && $this->getHeight() <= $maxHeight) {
return $this;
}
// Calculate max width or height based on orientation
if ($this->getOrientation() === 'portrait') {
$height = $maxHeight;
$width = (int) round($maxHeight * $this->getAspectRatio());
} else {
$width = $maxWidth;
$height = (int) round($maxWidth / $this->getAspectRatio());
}
// Reduce to max width
if ($width > $maxWidth) {
$width = $maxWidth;
$height = (int) round($width / $this->getAspectRatio());
}
// Reduce to max height
if ($height > $maxHeight) {
$height = $maxHeight;
$width = (int) round($height * $this->getAspectRatio());
}
return $this->resize($width, $height);
}
/**
* Crop the image.
*
* @param int|float $x1 Top left x coordinate.
* @param int|float $y1 Top left y coordinate.
* @param int|float $x2 Bottom right x coordinate.
* @param int|float $y2 Bottom right x coordinate.
* @return SimpleImage
*/
public function crop(int|float $x1, int|float $y1, int|float $x2, int|float $y2): static
{
// Keep crop within image dimensions
$x1 = self::keepWithin($x1, 0, $this->getWidth());
$x2 = self::keepWithin($x2, 0, $this->getWidth());
$y1 = self::keepWithin($y1, 0, $this->getHeight());
$y2 = self::keepWithin($y2, 0, $this->getHeight());
// Avoid using native imagecrop() because of a bug with PNG transparency
$dstW = abs($x2 - $x1);
$dstH = abs($y2 - $y1);
$newImage = imagecreatetruecolor((int) $dstW, (int) $dstH);
$transparentColor = imagecolorallocatealpha($newImage, 0, 0, 0, 127);
imagecolortransparent($newImage, $transparentColor ?: null);
imagefill($newImage, 0, 0, $transparentColor);
// Crop it
imagecopyresampled(
$newImage,
$this->image,
0,
0,
(int) round(min($x1, $x2)),
(int) round(min($y1, $y2)),
(int) $dstW,
(int) $dstH,
(int) $dstW,
(int) $dstH
);
// Swap out the new image
$this->image = $newImage;
return $this;
}
/**
* Applies a duotone filter to the image.
*
* @param string|array $lightColor The lightest color in the duotone.
* @param string|array $darkColor The darkest color in the duotone.
* @return SimpleImage
*
* @throws Exception
*/
public function duotone(string|array $lightColor, string|array $darkColor): static
{
$lightColor = self::normalizeColor($lightColor);
$darkColor = self::normalizeColor($darkColor);
// Calculate averages between light and dark colors
$redAvg = $lightColor['red'] - $darkColor['red'];
$greenAvg = $lightColor['green'] - $darkColor['green'];
$blueAvg = $lightColor['blue'] - $darkColor['blue'];
// Create a matrix of all possible duotone colors based on gray values
$pixels = [];
for ($i = 0; $i <= 255; $i++) {
$grayAvg = $i / 255;
$pixels['red'][$i] = $darkColor['red'] + $grayAvg * $redAvg;
$pixels['green'][$i] = $darkColor['green'] + $grayAvg * $greenAvg;
$pixels['blue'][$i] = $darkColor['blue'] + $grayAvg * $blueAvg;
}
// Apply the filter pixel by pixel
for ($x = 0; $x < $this->getWidth(); $x++) {
for ($y = 0; $y < $this->getHeight(); $y++) {
$rgb = $this->getColorAt($x, $y);
$gray = min(255, round(0.299 * $rgb['red'] + 0.114 * $rgb['blue'] + 0.587 * $rgb['green']));
$this->dot($x, $y, [
'red' => $pixels['red'][$gray],
'green' => $pixels['green'][$gray],
'blue' => $pixels['blue'][$gray],
]);
}
}
return $this;
}
/**
* Proportionally resize the image to a specific width.
*
* @param int $width The width to resize the image to.
* @return SimpleImage
*
*@deprecated
* This method was deprecated in version 3.2.2 and will be removed in version 4.0.
* Please use `resize(null, $height)` instead.
*/
public function fitToWidth(int $width): static
{
return $this->resize($width);
}
/**
* Flip the image horizontally or vertically.
*
* @param string $direction The direction to flip: x|y|both.
* @return SimpleImage
*/
public function flip(string $direction): static
{
match ($direction) {
'x' => imageflip($this->image, IMG_FLIP_HORIZONTAL),
'y' => imageflip($this->image, IMG_FLIP_VERTICAL),
'both' => imageflip($this->image, IMG_FLIP_BOTH),
default => $this,
};
return $this;
}
/**
* Reduces the image to a maximum number of colors.
*
* @param int $max The maximum number of colors to use.
* @param bool $dither Whether or not to use a dithering effect (default true).
* @return SimpleImage
*/
public function maxColors(int $max, bool $dither = true): static
{
imagetruecolortopalette($this->image, $dither, max(1, $max));
return $this;
}
/**
* Place an image on top of the current image.
*
* @param string|SimpleImage $overlay The image to overlay. This can be a filename, a data URI, or a SimpleImage object.
* @param string $anchor The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center').
* @param float|int $opacity The opacity level of the overlay 0-1 (default 1).
* @param int $xOffset Horizontal offset in pixels (default 0).
* @param int $yOffset Vertical offset in pixels (default 0).
* @param bool $calculateOffsetFromEdge Calculate Offset referring to the edges of the image (default false).
* @return SimpleImage
*
* @throws Exception
*/
public function overlay(string|SimpleImage $overlay, string $anchor = 'center', float|int $opacity = 1, int $xOffset = 0, int $yOffset = 0, bool $calculateOffsetFromEdge = false): static
{
// Load overlay image
if (! ($overlay instanceof SimpleImage)) {
$overlay = new SimpleImage($overlay);
}
// Convert opacity
$opacity = (int) round(self::keepWithin($opacity, 0, 1) * 100);
// Get available space
$spaceX = $this->getWidth() - $overlay->getWidth();
$spaceY = $this->getHeight() - $overlay->getHeight();
// Set default center
$x = (int) round(($spaceX / 2) + ($calculateOffsetFromEdge ? 0 : $xOffset));
$y = (int) round(($spaceY / 2) + ($calculateOffsetFromEdge ? 0 : $yOffset));
// Determine if top|bottom
if (str_contains($anchor, 'top')) {
$y = $yOffset;
} elseif (str_contains($anchor, 'bottom')) {
$y = $spaceY + ($calculateOffsetFromEdge ? -$yOffset : $yOffset);
}
// Determine if left|right
if (str_contains($anchor, 'left')) {
$x = $xOffset;
} elseif (str_contains($anchor, 'right')) {
$x = $spaceX + ($calculateOffsetFromEdge ? -$xOffset : $xOffset);
}
// Perform the overlay
self::imageCopyMergeAlpha(
$this->image,
$overlay->image,
$x, $y,
0, 0,
$overlay->getWidth(),
$overlay->getHeight(),
$opacity
);
return $this;
}
/**
* Resize an image to the specified dimensions. If only one dimension is specified, the image will be resized proportionally.
*
* @param int|null $width The new image width.
* @param int|null $height The new image height.
* @return SimpleImage
*/
public function resize(int $width = null, int $height = null): static
{
// No dimensions specified
if (! $width && ! $height) {
return $this;
}
// Resize to width
if ($width && ! $height) {
$height = (int) round($width / $this->getAspectRatio());
}
// Resize to height
if (! $width && $height) {
$width = (int) round($height * $this->getAspectRatio());
}
// If the dimensions are the same, there's no need to resize
if ($this->getWidth() === $width && $this->getHeight() === $height) {
return $this;
}
// We can't use imagescale because it doesn't seem to preserve transparency properly. The