YaWK  24.1
Yet another WebKit
SimpleImage.php
Go to the documentation of this file.
1 <?php
2 
3 //
4 // SimpleImage
5 //
6 // A PHP class that makes working with images as simple as possible.
7 //
8 // Developed and maintained by Cory LaViska <https://github.com/claviska>.
9 //
10 // Copyright A Beautiful Site, LLC.
11 //
12 // Source: https://github.com/claviska/SimpleImage
13 //
14 // Licensed under the MIT license <http://opensource.org/licenses/MIT>
15 //
16 
17 namespace YAWK;
18 
19 use Exception;
20 use League\ColorExtractor\Color;
21 use League\ColorExtractor\ColorExtractor;
22 use League\ColorExtractor\Palette;
23 
24 /**
25  * A PHP class that makes working with images as simple as possible.
26  */
28 {
29  public const
30  ERR_FILE_NOT_FOUND = 1;
31 
32  public const
33  ERR_FONT_FILE = 2;
34 
35  public const
36  ERR_FREETYPE_NOT_ENABLED = 3;
37 
38  public const
39  ERR_GD_NOT_ENABLED = 4;
40 
41  public const
42  ERR_INVALID_COLOR = 5;
43 
44  public const
45  ERR_INVALID_DATA_URI = 6;
46 
47  public const
48  ERR_INVALID_IMAGE = 7;
49 
50  public const
51  ERR_LIB_NOT_LOADED = 8;
52 
53  public const
54  ERR_UNSUPPORTED_FORMAT = 9;
55 
56  public const
57  ERR_WEBP_NOT_ENABLED = 10;
58 
59  public const
60  ERR_WRITE = 11;
61 
62  public const
63  ERR_INVALID_FLAG = 12;
64 
65  protected array $flags;
66 
67  protected $image;
68 
69  protected string $mimeType;
70 
71  protected null|array|false $exif;
72 
73  //////////////////////////////////////////////////////////////////////////////////////////////////
74  // Magic methods
75  //////////////////////////////////////////////////////////////////////////////////////////////////
76 
77  /**
78  * Creates a new SimpleImage object.
79  *
80  * @param string $image An image file or a data URI to load.
81  * @param array $flags Optional override of default flags.
82  *
83  * @throws Exception Thrown if the GD library is not found; file|URI or image data is invalid.
84  */
85  public function __construct(string $image = '', array $flags = [])
86  {
87  // Check for the required GD extension
88  if (extension_loaded('gd')) {
89  // Ignore JPEG warnings that cause imagecreatefromjpeg() to fail
90  ini_set('gd.jpeg_ignore_warning', '1');
91  } else {
92  throw new Exception('Required extension GD is not loaded.', self::ERR_GD_NOT_ENABLED);
93  }
94 
95  // Associative array of flags.
96  $this->flags = [
97  'sslVerify' => true, // Skip SSL peer validation
98  ];
99 
100  // Override default flag values.
101  foreach ($flags as $flag => $value) {
102  $this->setFlag($flag, $value);
103  }
104 
105  // Load an image through the constructor
106  if (preg_match('/^data:(.*?);/', $image)) {
107  $this->fromDataUri($image);
108  } elseif ($image) {
109  $this->fromFile($image);
110  }
111  }
112 
113  /**
114  * Destroys the image resource.
115  */
116  public function __destruct()
117  {
118  //Check for a valid GDimage instance
119  $type_check = (gettype($this->image) == 'object' && $this->image::class == 'GdImage');
120 
121  if (is_resource($this->image) && $type_check) {
122  imagedestroy($this->image);
123  }
124  }
125 
126  //////////////////////////////////////////////////////////////////////////////////////////////////
127  // Helper functions
128  //////////////////////////////////////////////////////////////////////////////////////////////////
129 
130  /**
131  * Set flag value.
132  *
133  * @param string $flag Name of the flag to set.
134  * @param bool $value State of the flag.
135  *
136  * @throws Exception Thrown if flag does not exist (no default value).
137  */
138  public function setFlag(string $flag, bool $value): void
139  {
140  // Throw if flag does not exist
141  if (! in_array($flag, array_keys($this->flags))) {
142  throw new Exception('Invalid flag.', self::ERR_INVALID_FLAG);
143  }
144 
145  // Set flag value by name
146  $this->flags[$flag] = $value;
147  }
148 
149  /**
150  * Get flag value.
151  *
152  * @param string $flag Name of the flag to get.
153  */
154  public function getFlag(string $flag): ?bool
155  {
156  return in_array($flag, array_keys($this->flags)) ? $this->flags[$flag] : null;
157  }
158 
159  //////////////////////////////////////////////////////////////////////////////////////////////////
160  // Loaders
161  //////////////////////////////////////////////////////////////////////////////////////////////////
162 
163  /**
164  * Loads an image from a data URI.
165  *
166  * @param string $uri A data URI.
167  * @return SimpleImage
168  *
169  * @throws Exception Thrown if URI or image data is invalid.
170  */
171  public function fromDataUri(string $uri): static
172  {
173  // Basic formatting check
174  preg_match('/^data:(.*?);/', $uri, $matches);
175  if (! count($matches)) {
176  throw new Exception('Invalid data URI.', self::ERR_INVALID_DATA_URI);
177  }
178 
179  // Determine mime type
180  $this->mimeType = $matches[1];
181  if (! preg_match('/^image\/(gif|jpeg|png)$/', $this->mimeType)) {
182  throw new Exception(
183  'Unsupported format: '.$this->mimeType,
184  self::ERR_UNSUPPORTED_FORMAT
185  );
186  }
187 
188  // Get image data
189  $uri = base64_decode(strval(preg_replace('/^data:(.*?);base64,/', '', $uri)));
190  $this->image = imagecreatefromstring($uri);
191  if (! $this->image) {
192  throw new Exception('Invalid image data.', self::ERR_INVALID_IMAGE);
193  }
194 
195  return $this;
196  }
197 
198  /**
199  * Loads an image from a file.
200  *
201  * @param string $file The image file to load.
202  * @return SimpleImage
203  *
204  * @throws Exception Thrown if file or image data is invalid.
205  */
206  public function fromFile(string $file): static
207  {
208  // Set fopen options.
209  $sslVerify = $this->getFlag('sslVerify'); // Don't perform peer validation when true
210  $opts = [
211  'ssl' => [
212  'verify_peer' => $sslVerify,
213  'verify_peer_name' => $sslVerify,
214  ],
215  ];
216 
217  // Check if the file exists and is readable.
218  $file = @file_get_contents($file, false, stream_context_create($opts));
219  if ($file === false) {
220  throw new Exception("File not found: $file", self::ERR_FILE_NOT_FOUND);
221  }
222 
223  // Create image object from string
224  $this->image = imagecreatefromstring($file);
225 
226  // Get image info
227  $info = @getimagesizefromstring($file);
228  if ($info === false) {
229  throw new Exception("Invalid image file: $file", self::ERR_INVALID_IMAGE);
230  }
231  $this->mimeType = $info['mime'];
232 
233  if (! $this->image) {
234  throw new Exception('Unsupported format: '.$this->mimeType, self::ERR_UNSUPPORTED_FORMAT);
235  }
236 
237  switch($this->mimeType) {
238  case 'image/gif':
239  // Copy the gif over to a true color image to preserve its transparency. This is a
240  // workaround to prevent imagepalettetotruecolor() from borking transparency.
241  $width = imagesx($this->image);
242  $height = imagesx($this->image);
243 
244  $gif = imagecreatetruecolor((int) $width, (int) $height);
245  $alpha = imagecolorallocatealpha($gif, 0, 0, 0, 127);
246  imagecolortransparent($gif, $alpha ?: null);
247  imagefill($gif, 0, 0, $alpha);
248 
249  imagecopy($this->image, $gif, 0, 0, 0, 0, $width, $height);
250  imagedestroy($gif);
251  break;
252  case 'image/jpeg':
253  // Load exif data from JPEG images
254  if (function_exists('exif_read_data')) {
255  $this->exif = @exif_read_data('data://image/jpeg;base64,'.base64_encode($file));
256  }
257  break;
258  }
259 
260  // Convert pallete images to true color images
261  imagepalettetotruecolor($this->image);
262 
263  return $this;
264  }
265 
266  /**
267  * Creates a new image.
268  *
269  * @param int $width The width of the image.
270  * @param int $height The height of the image.
271  * @param string|array $color Optional fill color for the new image (default 'transparent').
272  * @return SimpleImage
273  *
274  * @throws Exception
275  */
276  public function fromNew(int $width, int $height, string|array $color = 'transparent'): static
277  {
278  $this->image = imagecreatetruecolor($width, $height);
279 
280  // Use PNG for dynamically created images because it's lossless and supports transparency
281  $this->mimeType = 'image/png';
282 
283  // Fill the image with color
284  $this->fill($color);
285 
286  return $this;
287  }
288 
289  /**
290  * Creates a new image from a string.
291  *
292  * @param string $string The raw image data as a string.
293  * @return SimpleImage
294  *
295  * @throws Exception
296  *
297  * @example
298  * $string = file_get_contents('image.jpg');
299  */
300  public function fromString(string $string): SimpleImage|static
301  {
302  return $this->fromFile('data://;base64,'.base64_encode($string));
303  }
304 
305  //////////////////////////////////////////////////////////////////////////////////////////////////
306  // Savers
307  //////////////////////////////////////////////////////////////////////////////////////////////////
308 
309  /**
310  * Generates an image.
311  *
312  * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
313  * @param array|int $options Array or Image quality as a percentage (default 100).
314  * @return array Returns an array containing the image data and mime type ['data' => '', 'mimeType' => ''].
315  *
316  * @throws Exception Thrown when WEBP support is not enabled or unsupported format.
317  */
318  protected function generate(string $mimeType = null, array|int $options = []): array
319  {
320  // Format defaults to the original mime type
321  $mimeType = $mimeType ?: $this->mimeType;
322 
323  $quality = null;
324  // allow $options to be an int for backwards compatibility to v3
325  if (is_int($options)) {
326  $quality = $options;
327  $options = [];
328  }
329 
330  // get quality if passed as an option
331  if (is_array($options) && array_key_exists('quality', $options)) {
332  $quality = intval($options['quality']);
333  }
334 
335  // Ensure quality is a valid integer
336  if ($quality === null) {
337  $quality = 100;
338  }
339  $quality = (int) round(self::keepWithin((int) $quality, 0, 100));
340 
341  $alpha = true;
342  // get alpha if passed as an option
343  if (is_array($options) && array_key_exists('alpha', $options)) {
344  $alpha = boolval($options['alpha']);
345  }
346 
347  $interlace = null; // keep the same
348  // get interlace if passed as an option
349  if (is_array($options) && array_key_exists('interlace', $options)) {
350  $interlace = boolval($options['interlace']);
351  }
352 
353  // get raw stream from image* functions in providing no path
354  $file = null;
355 
356  // Capture output
357  ob_start();
358 
359  // Generate the image
360  switch($mimeType) {
361  case 'image/gif':
362  imagesavealpha($this->image, $alpha);
363  imagegif($this->image, $file);
364  break;
365  case 'image/jpeg':
366  imageinterlace($this->image, $interlace);
367  imagejpeg($this->image, $file, $quality);
368  break;
369  case 'image/png':
370  $filters = -1; // imagepng default
371  // get filters if passed as an option
372  if (is_array($options) && array_key_exists('filters', $options)) {
373  $filters = intval($options['filters']);
374  }
375  // compression param is called quality in imagepng but that would be
376  // misleading in context of SimpleImage
377  $compression = -1; // defaults to zlib default which is 6
378  // get compression if passed as an option
379  if (is_array($options) && array_key_exists('compression', $options)) {
380  $compression = intval($options['compression']);
381  }
382  if ($compression !== -1) {
383  $compression = (int) round(self::keepWithin($compression, 0, 10));
384  }
385  imagesavealpha($this->image, $alpha);
386  imagepng($this->image, $file, $compression, $filters);
387  break;
388  case 'image/webp':
389  // Not all versions of PHP will have webp support enabled
390  if (! function_exists('imagewebp')) {
391  throw new Exception(
392  'WEBP support is not enabled in your version of PHP.',
393  self::ERR_WEBP_NOT_ENABLED
394  );
395  }
396  // useless but recommended, see https://www.php.net/manual/en/function.imagesavealpha.php
397  imagesavealpha($this->image, $alpha);
398  imagewebp($this->image, $file, $quality);
399  break;
400  case 'image/bmp':
401  case 'image/x-ms-bmp':
402  case 'image/x-windows-bmp':
403  // Not all versions of PHP support bmp
404  if (! function_exists('imagebmp')) {
405  throw new Exception(
406  'BMP support is not available in your version of PHP.',
407  self::ERR_UNSUPPORTED_FORMAT
408  );
409  }
410  $compression = true; // imagebmp default
411  // get compression if passed as an option
412  if (is_array($options) && array_key_exists('compression', $options)) {
413  $compression = is_int($options['compression']) ?
414  $options['compression'] > 0 : boolval($options['compression']);
415  }
416  imageinterlace($this->image, $interlace);
417  imagebmp($this->image, $file, $compression);
418  break;
419  case 'image/avif':
420  // Not all versions of PHP support avif
421  if (! function_exists('imageavif')) {
422  throw new Exception(
423  'AVIF support is not available in your version of PHP.',
424  self::ERR_UNSUPPORTED_FORMAT
425  );
426  }
427  $speed = -1; // imageavif default
428  // get speed if passed as an option
429  if (is_array($options) && array_key_exists('speed', $options)) {
430  $speed = intval($options['speed']);
431  $speed = self::keepWithin($speed, 0, 10);
432  }
433  // useless but recommended, see https://www.php.net/manual/en/function.imagesavealpha.php
434  imagesavealpha($this->image, $alpha);
435  imageavif($this->image, $file, $quality, $speed);
436  break;
437  default:
438  throw new Exception('Unsupported format: '.$mimeType, self::ERR_UNSUPPORTED_FORMAT);
439  }
440 
441  // Stop capturing
442  $data = ob_get_contents();
443  ob_end_clean();
444 
445  return [
446  'data' => $data,
447  'mimeType' => $mimeType,
448  ];
449  }
450 
451  /**
452  * Generates a data URI.
453  *
454  * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
455  * @param array|int $options Array or Image quality as a percentage (default 100).
456  * @return string Returns a string containing a data URI.
457  *
458  * @throws Exception
459  */
460  public function toDataUri(string $mimeType = null, array|int $options = 100): string
461  {
462  $image = $this->generate($mimeType, $options);
463 
464  return 'data:'.$image['mimeType'].';base64,'.base64_encode($image['data']);
465  }
466 
467  /**
468  * Forces the image to be downloaded to the clients machine. Must be called before any output is sent to the screen.
469  *
470  * @param string $filename The filename (without path) to send to the client (e.g. 'image.jpeg').
471  * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
472  * @param array|int $options Array or Image quality as a percentage (default 100).
473  * @return SimpleImage
474  *
475  * @throws Exception
476  */
477  public function toDownload(string $filename, string $mimeType = null, array|int $options = 100): static
478  {
479  $image = $this->generate($mimeType, $options);
480 
481  // Set download headers
482  header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
483  header('Content-Description: File Transfer');
484  header('Content-Length: '.strlen($image['data']));
485  header('Content-Transfer-Encoding: Binary');
486  header('Content-Type: application/octet-stream');
487  header("Content-Disposition: attachment; filename=\"$filename\"");
488 
489  echo $image['data'];
490 
491  return $this;
492  }
493 
494  /**
495  * Writes the image to a file.
496  *
497  * @param string $file The image format to output as a mime type (defaults to the original mime type).
498  * @param string|null $mimeType Image quality as a percentage (default 100).
499  * @param array|int $options Array or Image quality as a percentage (default 100).
500  * @return SimpleImage
501  *
502  * @throws Exception Thrown if failed write to file.
503  */
504  public function toFile(string $file, string $mimeType = null, array|int $options = 100): static
505  {
506  $image = $this->generate($mimeType, $options);
507 
508  // Save the image to file
509  if (! file_put_contents($file, $image['data'])) {
510  throw new Exception("Failed to write image to file: $file", self::ERR_WRITE);
511  }
512 
513  return $this;
514  }
515 
516  /**
517  * Outputs the image to the screen. Must be called before any output is sent to the screen.
518  *
519  * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
520  * @param array|int $options Array or Image quality as a percentage (default 100).
521  * @return SimpleImage
522  *
523  * @throws Exception
524  */
525  public function toScreen(string $mimeType = null, array|int $options = 100): static
526  {
527  $image = $this->generate($mimeType, $options);
528 
529  // Output the image to stdout
530  header('Content-Type: '.$image['mimeType']);
531  echo $image['data'];
532 
533  return $this;
534  }
535 
536  /**
537  * Generates an image string.
538  *
539  * @param string|null $mimeType The image format to output as a mime type (defaults to the original mime type).
540  * @param array|int $options Array or Image quality as a percentage (default 100).
541  *
542  * @throws Exception
543  */
544  public function toString(string $mimeType = null, array|int $options = 100): string
545  {
546  return $this->generate($mimeType, $options)['data'];
547  }
548 
549  //////////////////////////////////////////////////////////////////////////////////////////////////
550  // Utilities
551  //////////////////////////////////////////////////////////////////////////////////////////////////
552  /**
553  * Ensures a numeric value is always within the min and max range.
554  *
555  * @param int|float $value A numeric value to test.
556  * @param int|float $min The minimum allowed value.
557  * @param int|float $max The maximum allowed value.
558  */
559  protected static function keepWithin(int|float $value, int|float $min, int|float $max): int|float
560  {
561  if ($value < $min) {
562  return $min;
563  }
564  if ($value > $max) {
565  return $max;
566  }
567 
568  return $value;
569  }
570 
571  /**
572  * Gets the image's current aspect ratio.
573  *
574  * @return float|int Returns the aspect ratio as a float.
575  */
576  public function getAspectRatio(): float|int
577  {
578  return $this->getWidth() / $this->getHeight();
579  }
580 
581  /**
582  * Gets the image's exif data.
583  *
584  * @return array|null Returns an array of exif data or null if no data is available.
585  */
586  public function getExif(): ?array
587  {
588  return $this->exif ?? null;
589  }
590 
591  /**
592  * Gets the image's current height.
593  */
594  public function getHeight(): int
595  {
596  return (int) imagesy($this->image);
597  }
598 
599  /**
600  * Gets the mime type of the loaded image.
601  */
602  public function getMimeType(): string
603  {
604  return $this->mimeType;
605  }
606 
607  /**
608  * Gets the image's current orientation.
609  *
610  * @return string One of the values: 'landscape', 'portrait', or 'square'
611  */
612  public function getOrientation(): string
613  {
614  $width = $this->getWidth();
615  $height = $this->getHeight();
616 
617  if ($width > $height) {
618  return 'landscape';
619  }
620  if ($width < $height) {
621  return 'portrait';
622  }
623 
624  return 'square';
625  }
626 
627  /**
628  * Gets the resolution of the image
629  *
630  * @return array|bool The resolution as an array of integers: [96, 96]
631  */
632  public function getResolution(): bool|array
633  {
634  return imageresolution($this->image);
635  }
636 
637  /**
638  * Gets the image's current width.
639  */
640  public function getWidth(): int
641  {
642  return (int) imagesx($this->image);
643  }
644 
645  //////////////////////////////////////////////////////////////////////////////////////////////////
646  // Manipulation
647  //////////////////////////////////////////////////////////////////////////////////////////////////
648 
649  /**
650  * Same as PHP's imagecopymerge, but works with transparent images. Used internally for overlay.
651  *
652  * @param resource $dstIm Destination image link resource.
653  * @param resource $srcIm Source image link resource.
654  * @param int $dstX x-coordinate of destination point.
655  * @param int $dstY y-coordinate of destination point.
656  * @param int $srcX x-coordinate of source point.
657  * @param int $srcY y-coordinate of source point.
658  * @param int $srcW Source width.
659  * @param int $srcH Source height.
660  * @return bool true if success.
661  */
662  protected static function imageCopyMergeAlpha($dstIm, $srcIm, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct): bool
663  {
664  // Are we merging with transparency?
665  if ($pct < 100) {
666  // Disable alpha blending and "colorize" the image using a transparent color
667  imagealphablending($srcIm, false);
668  imagefilter($srcIm, IMG_FILTER_COLORIZE, 0, 0, 0, round(127 * ((100 - $pct) / 100)));
669  }
670 
671  imagecopy($dstIm, $srcIm, $dstX, $dstY, $srcX, $srcY, $srcW, $srcH);
672 
673  return true;
674  }
675 
676  /**
677  * Rotates an image so the orientation will be correct based on its exif data. It is safe to call
678  * this method on images that don't have exif data (no changes will be made).
679  *
680  * @return SimpleImage
681  *
682  * @throws Exception
683  */
684  public function autoOrient(): static
685  {
686  $exif = $this->getExif();
687 
688  if (! $exif || ! isset($exif['Orientation'])) {
689  return $this;
690  }
691 
692  switch($exif['Orientation']) {
693  case 1: // Do nothing!
694  break;
695  case 2: // Flip horizontally
696  $this->flip('x');
697  break;
698  case 3: // Rotate 180 degrees
699  $this->rotate(180);
700  break;
701  case 4: // Flip vertically
702  $this->flip('y');
703  break;
704  case 5: // Rotate 90 degrees clockwise and flip vertically
705  $this->flip('y')->rotate(90);
706  break;
707  case 6: // Rotate 90 clockwise
708  $this->rotate(90);
709  break;
710  case 7: // Rotate 90 clockwise and flip horizontally
711  $this->flip('x')->rotate(90);
712  break;
713  case 8: // Rotate 90 counterclockwise
714  $this->rotate(-90);
715  break;
716  }
717 
718  return $this;
719  }
720 
721  /**
722  * Proportionally resize the image to fit inside a specific width and height.
723  *
724  * @param int $maxWidth The maximum width the image can be.
725  * @param int $maxHeight The maximum height the image can be.
726  * @return SimpleImage
727  */
728  public function bestFit(int $maxWidth, int $maxHeight): static
729  {
730  // If the image already fits, there's nothing to do
731  if ($this->getWidth() <= $maxWidth && $this->getHeight() <= $maxHeight) {
732  return $this;
733  }
734 
735  // Calculate max width or height based on orientation
736  if ($this->getOrientation() === 'portrait') {
737  $height = $maxHeight;
738  $width = (int) round($maxHeight * $this->getAspectRatio());
739  } else {
740  $width = $maxWidth;
741  $height = (int) round($maxWidth / $this->getAspectRatio());
742  }
743 
744  // Reduce to max width
745  if ($width > $maxWidth) {
746  $width = $maxWidth;
747  $height = (int) round($width / $this->getAspectRatio());
748  }
749 
750  // Reduce to max height
751  if ($height > $maxHeight) {
752  $height = $maxHeight;
753  $width = (int) round($height * $this->getAspectRatio());
754  }
755 
756  return $this->resize($width, $height);
757  }
758 
759  /**
760  * Crop the image.
761  *
762  * @param int|float $x1 Top left x coordinate.
763  * @param int|float $y1 Top left y coordinate.
764  * @param int|float $x2 Bottom right x coordinate.
765  * @param int|float $y2 Bottom right x coordinate.
766  * @return SimpleImage
767  */
768  public function crop(int|float $x1, int|float $y1, int|float $x2, int|float $y2): static
769  {
770  // Keep crop within image dimensions
771  $x1 = self::keepWithin($x1, 0, $this->getWidth());
772  $x2 = self::keepWithin($x2, 0, $this->getWidth());
773  $y1 = self::keepWithin($y1, 0, $this->getHeight());
774  $y2 = self::keepWithin($y2, 0, $this->getHeight());
775 
776  // Avoid using native imagecrop() because of a bug with PNG transparency
777  $dstW = abs($x2 - $x1);
778  $dstH = abs($y2 - $y1);
779  $newImage = imagecreatetruecolor((int) $dstW, (int) $dstH);
780  $transparentColor = imagecolorallocatealpha($newImage, 0, 0, 0, 127);
781  imagecolortransparent($newImage, $transparentColor ?: null);
782  imagefill($newImage, 0, 0, $transparentColor);
783 
784  // Crop it
785  imagecopyresampled(
786  $newImage,
787  $this->image,
788  0,
789  0,
790  (int) round(min($x1, $x2)),
791  (int) round(min($y1, $y2)),
792  (int) $dstW,
793  (int) $dstH,
794  (int) $dstW,
795  (int) $dstH
796  );
797 
798  // Swap out the new image
799  $this->image = $newImage;
800 
801  return $this;
802  }
803 
804  /**
805  * Applies a duotone filter to the image.
806  *
807  * @param string|array $lightColor The lightest color in the duotone.
808  * @param string|array $darkColor The darkest color in the duotone.
809  * @return SimpleImage
810  *
811  * @throws Exception
812  */
813  public function duotone(string|array $lightColor, string|array $darkColor): static
814  {
815  $lightColor = self::normalizeColor($lightColor);
816  $darkColor = self::normalizeColor($darkColor);
817 
818  // Calculate averages between light and dark colors
819  $redAvg = $lightColor['red'] - $darkColor['red'];
820  $greenAvg = $lightColor['green'] - $darkColor['green'];
821  $blueAvg = $lightColor['blue'] - $darkColor['blue'];
822 
823  // Create a matrix of all possible duotone colors based on gray values
824  $pixels = [];
825  for ($i = 0; $i <= 255; $i++) {
826  $grayAvg = $i / 255;
827  $pixels['red'][$i] = $darkColor['red'] + $grayAvg * $redAvg;
828  $pixels['green'][$i] = $darkColor['green'] + $grayAvg * $greenAvg;
829  $pixels['blue'][$i] = $darkColor['blue'] + $grayAvg * $blueAvg;
830  }
831 
832  // Apply the filter pixel by pixel
833  for ($x = 0; $x < $this->getWidth(); $x++) {
834  for ($y = 0; $y < $this->getHeight(); $y++) {
835  $rgb = $this->getColorAt($x, $y);
836  $gray = min(255, round(0.299 * $rgb['red'] + 0.114 * $rgb['blue'] + 0.587 * $rgb['green']));
837  $this->dot($x, $y, [
838  'red' => $pixels['red'][$gray],
839  'green' => $pixels['green'][$gray],
840  'blue' => $pixels['blue'][$gray],
841  ]);
842  }
843  }
844 
845  return $this;
846  }
847 
848  /**
849  * Proportionally resize the image to a specific width.
850  *
851  * @param int $width The width to resize the image to.
852  * @return SimpleImage
853  *
854  *@deprecated
855  * This method was deprecated in version 3.2.2 and will be removed in version 4.0.
856  * Please use `resize(null, $height)` instead.
857  */
858  public function fitToWidth(int $width): static
859  {
860  return $this->resize($width);
861  }
862 
863  /**
864  * Flip the image horizontally or vertically.
865  *
866  * @param string $direction The direction to flip: x|y|both.
867  * @return SimpleImage
868  */
869  public function flip(string $direction): static
870  {
871  match ($direction) {
872  'x' => imageflip($this->image, IMG_FLIP_HORIZONTAL),
873  'y' => imageflip($this->image, IMG_FLIP_VERTICAL),
874  'both' => imageflip($this->image, IMG_FLIP_BOTH),
875  default => $this,
876  };
877 
878  return $this;
879  }
880 
881  /**
882  * Reduces the image to a maximum number of colors.
883  *
884  * @param int $max The maximum number of colors to use.
885  * @param bool $dither Whether or not to use a dithering effect (default true).
886  * @return SimpleImage
887  */
888  public function maxColors(int $max, bool $dither = true): static
889  {
890  imagetruecolortopalette($this->image, $dither, max(1, $max));
891 
892  return $this;
893  }
894 
895  /**
896  * Place an image on top of the current image.
897  *
898  * @param string|SimpleImage $overlay The image to overlay. This can be a filename, a data URI, or a SimpleImage object.
899  * @param string $anchor The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center').
900  * @param float|int $opacity The opacity level of the overlay 0-1 (default 1).
901  * @param int $xOffset Horizontal offset in pixels (default 0).
902  * @param int $yOffset Vertical offset in pixels (default 0).
903  * @param bool $calculateOffsetFromEdge Calculate Offset referring to the edges of the image (default false).
904  * @return SimpleImage
905  *
906  * @throws Exception
907  */
908  public function overlay(string|SimpleImage $overlay, string $anchor = 'center', float|int $opacity = 1, int $xOffset = 0, int $yOffset = 0, bool $calculateOffsetFromEdge = false): static
909  {
910  // Load overlay image
911  if (! ($overlay instanceof SimpleImage)) {
912  $overlay = new SimpleImage($overlay);
913  }
914 
915  // Convert opacity
916  $opacity = (int) round(self::keepWithin($opacity, 0, 1) * 100);
917 
918  // Get available space
919  $spaceX = $this->getWidth() - $overlay->getWidth();
920  $spaceY = $this->getHeight() - $overlay->getHeight();
921 
922  // Set default center
923  $x = (int) round(($spaceX / 2) + ($calculateOffsetFromEdge ? 0 : $xOffset));
924  $y = (int) round(($spaceY / 2) + ($calculateOffsetFromEdge ? 0 : $yOffset));
925 
926  // Determine if top|bottom
927  if (str_contains($anchor, 'top')) {
928  $y = $yOffset;
929  } elseif (str_contains($anchor, 'bottom')) {
930  $y = $spaceY + ($calculateOffsetFromEdge ? -$yOffset : $yOffset);
931  }
932 
933  // Determine if left|right
934  if (str_contains($anchor, 'left')) {
935  $x = $xOffset;
936  } elseif (str_contains($anchor, 'right')) {
937  $x = $spaceX + ($calculateOffsetFromEdge ? -$xOffset : $xOffset);
938  }
939 
940  // Perform the overlay
941  self::imageCopyMergeAlpha(
942  $this->image,
943  $overlay->image,
944  $x, $y,
945  0, 0,
946  $overlay->getWidth(),
947  $overlay->getHeight(),
948  $opacity
949  );
950 
951  return $this;
952  }
953 
954  /**
955  * Resize an image to the specified dimensions. If only one dimension is specified, the image will be resized proportionally.
956  *
957  * @param int|null $width The new image width.
958  * @param int|null $height The new image height.
959  * @return SimpleImage
960  */
961  public function resize(int $width = null, int $height = null): static
962  {
963  // No dimensions specified
964  if (! $width && ! $height) {
965  return $this;
966  }
967 
968  // Resize to width
969  if ($width && ! $height) {
970  $height = (int) round($width / $this->getAspectRatio());
971  }
972 
973  // Resize to height
974  if (! $width && $height) {
975  $width = (int) round($height * $this->getAspectRatio());
976  }
977 
978  // If the dimensions are the same, there's no need to resize
979  if ($this->getWidth() === $width && $this->getHeight() === $height) {
980  return $this;
981  }
982 
983  // We can't use imagescale because it doesn't seem to preserve transparency properly. The
984  // workaround is to create a new truecolor image, allocate a transparent color, and copy the
985  // image over to it using imagecopyresampled.
986  $newImage = imagecreatetruecolor($width, $height);
987  $transparentColor = imagecolorallocatealpha($newImage, 0, 0, 0, 127);
988  imagecolortransparent($newImage, $transparentColor);
989  imagefill($newImage, 0, 0, $transparentColor);
990  imagecopyresampled(
991  $newImage,
992  $this->image,
993  0, 0, 0, 0,
994  $width,
995  $height,
996  $this->getWidth(),
997  $this->getHeight()
998  );
999 
1000  // Swap out the new image
1001  $this->image = $newImage;
1002 
1003  return $this;
1004  }
1005 
1006  /**
1007  * Sets an image's resolution, as per https://www.php.net/manual/en/function.imageresolution.php
1008  *
1009  * @param int $res_x The horizontal resolution in DPI.
1010  * @param int|null $res_y The vertical resolution in DPI
1011  * @return SimpleImage
1012  */
1013  public function resolution(int $res_x, int $res_y = null): static
1014  {
1015  if (is_null($res_y)) {
1016  imageresolution($this->image, $res_x);
1017  } else {
1018  imageresolution($this->image, $res_x, $res_y);
1019  }
1020 
1021  return $this;
1022  }
1023 
1024  /**
1025  * Rotates the image.
1026  *
1027  * @param int $angle The angle of rotation (-360 - 360).
1028  * @param string|array $backgroundColor The background color to use for the uncovered zone area after rotation (default 'transparent').
1029  * @return SimpleImage
1030  *
1031  * @throws Exception
1032  */
1033  public function rotate(int $angle, string|array $backgroundColor = 'transparent'): static
1034  {
1035  // Rotate the image on a canvas with the desired background color
1036  $backgroundColor = $this->allocateColor($backgroundColor);
1037 
1038  $this->image = imagerotate(
1039  $this->image,
1040  -(self::keepWithin($angle, -360, 360)),
1041  $backgroundColor
1042  );
1043  imagecolortransparent($this->image, imagecolorallocatealpha($this->image, 0, 0, 0, 127));
1044 
1045  return $this;
1046  }
1047 
1048  /**
1049  * Adds text to the image.
1050  *
1051  * @param string $text The desired text.
1052  * @param array $options
1053  * An array of options.
1054  * - fontFile* (string) - The TrueType (or compatible) font file to use.
1055  * - size (integer) - The size of the font in pixels (default 12).
1056  * - color (string|array) - The text color (default black).
1057  * - anchor (string) - The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center').
1058  * - xOffset (integer) - The horizontal offset in pixels (default 0).
1059  * - yOffset (integer) - The vertical offset in pixels (default 0).
1060  * - shadow (array) - Text shadow params.
1061  * - x* (integer) - Horizontal offset in pixels.
1062  * - y* (integer) - Vertical offset in pixels.
1063  * - color* (string|array) - The text shadow color.
1064  * - $calculateOffsetFromEdge (bool) - Calculate offsets from the edge of the image (default false).
1065  * - $baselineAlign (bool) - Align the text font with the baseline. (default true).
1066  * @param array|null $boundary
1067  * If passed, this variable will contain an array with coordinates that surround the text: [x1, y1, x2, y2, width, height].
1068  * This can be used for calculating the text's position after it gets added to the image.
1069  * @return SimpleImage
1070  *
1071  * @throws Exception
1072  */
1073  public function text(string $text, array $options, array &$boundary = null): static
1074  {
1075  // Check for freetype support
1076  if (! function_exists('imagettftext')) {
1077  throw new Exception(
1078  'Freetype support is not enabled in your version of PHP.',
1079  self::ERR_FREETYPE_NOT_ENABLED
1080  );
1081  }
1082 
1083  // Default options
1084  $options = array_merge([
1085  'fontFile' => null,
1086  'size' => 12,
1087  'color' => 'black',
1088  'anchor' => 'center',
1089  'xOffset' => 0,
1090  'yOffset' => 0,
1091  'shadow' => null,
1092  'calculateOffsetFromEdge' => false,
1093  'baselineAlign' => true,
1094  ], $options);
1095 
1096  // Extract and normalize options
1097  $fontFile = $options['fontFile'];
1098  $size = ($options['size'] / 96) * 72; // Convert px to pt (72pt per inch, 96px per inch)
1099  $color = $this->allocateColor($options['color']);
1100  $anchor = $options['anchor'];
1101  $xOffset = $options['xOffset'];
1102  $yOffset = $options['yOffset'];
1103  $calculateOffsetFromEdge = $options['calculateOffsetFromEdge'];
1104  $baselineAlign = $options['baselineAlign'];
1105  $angle = 0;
1106 
1107  // Calculate the bounding box dimensions
1108  //
1109  // Since imagettfbox() returns a bounding box from the text's baseline, we can end up with
1110  // different heights for different strings of the same font size. For example, 'type' will often
1111  // be taller than 'text' because the former has a descending letter.
1112  //
1113  // To compensate for this, we created a temporary bounding box to measure the maximum height
1114  // that the font used can occupy. Based on this, we can adjust the text vertically so that it
1115  // appears inside the box with a good consistency.
1116  //
1117  // See: https://github.com/claviska/SimpleImage/issues/165
1118  //
1119 
1120  $boxText = imagettfbbox($size, $angle, $fontFile, $text);
1121  if (! $boxText) {
1122  throw new Exception("Unable to load font file: $fontFile", self::ERR_FONT_FILE);
1123  }
1124 
1125  $boxWidth = abs($boxText[4] - $boxText[0]);
1126  $boxHeight = abs($boxText[5] - $boxText[1]);
1127 
1128  // Calculate Offset referring to the edges of the image.
1129  // Just invert the value for bottom|right;
1130  if ($calculateOffsetFromEdge) {
1131  if (str_contains($anchor, 'bottom')) {
1132  $yOffset *= -1;
1133  }
1134  if (str_contains($anchor, 'right')) {
1135  $xOffset *= -1;
1136  }
1137  }
1138 
1139  // Align the text font with the baseline.
1140  // I use $yOffset to inject the vertical alignment correction value.
1141  if ($baselineAlign) {
1142  // Create a temporary box to obtain the maximum height that this font can use.
1143  $boxFull = imagettfbbox($size, $angle, $fontFile, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890');
1144  // Based on the maximum height, the text is aligned.
1145  if (str_contains($anchor, 'bottom')) {
1146  $yOffset -= $boxFull[1];
1147  } elseif (str_contains($anchor, 'top')) {
1148  $yOffset += abs($boxFull[5]) - $boxHeight;
1149  } else { // center
1150  $boxFullHeight = abs($boxFull[1]) + abs($boxFull[5]);
1151  $yOffset += ($boxFullHeight / 2) - ($boxHeight / 2) - abs($boxFull[1]);
1152  }
1153  } else {
1154  // Prevents fonts rendered outside the box boundary from being cut.
1155  // Example: 'Scriptina' font, some letters invade the space of the previous or subsequent letter.
1156  $yOffset -= $boxText[1];
1157  }
1158 
1159  // Prevents fonts rendered outside the box boundary from being cut.
1160  // Example: 'Scriptina' font, some letters invade the space of the previous or subsequent letter.
1161  $xOffset -= $boxText[0];
1162 
1163  // Determine position
1164  switch($anchor) {
1165  case 'top left':
1166  $x = $xOffset;
1167  $y = $yOffset + $boxHeight;
1168  break;
1169  case 'top right':
1170  $x = $this->getWidth() - $boxWidth + $xOffset;
1171  $y = $yOffset + $boxHeight;
1172  break;
1173  case 'top':
1174  $x = ($this->getWidth() / 2) - ($boxWidth / 2) + $xOffset;
1175  $y = $yOffset + $boxHeight;
1176  break;
1177  case 'bottom left':
1178  $x = $xOffset;
1179  $y = $this->getHeight() + $yOffset;
1180  break;
1181  case 'bottom right':
1182  $x = $this->getWidth() - $boxWidth + $xOffset;
1183  $y = $this->getHeight() + $yOffset;
1184  break;
1185  case 'bottom':
1186  $x = ($this->getWidth() / 2) - ($boxWidth / 2) + $xOffset;
1187  $y = $this->getHeight() + $yOffset;
1188  break;
1189  case 'left':
1190  $x = $xOffset;
1191  $y = ($this->getHeight() / 2) - (($boxHeight / 2) - $boxHeight) + $yOffset;
1192  break;
1193  case 'right':
1194  $x = $this->getWidth() - $boxWidth + $xOffset;
1195  $y = ($this->getHeight() / 2) - (($boxHeight / 2) - $boxHeight) + $yOffset;
1196  break;
1197  default: // center
1198  $x = ($this->getWidth() / 2) - ($boxWidth / 2) + $xOffset;
1199  $y = ($this->getHeight() / 2) - (($boxHeight / 2) - $boxHeight) + $yOffset;
1200  break;
1201  }
1202  $x = (int) round($x);
1203  $y = (int) round($y);
1204 
1205  // Pass the boundary back by reference
1206  $boundary = [
1207  'x1' => $x + $boxText[0],
1208  'y1' => $y + $boxText[1] - $boxHeight, // $y is the baseline, not the top!
1209  'x2' => $x + $boxWidth + $boxText[0],
1210  'y2' => $y + $boxText[1],
1211  'width' => $boxWidth,
1212  'height' => $boxHeight,
1213  ];
1214 
1215  // Text shadow
1216  if (is_array($options['shadow'])) {
1217  imagettftext(
1218  $this->image,
1219  $size,
1220  $angle,
1221  $x + $options['shadow']['x'],
1222  $y + $options['shadow']['y'],
1223  $this->allocateColor($options['shadow']['color']),
1224  $fontFile,
1225  $text
1226  );
1227  }
1228 
1229  // Draw the text
1230  imagettftext($this->image, $size, $angle, $x, $y, $color, $fontFile, $text);
1231 
1232  return $this;
1233  }
1234 
1235  /**
1236  * Adds text with a line break to the image.
1237  *
1238  * @param string $text The desired text.
1239  * @param array $options
1240  * An array of options.
1241  * - fontFile* (string) - The TrueType (or compatible) font file to use.
1242  * - size (integer) - The size of the font in pixels (default 12).
1243  * - color (string|array) - The text color (default black).
1244  * - anchor (string) - The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center').
1245  * - xOffset (integer) - The horizontal offset in pixels (default 0). Has no effect when anchor is 'center'.
1246  * - yOffset (integer) - The vertical offset in pixels (default 0). Has no effect when anchor is 'center'.
1247  * - shadow (array) - Text shadow params.
1248  * - x* (integer) - Horizontal offset in pixels.
1249  * - y* (integer) - Vertical offset in pixels.
1250  * - color* (string|array) - The text shadow color.
1251  * - $calculateOffsetFromEdge (bool) - Calculate offsets from the edge of the image (default false).
1252  * - width (int) - Width of text box (default image width).
1253  * - align (string) - How to align text: 'left', 'right', 'center', 'justify' (default 'left').
1254  * - leading (float) - Increase/decrease spacing between lines of text (default 0).
1255  * - opacity (float) - The opacity level of the text 0-1 (default 1).
1256  * @return SimpleImage
1257  *
1258  * @throws Exception
1259  */
1260  public function textBox(string $text, array $options): static
1261  {
1262  // default width of image
1263  $maxWidth = $this->getWidth();
1264  // Default options
1265  $options = array_merge([
1266  'fontFile' => null,
1267  'size' => 12,
1268  'color' => 'black',
1269  'anchor' => 'center',
1270  'xOffset' => 0,
1271  'yOffset' => 0,
1272  'shadow' => null,
1273  'calculateOffsetFromEdge' => false,
1274  'width' => $maxWidth,
1275  'align' => 'left',
1276  'leading' => 0,
1277  'opacity' => 1,
1278  ], $options);
1279 
1280  // Extract and normalize options
1281  $fontFile = $options['fontFile'];
1282  $fontSize = $fontSizePx = $options['size'];
1283  $fontSize = ($fontSize / 96) * 72; // Convert px to pt (72pt per inch, 96px per inch)
1284  $color = $options['color'];
1285  $anchor = $options['anchor'];
1286  $xOffset = $options['xOffset'];
1287  $yOffset = $options['yOffset'];
1288  $shadow = $options['shadow'];
1289  $calculateOffsetFromEdge = $options['calculateOffsetFromEdge'];
1290  $maxWidth = intval($options['width']);
1291  $leading = $options['leading'];
1292  $leading = self::keepWithin($leading, ($fontSizePx * -1), $leading);
1293  $opacity = $options['opacity'];
1294 
1295  $align = $options['align'];
1296  if ($align == 'right') {
1297  $align = 'top right';
1298  } elseif ($align == 'center') {
1299  $align = 'top';
1300  } elseif ($align == 'justify') {
1301  $align = 'justify';
1302  } else {
1303  $align = 'top left';
1304  }
1305 
1306  [$lines, $isLastLine, $lastLineHeight] = self::textSeparateLines($text, $fontFile, $fontSize, $maxWidth);
1307 
1308  $maxHeight = (int) round(((is_countable($lines) ? count($lines) : 0) - 1) * ($fontSizePx * 1.2 + $leading) + $lastLineHeight);
1309 
1310  $imageText = new SimpleImage();
1311  $imageText->fromNew($maxWidth, $maxHeight);
1312 
1313  // Align left/center/right
1314  if ($align != 'justify') {
1315  foreach ($lines as $key => $line) {
1316  if ($align == 'top') {
1317  $line = trim($line);
1318  } // If is justify = 'center'
1319  $imageText->text($line, ['fontFile' => $fontFile, 'size' => $fontSizePx, 'color' => $color, 'anchor' => $align, 'xOffset' => 0, 'yOffset' => $key * ($fontSizePx * 1.2 + $leading), 'shadow' => $shadow, 'calculateOffsetFromEdge' => true]);
1320  }
1321 
1322  // Justify
1323  } else {
1324  foreach ($lines as $keyLine => $line) {
1325  // Check if there are spaces at the beginning of the sentence
1326  $spaces = 0;
1327  if (preg_match("/^\s+/", $line, $match)) {
1328  // Count spaces
1329  $spaces = strlen($match[0]);
1330  $line = ltrim($line);
1331  }
1332 
1333  // Separate words
1334  $words = preg_split("/\s+/", $line);
1335  // Include spaces with the first word
1336  $words[0] = str_repeat(' ', $spaces).$words[0];
1337 
1338  // Calculates the space occupied by all words
1339  $wordsSize = [];
1340  foreach ($words as $key => $word) {
1341  $wordBox = imagettfbbox($fontSize, 0, $fontFile, $word);
1342  $wordWidth = abs($wordBox[4] - $wordBox[0]);
1343  $wordsSize[$key] = $wordWidth;
1344  }
1345  $wordsSizeTotal = array_sum($wordsSize);
1346 
1347  // Calculates the required space between words
1348  $countWords = count($words);
1349  $wordSpacing = 0;
1350  if ($countWords > 1) {
1351  $wordSpacing = ($maxWidth - $wordsSizeTotal) / ($countWords - 1);
1352  $wordSpacing = round($wordSpacing, 3);
1353  }
1354 
1355  $xOffsetJustify = 0;
1356  foreach ($words as $key => $word) {
1357  if ($isLastLine[$keyLine]) {
1358  if ($key < (count($words) - 1)) {
1359  continue;
1360  }
1361  $word = $line;
1362  }
1363  $imageText->text($word, ['fontFile' => $fontFile, 'size' => $fontSizePx, 'color' => $color, 'anchor' => 'top left', 'xOffset' => $xOffsetJustify, 'yOffset' => $keyLine * ($fontSizePx * 1.2 + $leading), 'shadow' => $shadow, 'calculateOffsetFromEdge' => true]
1364  );
1365  // Calculate offset for next word
1366  $xOffsetJustify += $wordsSize[$key] + $wordSpacing;
1367  }
1368  }
1369  }
1370 
1371  $this->overlay($imageText, $anchor, $opacity, $xOffset, $yOffset, $calculateOffsetFromEdge);
1372 
1373  return $this;
1374  }
1375 
1376  /**
1377  * Receives a text and breaks into LINES.
1378  */
1379  private function textSeparateLines(string $text, string $fontFile, int $fontSize, int $maxWidth): array
1380  {
1381  $lines = [];
1382  $words = self::textSeparateWords($text);
1383  $countWords = count($words) - 1;
1384  $lines[0] = '';
1385  $lineKey = 0;
1386  $isLastLine = [];
1387  for ($i = 0; $i < $countWords; $i++) {
1388  $word = $words[$i];
1389  $isLastLine[$lineKey] = false;
1390  if ($word === PHP_EOL) {
1391  $isLastLine[$lineKey] = true;
1392  $lineKey++;
1393  $lines[$lineKey] = '';
1394 
1395  continue;
1396  }
1397  $lineBox = imagettfbbox($fontSize, 0, $fontFile, $lines[$lineKey].$word);
1398  if (abs($lineBox[4] - $lineBox[0]) < $maxWidth) {
1399  $lines[$lineKey] .= $word.' ';
1400  } else {
1401  $lineKey++;
1402  $lines[$lineKey] = $word.' ';
1403  }
1404  }
1405  $isLastLine[$lineKey] = true;
1406  // Exclude space of right
1407  $lines = array_map('rtrim', $lines);
1408  // Calculate height of last line
1409  $boxFull = imagettfbbox($fontSize, 0, $fontFile, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890');
1410  $lineBox = imagettfbbox($fontSize, 0, $fontFile, $lines[$lineKey]);
1411  // Height of last line = ascender of $boxFull + descender of $lineBox
1412  $lastLineHeight = abs($lineBox[1]) + abs($boxFull[5]);
1413 
1414  return [$lines, $isLastLine, $lastLineHeight];
1415  }
1416 
1417  /**
1418  * Receives a text and breaks into WORD / SPACE / NEW LINE.
1419  */
1420  private function textSeparateWords(string $text): array
1421  {
1422  // Normalizes line break
1423  $text = strval(preg_replace('/(\r\n|\n|\r)/', PHP_EOL, $text));
1424  $text = explode(PHP_EOL, $text);
1425  $newText = [];
1426  foreach ($text as $line) {
1427  $newText = array_merge($newText, explode(' ', $line), [PHP_EOL]);
1428  }
1429 
1430  return $newText;
1431  }
1432 
1433  /**
1434  * Creates a thumbnail image. This function attempts to get the image as close to the provided
1435  * dimensions as possible, then crops the remaining overflow to force the desired size. Useful
1436  * for generating thumbnail images.
1437  *
1438  * @param int $width The thumbnail width.
1439  * @param int $height The thumbnail height.
1440  * @param string $anchor The anchor point: 'center', 'top', 'bottom', 'left', 'right', 'top left', 'top right', 'bottom left', 'bottom right' (default 'center').
1441  * @return SimpleImage
1442  */
1443  public function thumbnail(int $width, int $height, string $anchor = 'center'): SimpleImage|static
1444  {
1445  // Determine aspect ratios
1446  $currentRatio = $this->getHeight() / $this->getWidth();
1447  $targetRatio = $height / $width;
1448 
1449  // Fit to height/width
1450  if ($targetRatio > $currentRatio) {
1451  $this->resize(null, $height);
1452  } else {
1453  $this->resize($width);
1454  }
1455 
1456  switch($anchor) {
1457  case 'top':
1458  $x1 = floor(($this->getWidth() / 2) - ($width / 2));
1459  $x2 = $width + $x1;
1460  $y1 = 0;
1461  $y2 = $height;
1462  break;
1463  case 'bottom':
1464  $x1 = floor(($this->getWidth() / 2) - ($width / 2));
1465  $x2 = $width + $x1;
1466  $y1 = $this->getHeight() - $height;
1467  $y2 = $this->getHeight();
1468  break;
1469  case 'left':
1470  $x1 = 0;
1471  $x2 = $width;
1472  $y1 = floor(($this->getHeight() / 2) - ($height / 2));
1473  $y2 = $height + $y1;
1474  break;
1475  case 'right':
1476  $x1 = $this->getWidth() - $width;
1477  $x2 = $this->getWidth();
1478  $y1 = floor(($this->getHeight() / 2) - ($height / 2));
1479  $y2 = $height + $y1;
1480  break;
1481  case 'top left':
1482  $x1 = 0;
1483  $x2 = $width;
1484  $y1 = 0;
1485  $y2 = $height;
1486  break;
1487  case 'top right':
1488  $x1 = $this->getWidth() - $width;
1489  $x2 = $this->getWidth();
1490  $y1 = 0;
1491  $y2 = $height;
1492  break;
1493  case 'bottom left':
1494  $x1 = 0;
1495  $x2 = $width;
1496  $y1 = $this->getHeight() - $height;
1497  $y2 = $this->getHeight();
1498  break;
1499  case 'bottom right':
1500  $x1 = $this->getWidth() - $width;
1501  $x2 = $this->getWidth();
1502  $y1 = $this->getHeight() - $height;
1503  $y2 = $this->getHeight();
1504  break;
1505  default:
1506  $x1 = floor(($this->getWidth() / 2) - ($width / 2));
1507  $x2 = $width + $x1;
1508  $y1 = floor(($this->getHeight() / 2) - ($height / 2));
1509  $y2 = $height + $y1;
1510  break;
1511  }
1512 
1513  // Return the cropped thumbnail image
1514  return $this->crop($x1, $y1, $x2, $y2);
1515  }
1516 
1517  //////////////////////////////////////////////////////////////////////////////////////////////////
1518  // Drawing
1519  //////////////////////////////////////////////////////////////////////////////////////////////////
1520 
1521  /**
1522  * Draws an arc.
1523  *
1524  * @param int $x The x coordinate of the arc's center.
1525  * @param int $y The y coordinate of the arc's center.
1526  * @param int $width The width of the arc.
1527  * @param int $height The height of the arc.
1528  * @param int $start The start of the arc in degrees.
1529  * @param int $end The end of the arc in degrees.
1530  * @param string|array $color The arc color.
1531  * @param int|string $thickness Line thickness in pixels or 'filled' (default 1).
1532  * @return SimpleImage
1533  *
1534  * @throws Exception
1535  */
1536  public function arc(int $x, int $y, int $width, int $height, int $start, int $end, string|array $color, int|string $thickness = 1): static
1537  {
1538  // Allocate the color
1539  $tempColor = $this->allocateColor($color);
1540  imagesetthickness($this->image, 1);
1541 
1542  // Draw an arc
1543  if ($thickness === 'filled') {
1544  imagefilledarc($this->image, $x, $y, $width, $height, $start, $end, $tempColor, IMG_ARC_PIE);
1545  } elseif ($thickness === 1) {
1546  imagearc($this->image, $x, $y, $width, $height, $start, $end, $tempColor);
1547  } else {
1548  // New temp image
1549  $tempImage = new SimpleImage();
1550  $tempImage->fromNew($this->getWidth(), $this->getHeight());
1551 
1552  // Draw a large ellipse filled with $color (+$thickness pixels)
1553  $tempColor = $tempImage->allocateColor($color);
1554  imagefilledarc($tempImage->image, $x, $y, $width + $thickness, $height + $thickness, $start, $end, $tempColor, IMG_ARC_PIE);
1555 
1556  // Draw a smaller ellipse filled with red|blue (-$thickness pixels)
1557  $tempColor = (self::normalizeColor($color)['red'] == 255) ? 'blue' : 'red';
1558  $tempColor = $tempImage->allocateColor($tempColor);
1559  imagefilledarc($tempImage->image, $x, $y, $width - $thickness, $height - $thickness, $start, $end, $tempColor, IMG_ARC_PIE);
1560 
1561  // Replace the color of the smaller ellipse with 'transparent'
1562  $tempImage->excludeInsideColor($x, $y, $color);
1563 
1564  // Apply the temp image
1565  $this->overlay($tempImage);
1566  }
1567 
1568  return $this;
1569  }
1570 
1571  /**
1572  * Draws a border around the image.
1573  *
1574  * @param string|array $color The border color.
1575  * @param int $thickness The thickness of the border (default 1).
1576  * @return SimpleImage
1577  *
1578  * @throws Exception
1579  */
1580  public function border(string|array $color, int $thickness = 1): static
1581  {
1582  $x1 = -1;
1583  $y1 = 0;
1584  $x2 = $this->getWidth();
1585  $y2 = $this->getHeight() - 1;
1586 
1587  $color = $this->allocateColor($color);
1588  imagesetthickness($this->image, $thickness * 2);
1589  imagerectangle($this->image, $x1, $y1, $x2, $y2, $color);
1590 
1591  return $this;
1592  }
1593 
1594  /**
1595  * Draws a single pixel dot.
1596  *
1597  * @param int $x The x coordinate of the dot.
1598  * @param int $y The y coordinate of the dot.
1599  * @param string|array $color The dot color.
1600  * @return SimpleImage
1601  *
1602  * @throws Exception
1603  */
1604  public function dot(int $x, int $y, string|array $color): static
1605  {
1606  $color = $this->allocateColor($color);
1607  imagesetpixel($this->image, $x, $y, $color);
1608 
1609  return $this;
1610  }
1611 
1612  /**
1613  * Draws an ellipse.
1614  *
1615  * @param int $x The x coordinate of the center.
1616  * @param int $y The y coordinate of the center.
1617  * @param int $width The ellipse width.
1618  * @param int $height The ellipse height.
1619  * @param string|array $color The ellipse color.
1620  * @param int|array $thickness Line thickness in pixels or 'filled' (default 1).
1621  * @return SimpleImage
1622  *
1623  * @throws Exception
1624  */
1625  public function ellipse(int $x, int $y, int $width, int $height, string|array $color, int|array $thickness = 1): static
1626  {
1627  // Allocate the color
1628  $tempColor = $this->allocateColor($color);
1629  imagesetthickness($this->image, 1);
1630 
1631  // Draw an ellipse
1632  if ($thickness == 'filled') {
1633  imagefilledellipse($this->image, $x, $y, $width, $height, $tempColor);
1634  } elseif ($thickness === 1) {
1635  imageellipse($this->image, $x, $y, $width, $height, $tempColor);
1636  } else {
1637  // New temp image
1638  $tempImage = new SimpleImage();
1639  $tempImage->fromNew($this->getWidth(), $this->getHeight());
1640 
1641  // Draw a large ellipse filled with $color (+$thickness pixels)
1642  $tempColor = $tempImage->allocateColor($color);
1643  imagefilledellipse($tempImage->image, $x, $y, $width + $thickness, $height + $thickness, $tempColor);
1644 
1645  // Draw a smaller ellipse filled with red|blue (-$thickness pixels)
1646  $tempColor = (self::normalizeColor($color)['red'] == 255) ? 'blue' : 'red';
1647  $tempColor = $tempImage->allocateColor($tempColor);
1648  imagefilledellipse($tempImage->image, $x, $y, $width - $thickness, $height - $thickness, $tempColor);
1649 
1650  // Replace the color of the smaller ellipse with 'transparent'
1651  $tempImage->excludeInsideColor($x, $y, $color);
1652 
1653  // Apply the temp image
1654  $this->overlay($tempImage);
1655  }
1656 
1657  return $this;
1658  }
1659 
1660  /**
1661  * Fills the image with a solid color.
1662  *
1663  * @param string|array $color The fill color.
1664  * @return SimpleImage
1665  *
1666  * @throws Exception
1667  */
1668  public function fill(string|array $color): static
1669  {
1670  // Draw a filled rectangle over the entire image
1671  $this->rectangle(0, 0, $this->getWidth(), $this->getHeight(), 'white', 'filled');
1672 
1673  // Now flood it with the appropriate color
1674  $color = $this->allocateColor($color);
1675  imagefill($this->image, 0, 0, $color);
1676 
1677  return $this;
1678  }
1679 
1680  /**
1681  * Draws a line.
1682  *
1683  * @param int $x1 The x coordinate for the first point.
1684  * @param int $y1 The y coordinate for the first point.
1685  * @param int $x2 The x coordinate for the second point.
1686  * @param int $y2 The y coordinate for the second point.
1687  * @param string|array $color The line color.
1688  * @param int $thickness The line thickness (default 1).
1689  * @return SimpleImage
1690  *
1691  * @throws Exception
1692  */
1693  public function line(int $x1, int $y1, int $x2, int $y2, string|array $color, int $thickness = 1): static
1694  {
1695  // Allocate the color
1696  $color = $this->allocateColor($color);
1697 
1698  // Draw a line
1699  imagesetthickness($this->image, $thickness);
1700  imageline($this->image, $x1, $y1, $x2, $y2, $color);
1701 
1702  return $this;
1703  }
1704 
1705  /**
1706  * Draws a polygon.
1707  *
1708  * @param array $vertices
1709  * The polygon's vertices in an array of x/y arrays.
1710  * Example:
1711  * [
1712  * ['x' => x1, 'y' => y1],
1713  * ['x' => x2, 'y' => y2],
1714  * ['x' => xN, 'y' => yN]
1715  * ]
1716  * @param string|array $color The polygon color.
1717  * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1).
1718  * @return SimpleImage
1719  *
1720  * @throws Exception
1721  */
1722  public function polygon(array $vertices, string|array $color, string|int|array $thickness = 1): static
1723  {
1724  // Allocate the color
1725  $color = $this->allocateColor($color);
1726 
1727  // Convert [['x' => x1, 'y' => x1], ['x' => x1, 'y' => y2], ...] to [x1, y1, x2, y2, ...]
1728  $points = [];
1729  foreach ($vertices as $vals) {
1730  $points[] = $vals['x'];
1731  $points[] = $vals['y'];
1732  }
1733 
1734  // Draw a polygon
1735  if ($thickness == 'filled') {
1736  imagesetthickness($this->image, 1);
1737  imagefilledpolygon($this->image, $points, count($vertices), $color);
1738  } else {
1739  imagesetthickness($this->image, $thickness);
1740  imagepolygon($this->image, $points, count($vertices), $color);
1741  }
1742 
1743  return $this;
1744  }
1745 
1746  /**
1747  * Draws a rectangle.
1748  *
1749  * @param int $x1 The upper left x coordinate.
1750  * @param int $y1 The upper left y coordinate.
1751  * @param int $x2 The bottom right x coordinate.
1752  * @param int $y2 The bottom right y coordinate.
1753  * @param string|array $color The rectangle color.
1754  * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1).
1755  * @return SimpleImage
1756  *
1757  * @throws Exception
1758  */
1759  public function rectangle(int $x1, int $y1, int $x2, int $y2, string|array $color, string|int|array $thickness = 1): static
1760  {
1761  // Allocate the color
1762  $color = $this->allocateColor($color);
1763 
1764  // Draw a rectangle
1765  if ($thickness == 'filled') {
1766  imagesetthickness($this->image, 1);
1767  imagefilledrectangle($this->image, $x1, $y1, $x2, $y2, $color);
1768  } else {
1769  imagesetthickness($this->image, $thickness);
1770  imagerectangle($this->image, $x1, $y1, $x2, $y2, $color);
1771  }
1772 
1773  return $this;
1774  }
1775 
1776  /**
1777  * Draws a rounded rectangle.
1778  *
1779  * @param int $x1 The upper left x coordinate.
1780  * @param int $y1 The upper left y coordinate.
1781  * @param int $x2 The bottom right x coordinate.
1782  * @param int $y2 The bottom right y coordinate.
1783  * @param int $radius The border radius in pixels.
1784  * @param string|array $color The rectangle color.
1785  * @param string|int|array $thickness Line thickness in pixels or 'filled' (default 1).
1786  * @return SimpleImage
1787  *
1788  * @throws Exception
1789  */
1790  public function roundedRectangle(int $x1, int $y1, int $x2, int $y2, int $radius, string|array $color, string|int|array $thickness = 1): static
1791  {
1792  if ($thickness == 'filled') {
1793  // Draw the filled rectangle without edges
1794  $this->rectangle($x1 + $radius + 1, $y1, $x2 - $radius - 1, $y2, $color, 'filled');
1795  $this->rectangle($x1, $y1 + $radius + 1, $x1 + $radius, $y2 - $radius - 1, $color, 'filled');
1796  $this->rectangle($x2 - $radius, $y1 + $radius + 1, $x2, $y2 - $radius - 1, $color, 'filled');
1797 
1798  // Fill in the edges with arcs
1799  $this->arc($x1 + $radius, $y1 + $radius, $radius * 2, $radius * 2, 180, 270, $color, 'filled');
1800  $this->arc($x2 - $radius, $y1 + $radius, $radius * 2, $radius * 2, 270, 360, $color, 'filled');
1801  $this->arc($x1 + $radius, $y2 - $radius, $radius * 2, $radius * 2, 90, 180, $color, 'filled');
1802  $this->arc($x2 - $radius, $y2 - $radius, $radius * 2, $radius * 2, 360, 90, $color, 'filled');
1803  } else {
1804  $offset = $thickness / 2;
1805  $x1 -= $offset;
1806  $x2 += $offset;
1807  $y1 -= $offset;
1808  $y2 += $offset;
1809  $radius = self::keepWithin($radius, 0, min(($x2 - $x1) / 2, ($y2 - $y1) / 2) - 1);
1810  $radius = (int) floor($radius);
1811  $thickness = self::keepWithin($thickness, 1, min(($x2 - $x1) / 2, ($y2 - $y1) / 2));
1812 
1813  // New temp image
1814  $tempImage = new SimpleImage();
1815  $tempImage->fromNew($this->getWidth(), $this->getHeight());
1816 
1817  // Draw a large rectangle filled with $color
1818  $tempImage->roundedRectangle($x1, $y1, $x2, $y2, $radius, $color, 'filled');
1819 
1820  // Draw a smaller rectangle filled with red|blue (-$thickness pixels on each side)
1821  $tempColor = (self::normalizeColor($color)['red'] == 255) ? 'blue' : 'red';
1822  $radius = $radius - $thickness;
1823  $radius = self::keepWithin($radius, 0, $radius);
1824  $tempImage->roundedRectangle(
1825  $x1 + $thickness,
1826  $y1 + $thickness,
1827  $x2 - $thickness,
1828  $y2 - $thickness,
1829  $radius,
1830  $tempColor,
1831  'filled'
1832  );
1833 
1834  // Replace the color of the smaller rectangle with 'transparent'
1835  $tempImage->excludeInsideColor(($x2 + $x1) / 2, ($y2 + $y1) / 2, $color);
1836 
1837  // Apply the temp image
1838  $this->overlay($tempImage);
1839  }
1840 
1841  return $this;
1842  }
1843 
1844  /**
1845  * Exclude inside color.
1846  * Used for roundedRectangle(), ellipse() and arc()
1847  *
1848  * @param int $x certer x of rectangle.
1849  * @param int $y certer y of rectangle.
1850  * @param string|array $borderColor The color of border.
1851  *
1852  * @throws Exception
1853  */
1854  private function excludeInsideColor(int $x, int $y, string|array $borderColor): static
1855  {
1856  $borderColor = $this->allocateColor($borderColor);
1857  $transparent = $this->allocateColor('transparent');
1858  imagefilltoborder($this->image, $x, $y, $borderColor, $transparent);
1859 
1860  return $this;
1861  }
1862 
1863  //////////////////////////////////////////////////////////////////////////////////////////////////
1864  // Filters
1865  //////////////////////////////////////////////////////////////////////////////////////////////////
1866 
1867  /**
1868  * Applies the blur filter.
1869  *
1870  * @param string $type The blur algorithm to use: 'selective', 'gaussian' (default 'gaussian').
1871  * @param int $passes The number of time to apply the filter, enhancing the effect (default 1).
1872  * @return SimpleImage
1873  */
1874  public function blur(string $type = 'selective', int $passes = 1): static
1875  {
1876  $filter = $type === 'gaussian' ? IMG_FILTER_GAUSSIAN_BLUR : IMG_FILTER_SELECTIVE_BLUR;
1877 
1878  for ($i = 0; $i < $passes; $i++) {
1879  imagefilter($this->image, $filter);
1880  }
1881 
1882  return $this;
1883  }
1884 
1885  /**
1886  * Applies the brightness filter to brighten the image.
1887  *
1888  * @param int $percentage Percentage to brighten the image (0 - 100).
1889  * @return SimpleImage
1890  */
1891  public function brighten(int $percentage): static
1892  {
1893  $percentage = self::keepWithin(255 * $percentage / 100, 0, 255);
1894 
1895  imagefilter($this->image, IMG_FILTER_BRIGHTNESS, $percentage);
1896 
1897  return $this;
1898  }
1899 
1900  /**
1901  * Applies the colorize filter.
1902  *
1903  * @param string|array $color The filter color.
1904  * @return SimpleImage
1905  *
1906  * @throws Exception
1907  */
1908  public function colorize(string|array $color): static
1909  {
1910  $color = self::normalizeColor($color);
1911 
1912  imagefilter(
1913  $this->image,
1914  IMG_FILTER_COLORIZE,
1915  $color['red'],
1916  $color['green'],
1917  $color['blue'],
1918  127 - ($color['alpha'] * 127)
1919  );
1920 
1921  return $this;
1922  }
1923 
1924  /**
1925  * Applies the contrast filter.
1926  *
1927  * @param int $percentage Percentage to adjust (-100 - 100).
1928  * @return SimpleImage
1929  */
1930  public function contrast(int $percentage): static
1931  {
1932  imagefilter($this->image, IMG_FILTER_CONTRAST, self::keepWithin($percentage, -100, 100));
1933 
1934  return $this;
1935  }
1936 
1937  /**
1938  * Applies the brightness filter to darken the image.
1939  *
1940  * @param int $percentage Percentage to darken the image (0 - 100).
1941  * @return SimpleImage
1942  */
1943  public function darken(int $percentage): static
1944  {
1945  $percentage = self::keepWithin(255 * $percentage / 100, 0, 255);
1946 
1947  imagefilter($this->image, IMG_FILTER_BRIGHTNESS, -$percentage);
1948 
1949  return $this;
1950  }
1951 
1952  /**
1953  * Applies the desaturate (grayscale) filter.
1954  *
1955  * @return SimpleImage
1956  */
1957  public function desaturate(): static
1958  {
1959  imagefilter($this->image, IMG_FILTER_GRAYSCALE);
1960 
1961  return $this;
1962  }
1963 
1964  /**
1965  * Applies the edge detect filter.
1966  *
1967  * @return SimpleImage
1968  */
1969  public function edgeDetect(): static
1970  {
1971  imagefilter($this->image, IMG_FILTER_EDGEDETECT);
1972 
1973  return $this;
1974  }
1975 
1976  /**
1977  * Applies the emboss filter.
1978  *
1979  * @return SimpleImage
1980  */
1981  public function emboss(): static
1982  {
1983  imagefilter($this->image, IMG_FILTER_EMBOSS);
1984 
1985  return $this;
1986  }
1987 
1988  /**
1989  * Inverts the image's colors.
1990  *
1991  * @return SimpleImage
1992  */
1993  public function invert(): static
1994  {
1995  imagefilter($this->image, IMG_FILTER_NEGATE);
1996 
1997  return $this;
1998  }
1999 
2000  /**
2001  * Changes the image's opacity level.
2002  *
2003  * @param float $opacity The desired opacity level (0 - 1).
2004  * @return SimpleImage
2005  *
2006  * @throws Exception
2007  */
2008  public function opacity(float $opacity): static
2009  {
2010  // Create a transparent image
2011  $newImage = new SimpleImage();
2012  $newImage->fromNew($this->getWidth(), $this->getHeight());
2013 
2014  // Copy the current image (with opacity) onto the transparent image
2015  self::imageCopyMergeAlpha(
2016  $newImage->image,
2017  $this->image,
2018  0, 0,
2019  0, 0,
2020  $this->getWidth(),
2021  $this->getHeight(),
2022  (int) round(self::keepWithin($opacity, 0, 1) * 100)
2023  );
2024 
2025  return $this;
2026  }
2027 
2028  /**
2029  * Applies the pixelate filter.
2030  *
2031  * @param int $size The size of the blocks in pixels (default 10).
2032  * @return SimpleImage
2033  */
2034  public function pixelate(int $size = 10): static
2035  {
2036  imagefilter($this->image, IMG_FILTER_PIXELATE, $size, true);
2037 
2038  return $this;
2039  }
2040 
2041  /**
2042  * Simulates a sepia effect by desaturating the image and applying a sepia tone.
2043  *
2044  * @return SimpleImage
2045  */
2046  public function sepia(): static
2047  {
2048  imagefilter($this->image, IMG_FILTER_GRAYSCALE);
2049  imagefilter($this->image, IMG_FILTER_COLORIZE, 70, 35, 0);
2050 
2051  return $this;
2052  }
2053 
2054  /**
2055  * Sharpens the image.
2056  *
2057  * @param int $amount Sharpening amount (default 50).
2058  * @return SimpleImage
2059  */
2060  public function sharpen(int $amount = 50): static
2061  {
2062  // Normalize amount
2063  $amount = max(1, min(100, $amount)) / 100;
2064 
2065  $sharpen = [
2066  [-1, -1, -1],
2067  [-1, 8 / $amount, -1],
2068  [-1, -1, -1],
2069  ];
2070  $divisor = array_sum(array_map('array_sum', $sharpen));
2071 
2072  imageconvolution($this->image, $sharpen, $divisor, 0);
2073 
2074  return $this;
2075  }
2076 
2077  /**
2078  * Applies the mean remove filter to produce a sketch effect.
2079  *
2080  * @return SimpleImage
2081  */
2082  public function sketch(): static
2083  {
2084  imagefilter($this->image, IMG_FILTER_MEAN_REMOVAL);
2085 
2086  return $this;
2087  }
2088 
2089  //////////////////////////////////////////////////////////////////////////////////////////////////
2090  // Color utilities
2091  //////////////////////////////////////////////////////////////////////////////////////////////////
2092 
2093  /**
2094  * Converts a "friendly color" into a color identifier for use with GD's image functions.
2095  *
2096  * @param string|array $color The color to allocate.
2097  *
2098  * @throws Exception
2099  */
2100  protected function allocateColor(string|array $color): int
2101  {
2102  $color = self::normalizeColor($color);
2103 
2104  // Was this color already allocated?
2105  $index = imagecolorexactalpha(
2106  $this->image,
2107  $color['red'],
2108  $color['green'],
2109  $color['blue'],
2110  (int) (127 - ($color['alpha'] * 127))
2111  );
2112  if ($index > -1) {
2113  // Yes, return this color index
2114  return $index;
2115  }
2116 
2117  // Allocate a new color index
2118  return imagecolorallocatealpha(
2119  $this->image,
2120  $color['red'],
2121  $color['green'],
2122  $color['blue'],
2123  127 - ($color['alpha'] * 127)
2124  );
2125  }
2126 
2127  /**
2128  * Adjusts a color by increasing/decreasing red/green/blue/alpha values independently.
2129  *
2130  * @param string|array $color The color to adjust.
2131  * @param int $red Red adjustment (-255 - 255).
2132  * @param int $green Green adjustment (-255 - 255).
2133  * @param int $blue Blue adjustment (-255 - 255).
2134  * @param int $alpha Alpha adjustment (-1 - 1).
2135  * @return int[] An RGBA color array.
2136  *
2137  * @throws Exception
2138  */
2139  public static function adjustColor(string|array $color, int $red, int $green, int $blue, int $alpha): array
2140  {
2141  // Normalize to RGBA
2142  $color = self::normalizeColor($color);
2143 
2144  // Adjust each channel
2145  return self::normalizeColor([
2146  'red' => $color['red'] + $red,
2147  'green' => $color['green'] + $green,
2148  'blue' => $color['blue'] + $blue,
2149  'alpha' => $color['alpha'] + $alpha,
2150  ]);
2151  }
2152 
2153  /**
2154  * Darkens a color.
2155  *
2156  * @param string|array $color The color to darken.
2157  * @param int $amount Amount to darken (0 - 255).
2158  * @return int[] An RGBA color array.
2159  *
2160  * @throws Exception
2161  */
2162  public static function darkenColor(string|array $color, int $amount): array
2163  {
2164  return self::adjustColor($color, -$amount, -$amount, -$amount, 0);
2165  }
2166 
2167  /**
2168  * Extracts colors from an image like a human would do.â„¢ This method requires the third-party
2169  * library \League\ColorExtractor. If you're using Composer, it will be installed for you
2170  * automatically.
2171  *
2172  * @param int $count The max number of colors to extract (default 5).
2173  * @param string|array|null $backgroundColor
2174  * By default any pixel with alpha value greater than zero will
2175  * be discarded. This is because transparent colors are not perceived as is. For example, fully
2176  * transparent black would be seen white on a white background. So if you want to take
2177  * transparency into account, you have to specify a default background color.
2178  * @return int[] An array of RGBA colors arrays.
2179  *
2180  * @throws Exception Thrown if library \League\ColorExtractor is missing.
2181  */
2182  public function extractColors(int $count = 5, string|array $backgroundColor = null): array
2183  {
2184  // Check for required library
2185  if (! class_exists('\\'.ColorExtractor::class)) {
2186  throw new Exception(
2187  'Required library \League\ColorExtractor is missing.',
2188  self::ERR_LIB_NOT_LOADED
2189  );
2190  }
2191 
2192  // Convert background color to an integer value
2193  if ($backgroundColor) {
2194  $backgroundColor = self::normalizeColor($backgroundColor);
2195  $backgroundColor = Color::fromRgbToInt([
2196  'r' => $backgroundColor['red'],
2197  'g' => $backgroundColor['green'],
2198  'b' => $backgroundColor['blue'],
2199  ]);
2200  }
2201 
2202  // Extract colors from the image
2203  $palette = Palette::fromGD($this->image, $backgroundColor);
2204  $extractor = new ColorExtractor($palette);
2205  $colors = $extractor->extract($count);
2206 
2207  // Convert colors to an RGBA color array
2208  foreach ($colors as $key => $value) {
2209  $colors[$key] = self::normalizeColor(Color::fromIntToHex($value));
2210  }
2211 
2212  return $colors;
2213  }
2214 
2215  /**
2216  * Gets the RGBA value of a single pixel.
2217  *
2218  * @param int $x The horizontal position of the pixel.
2219  * @param int $y The vertical position of the pixel.
2220  * @return bool|int[] An RGBA color array or false if the x/y position is off the canvas.
2221  */
2222  public function getColorAt(int $x, int $y): array|bool
2223  {
2224  // Coordinates must be on the canvas
2225  if ($x < 0 || $x > $this->getWidth() || $y < 0 || $y > $this->getHeight()) {
2226  return false;
2227  }
2228 
2229  // Get the color of this pixel and convert it to RGBA
2230  $color = imagecolorat($this->image, $x, $y);
2231  $rgba = imagecolorsforindex($this->image, $color);
2232  $rgba['alpha'] = 127 - ($color >> 24) & 0xFF;
2233 
2234  return $rgba;
2235  }
2236 
2237  /**
2238  * Lightens a color.
2239  *
2240  * @param string|array $color The color to lighten.
2241  * @param int $amount Amount to lighten (0 - 255).
2242  * @return int[] An RGBA color array.
2243  *
2244  * @throws Exception
2245  */
2246  public static function lightenColor(string|array $color, int $amount): array
2247  {
2248  return self::adjustColor($color, $amount, $amount, $amount, 0);
2249  }
2250 
2251  /**
2252  * Normalizes a hex or array color value to a well-formatted RGBA array.
2253  *
2254  * @param string|array $color
2255  * A CSS color name, hex string, or an array [red, green, blue, alpha].
2256  * You can pipe alpha transparency through hex strings and color names. For example:
2257  * #fff|0.50 <-- 50% white
2258  * red|0.25 <-- 25% red
2259  * @return array [red, green, blue, alpha].
2260  *
2261  * @throws Exception Thrown if color value is invalid.
2262  */
2263  public static function normalizeColor(string|array $color): array
2264  {
2265  // 140 CSS color names and hex values
2266  $cssColors = [
2267  'aliceblue' => '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff',
2268  'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', 'beige' => '#f5f5dc', 'bisque' => '#ffe4c4',
2269  'black' => '#000000', 'blanchedalmond' => '#ffebcd', 'blue' => '#0000ff',
2270  'blueviolet' => '#8a2be2', 'brown' => '#a52a2a', 'burlywood' => '#deb887',
2271  'cadetblue' => '#5f9ea0', 'chartreuse' => '#7fff00', 'chocolate' => '#d2691e',
2272  'coral' => '#ff7f50', 'cornflowerblue' => '#6495ed', 'cornsilk' => '#fff8dc',
2273  'crimson' => '#dc143c', 'cyan' => '#00ffff', 'darkblue' => '#00008b', 'darkcyan' => '#008b8b',
2274  'darkgoldenrod' => '#b8860b', 'darkgray' => '#a9a9a9', 'darkgrey' => '#a9a9a9',
2275  'darkgreen' => '#006400', 'darkkhaki' => '#bdb76b', 'darkmagenta' => '#8b008b',
2276  'darkolivegreen' => '#556b2f', 'darkorange' => '#ff8c00', 'darkorchid' => '#9932cc',
2277  'darkred' => '#8b0000', 'darksalmon' => '#e9967a', 'darkseagreen' => '#8fbc8f',
2278  'darkslateblue' => '#483d8b', 'darkslategray' => '#2f4f4f', 'darkslategrey' => '#2f4f4f',
2279  'darkturquoise' => '#00ced1', 'darkviolet' => '#9400d3', 'deeppink' => '#ff1493',
2280  'deepskyblue' => '#00bfff', 'dimgray' => '#696969', 'dimgrey' => '#696969',
2281  'dodgerblue' => '#1e90ff', 'firebrick' => '#b22222', 'floralwhite' => '#fffaf0',
2282  'forestgreen' => '#228b22', 'fuchsia' => '#ff00ff', 'gainsboro' => '#dcdcdc',
2283  'ghostwhite' => '#f8f8ff', 'gold' => '#ffd700', 'goldenrod' => '#daa520', 'gray' => '#808080',
2284  'grey' => '#808080', 'green' => '#008000', 'greenyellow' => '#adff2f',
2285  'honeydew' => '#f0fff0', 'hotpink' => '#ff69b4', 'indianred ' => '#cd5c5c',
2286  'indigo ' => '#4b0082', 'ivory' => '#fffff0', 'khaki' => '#f0e68c', 'lavender' => '#e6e6fa',
2287  'lavenderblush' => '#fff0f5', 'lawngreen' => '#7cfc00', 'lemonchiffon' => '#fffacd',
2288  'lightblue' => '#add8e6', 'lightcoral' => '#f08080', 'lightcyan' => '#e0ffff',
2289  'lightgoldenrodyellow' => '#fafad2', 'lightgray' => '#d3d3d3', 'lightgrey' => '#d3d3d3',
2290  'lightgreen' => '#90ee90', 'lightpink' => '#ffb6c1', 'lightsalmon' => '#ffa07a',
2291  'lightseagreen' => '#20b2aa', 'lightskyblue' => '#87cefa', 'lightslategray' => '#778899',
2292  'lightslategrey' => '#778899', 'lightsteelblue' => '#b0c4de', 'lightyellow' => '#ffffe0',
2293  'lime' => '#00ff00', 'limegreen' => '#32cd32', 'linen' => '#faf0e6', 'magenta' => '#ff00ff',
2294  'maroon' => '#800000', 'mediumaquamarine' => '#66cdaa', 'mediumblue' => '#0000cd',
2295  'mediumorchid' => '#ba55d3', 'mediumpurple' => '#9370db', 'mediumseagreen' => '#3cb371',
2296  'mediumslateblue' => '#7b68ee', 'mediumspringgreen' => '#00fa9a',
2297  'mediumturquoise' => '#48d1cc', 'mediumvioletred' => '#c71585', 'midnightblue' => '#191970',
2298  'mintcream' => '#f5fffa', 'mistyrose' => '#ffe4e1', 'moccasin' => '#ffe4b5',
2299  'navajowhite' => '#ffdead', 'navy' => '#000080', 'oldlace' => '#fdf5e6', 'olive' => '#808000',
2300  'olivedrab' => '#6b8e23', 'orange' => '#ffa500', 'orangered' => '#ff4500',
2301  'orchid' => '#da70d6', 'palegoldenrod' => '#eee8aa', 'palegreen' => '#98fb98',
2302  'paleturquoise' => '#afeeee', 'palevioletred' => '#db7093', 'papayawhip' => '#ffefd5',
2303  'peachpuff' => '#ffdab9', 'peru' => '#cd853f', 'pink' => '#ffc0cb', 'plum' => '#dda0dd',
2304  'powderblue' => '#b0e0e6', 'purple' => '#800080', 'rebeccapurple' => '#663399',
2305  'red' => '#ff0000', 'rosybrown' => '#bc8f8f', 'royalblue' => '#4169e1',
2306  'saddlebrown' => '#8b4513', 'salmon' => '#fa8072', 'sandybrown' => '#f4a460',
2307  'seagreen' => '#2e8b57', 'seashell' => '#fff5ee', 'sienna' => '#a0522d',
2308  'silver' => '#c0c0c0', 'skyblue' => '#87ceeb', 'slateblue' => '#6a5acd',
2309  'slategray' => '#708090', 'slategrey' => '#708090', 'snow' => '#fffafa',
2310  'springgreen' => '#00ff7f', 'steelblue' => '#4682b4', 'tan' => '#d2b48c', 'teal' => '#008080',
2311  'thistle' => '#d8bfd8', 'tomato' => '#ff6347', 'turquoise' => '#40e0d0',
2312  'violet' => '#ee82ee', 'wheat' => '#f5deb3', 'white' => '#ffffff', 'whitesmoke' => '#f5f5f5',
2313  'yellow' => '#ffff00', 'yellowgreen' => '#9acd32',
2314  ];
2315 
2316  // Parse alpha from '#fff|.5' and 'white|.5'
2317  if (is_string($color) && strstr($color, '|')) {
2318  $color = explode('|', $color);
2319  $alpha = (float) $color[1];
2320  $color = trim($color[0]);
2321  } else {
2322  $alpha = 1;
2323  }
2324 
2325  // Translate CSS color names to hex values
2326  if (is_string($color) && array_key_exists(strtolower($color), $cssColors)) {
2327  $color = $cssColors[strtolower($color)];
2328  }
2329 
2330  // Translate transparent keyword to a transparent color
2331  if ($color === 'transparent') {
2332  $color = ['red' => 0, 'green' => 0, 'blue' => 0, 'alpha' => 0];
2333  }
2334 
2335  // Convert hex values to RGBA
2336  if (is_string($color)) {
2337  // Remove #
2338  $hex = strval(preg_replace('/^#/', '', $color));
2339 
2340  // Support short and standard hex codes
2341  if (strlen($hex) === 3) {
2342  [$red, $green, $blue] = [
2343  $hex[0].$hex[0],
2344  $hex[1].$hex[1],
2345  $hex[2].$hex[2],
2346  ];
2347  } elseif (strlen($hex) === 6) {
2348  [$red, $green, $blue] = [
2349  $hex[0].$hex[1],
2350  $hex[2].$hex[3],
2351  $hex[4].$hex[5],
2352  ];
2353  } else {
2354  throw new Exception("Invalid color value: $color", self::ERR_INVALID_COLOR);
2355  }
2356 
2357  // Turn color into an array
2358  $color = [
2359  'red' => hexdec($red),
2360  'green' => hexdec($green),
2361  'blue' => hexdec($blue),
2362  'alpha' => $alpha,
2363  ];
2364  }
2365 
2366  // Enforce color value ranges
2367  if (is_array($color)) {
2368  // RGB default to 0
2369  $color['red'] ??= 0;
2370  $color['green'] ??= 0;
2371  $color['blue'] ??= 0;
2372 
2373  // Alpha defaults to 1
2374  $color['alpha'] ??= 1;
2375 
2376  return [
2377  'red' => (int) self::keepWithin((int) $color['red'], 0, 255),
2378  'green' => (int) self::keepWithin((int) $color['green'], 0, 255),
2379  'blue' => (int) self::keepWithin((int) $color['blue'], 0, 255),
2380  'alpha' => self::keepWithin($color['alpha'], 0, 1),
2381  ];
2382  }
2383 
2384  throw new Exception("Invalid color value: $color", self::ERR_INVALID_COLOR);
2385  }
2386 }
$filename
Definition: actions.php:10
$data
Definition: stats.php:78
textSeparateLines(string $text, string $fontFile, int $fontSize, int $maxWidth)
roundedRectangle(int $x1, int $y1, int $x2, int $y2, int $radius, string|array $color, string|int|array $thickness=1)
static keepWithin(int|float $value, int|float $min, int|float $max)
text(string $text, array $options, array &$boundary=null)
flip(string $direction)
static imageCopyMergeAlpha($dstIm, $srcIm, int $dstX, int $dstY, int $srcX, int $srcY, int $srcW, int $srcH, int $pct)
toScreen(string $mimeType=null, array|int $options=100)
fromFile(string $file)
contrast(int $percentage)
resolution(int $res_x, int $res_y=null)
toString(string $mimeType=null, array|int $options=100)
generate(string $mimeType=null, array|int $options=[])
setFlag(string $flag, bool $value)
toFile(string $file, string $mimeType=null, array|int $options=100)
getColorAt(int $x, int $y)
ellipse(int $x, int $y, int $width, int $height, string|array $color, int|array $thickness=1)
toDownload(string $filename, string $mimeType=null, array|int $options=100)
pixelate(int $size=10)
blur(string $type='selective', int $passes=1)
excludeInsideColor(int $x, int $y, string|array $borderColor)
overlay(string|SimpleImage $overlay, string $anchor='center', float|int $opacity=1, int $xOffset=0, int $yOffset=0, bool $calculateOffsetFromEdge=false)
line(int $x1, int $y1, int $x2, int $y2, string|array $color, int $thickness=1)
bestFit(int $maxWidth, int $maxHeight)
crop(int|float $x1, int|float $y1, int|float $x2, int|float $y2)
darken(int $percentage)
static darkenColor(string|array $color, int $amount)
brighten(int $percentage)
static adjustColor(string|array $color, int $red, int $green, int $blue, int $alpha)
__construct(string $image='', array $flags=[])
Definition: SimpleImage.php:85
textBox(string $text, array $options)
opacity(float $opacity)
fromDataUri(string $uri)
rectangle(int $x1, int $y1, int $x2, int $y2, string|array $color, string|int|array $thickness=1)
rotate(int $angle, string|array $backgroundColor='transparent')
toDataUri(string $mimeType=null, array|int $options=100)
resize(int $width=null, int $height=null)
textSeparateWords(string $text)
border(string|array $color, int $thickness=1)
thumbnail(int $width, int $height, string $anchor='center')
static lightenColor(string|array $color, int $amount)
fromNew(int $width, int $height, string|array $color='transparent')
static normalizeColor(string|array $color)
duotone(string|array $lightColor, string|array $darkColor)
maxColors(int $max, bool $dither=true)
null array false $exif
Definition: SimpleImage.php:71
dot(int $x, int $y, string|array $color)
arc(int $x, int $y, int $width, int $height, int $start, int $end, string|array $color, int|string $thickness=1)
extractColors(int $count=5, string|array $backgroundColor=null)
fromString(string $string)
colorize(string|array $color)
getFlag(string $flag)
polygon(array $vertices, string|array $color, string|int|array $thickness=1)
fill(string|array $color)
fitToWidth(int $width)
sharpen(int $amount=50)
allocateColor(string|array $color)
$type
This class serves methods to create backup from files.
Definition: AdminLTE.php:2
$value
$i