├── LICENSE ├── README.md ├── composer.json └── src ├── Exception.php ├── Filter.php ├── Image.php └── Text.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 JBZoo Content Construction Kit (CCK) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JBZoo / Image 2 | 3 | [![CI](https://github.com/JBZoo/Image/actions/workflows/main.yml/badge.svg?branch=master)](https://github.com/JBZoo/Image/actions/workflows/main.yml?query=branch%3Amaster) [![Coverage Status](https://coveralls.io/repos/github/JBZoo/Image/badge.svg?branch=master)](https://coveralls.io/github/JBZoo/Image?branch=master) [![Psalm Coverage](https://shepherd.dev/github/JBZoo/Image/coverage.svg)](https://shepherd.dev/github/JBZoo/Image) [![Psalm Level](https://shepherd.dev/github/JBZoo/Image/level.svg)](https://shepherd.dev/github/JBZoo/Image) [![CodeFactor](https://www.codefactor.io/repository/github/jbzoo/image/badge)](https://www.codefactor.io/repository/github/jbzoo/image/issues) 4 | [![Stable Version](https://poser.pugx.org/jbzoo/image/version)](https://packagist.org/packages/jbzoo/image/) [![Total Downloads](https://poser.pugx.org/jbzoo/image/downloads)](https://packagist.org/packages/jbzoo/image/stats) [![Dependents](https://poser.pugx.org/jbzoo/image/dependents)](https://packagist.org/packages/jbzoo/image/dependents?order_by=downloads) [![GitHub License](https://img.shields.io/github/license/jbzoo/image)](https://github.com/JBZoo/Image/blob/master/LICENSE) 5 | 6 | 7 | 8 | Package provides object-oriented way to manipulate with images as simple as possible. 9 | 10 | 11 | ### Install 12 | ```sh 13 | composer require jbzoo/image 14 | ``` 15 | 16 | ### Example 17 | 18 | ```php 19 | use JBZoo\Image\Image; 20 | 21 | $img = (new Image('./example/source-image.jpg')) 22 | ->addFilter('flip', 'x') 23 | ->addFilter('text', 'Some text', './res/font.ttf') 24 | ->thumbnail(320, 240) 25 | ->saveAs('./example/dist-image.png'); 26 | ``` 27 | 28 | That block loads `source-image.jpg`, flip it horizontally, rotate it 90 degrees clockwise, 29 | shrink it to fit within a 320x240 box, apply a sepia effect, convert it to a PNG, and save it to `dist-image.png` with other format! 30 | 31 | 32 | With this class, you can effortlessly: 33 | * Resize images (free resize, resize to width, resize to height, resize to fit) 34 | * Crop images 35 | * Flip/rotate/adjust orientation 36 | * Adjust brightness & contrast 37 | * Desaturate, colorize, pixelate, blur, etc. 38 | * Overlay one image onto another (watermarking) 39 | * Add text using a font of your choice 40 | * Convert between GIF, JPEG, PNG and WEBP formats 41 | * Strip EXIF data (Just save it!) 42 | 43 | 44 | ### Usage 45 | ```php 46 | use JBZoo\Image\Image; 47 | use JBZoo\Image\Filter; 48 | use JBZoo\Image\Exception; 49 | 50 | try { // Error handling 51 | 52 | $img = (new Image('./some-path/image.jpg')) // You can load an image when you instantiate a new Image object 53 | ->loadFile('./some-path/another-path.jpg') // Load another file (replace internal state) 54 | 55 | // Saving 56 | ->save() // Images must be saved after you manipulate them. To save your changes to the original file. 57 | ->save(90) // Specify quality (0 to 100) 58 | 59 | // Save as new file 60 | ->saveAs('./some-path/new-image.jpg') // Alternatively, you can specify a new filename 61 | ->saveAs('./some-path/new-image.jpg', 90) // You can specify quality as a second parameter in percents within range 0-100 62 | ->saveAs('./some-path/new-image.png') // Or convert it into another format by extention (gif|jpeg|png|webp) 63 | 64 | // Resizing 65 | ->resize(320, 200) // Resize the image to 320x200 66 | ->thumbnail(100, 75) // Trim the image and resize to exactly 100x75 (crop CENTER if needed) 67 | ->thumbnail(100, 75, true) // Trim the image and resize to exactly 100x75 (crop TOP if needed) 68 | ->fitToWidth(320) // Shrink the image to the specified width while maintaining proportion (width) 69 | ->fitToHeight(200) // Shrink the image to the specified height while maintaining proportion (height) 70 | ->bestFit(500, 500) // Shrink the image proportionally to fit inside a 500x500 box 71 | ->crop(100, 100, 400, 400) // Crop a portion of the image from left, top, right, bottom 72 | 73 | // Filters 74 | ->addFilter('sepia') // Sepia effect (simulated) 75 | ->addFilter('grayscale') // Grayscale 76 | ->addFilter('desaturate', 50) // Desaturate 77 | ->addFilter('pixelate', 8) // Pixelate using 8px blocks 78 | ->addFilter('edges') // Edges filter 79 | ->addFilter('emboss') // Emboss filter 80 | ->addFilter('invert') // Invert colors 81 | ->addFilter('blur', Filter::BLUR_SEL) // Selective blur (one pass) 82 | ->addFilter('blur', Filter::BLUR_GAUS, 2) // Gaussian blur (two passes) 83 | ->addFilter('brightness', 100) // Adjust Brightness (-255 to 255) 84 | ->addFilter('contrast', 50) // Adjust Contrast (-100 to 100) 85 | ->addFilter('colorize', '#FF0000', .5) // Colorize red at 50% opacity 86 | ->addFilter('meanRemove') // Mean removal filter 87 | ->addFilter('smooth', 5) // Smooth filter (-10 to 10) 88 | ->addFilter('opacity', .5) // Change opacity 89 | ->addFilter('rotate', 90) // Rotate the image 90 degrees clockwise 90 | ->addFilter('flip', 'x') // Flip the image horizontally 91 | ->addFilter('flip', 'y') // Flip the image vertically 92 | ->addFilter('flip', 'xy') // Flip the image horizontally and vertically 93 | ->addFilter('fill', '#fff') // Fill image with white color 94 | 95 | // Custom filter handler 96 | ->addFilter(function ($image, $blockSize) { 97 | imagefilter($image, IMG_FILTER_PIXELATE, $blockSize, true); 98 | }, 2) // $blockSize = 2 99 | 100 | // Overlay watermark.png at 50% opacity at the bottom-right of the image with a 10 pixel horz and vert margin 101 | ->overlay('./image/watermark.png', 'bottom right', .5, -10, -10) 102 | 103 | // Other 104 | ->create(200, 100, '#000') // Create empty image 200x100 with black background 105 | ->setQuality(95) // Set new internal quality state 106 | ->autoOrient() // Adjust the orientation if needed (physically rotates/flips the image based on its EXIF 'Orientation' property) 107 | ; 108 | 109 | } catch(Exception $e) { 110 | echo 'Error: ' . $e->getMessage(); 111 | } 112 | 113 | ``` 114 | 115 | 116 | ### Methods to create Image objects 117 | ```php 118 | // Filename 119 | $img = new Image('./path/to/image.png'); 120 | 121 | // Base64 format 122 | $img = new Image('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='); 123 | 124 | // Image string 125 | $img = new Image('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='); 126 | 127 | // Some binary data 128 | $imgBin = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='); 129 | $img = new Image($imgBin); 130 | 131 | // Resource 132 | $imgRes = imagecreatefromjpeg('./some-image.jpeg'); 133 | $img = new Image($imgRes); 134 | ``` 135 | 136 | 137 | ### Other utility methods 138 | ```php 139 | $img = new Image($_SERVER['DOCUMENT_ROOT'] . '/resources/butterfly.jpg'); 140 | 141 | $img->getBase64(); // Get base64 as string (format from inner state) 142 | $img->getBase64('gif'); // Convert to GIF and get base64 as string 143 | $img->getBase64('jpeg', 85); // Convert to JPEG (q=85%) and get base64 as string 144 | $img->getBase64('png', 100, false); // Get only base64 without mime header 145 | 146 | $img->getBinary(); // Get clean binary data (format from inner state) 147 | $img->getBinary('jpeg', 85); // Binary in JPEG format with quality 85% 148 | 149 | $img->getHeight(); // Height in px 150 | $img->getWidth(); // Width in px 151 | $img->cleanup(); // Full cleanup of internal state of object 152 | $img->getImage(); // Get GD Image resource 153 | 154 | $img->isGif(); // Check format 155 | $img->isJpeg(); // Check format 156 | $img->isPng(); // Check format 157 | 158 | $img->isPortrait(); // Check orientation 159 | $img->isLandscape(); // Check orientation 160 | $img->isSquare(); // Check orientation 161 | 162 | $img->getUrl(); // Get full url to image - http://site.com/resources/butterfly.jpg 163 | $img->getPath(); // Get relative url to image - /resources/butterfly.jpg 164 | 165 | $imgInfo = $img->getInfo(); // Get array of all properties 166 | 167 | // It will be something like that ... 168 | $imgInfo = [ 169 | "filename" => "//resources/butterfly.jpg", 170 | "width" => 640, 171 | "height" => 478, 172 | "mime" => "image/jpeg", 173 | "quality" => 95, 174 | "exif" => [ 175 | "FileName" => "butterfly.jpg", 176 | "FileDateTime" => 1454653291, 177 | "FileSize" => 280448, 178 | "FileType" => 2, 179 | "MimeType" => "image/jpeg", 180 | "SectionsFound" => "", 181 | "COMPUTED" => [ 182 | "html" => 'width="640" height="478"', 183 | "Height" => 478, 184 | "Width" => 640, 185 | "IsColor" => 1, 186 | ], 187 | ], 188 | "orient" => "landscape", 189 | ]; 190 | ``` 191 | 192 | 193 | ### Add text on image (filter) 194 | ```php 195 | $img = new Image('./resources/butterfly.jpg'); 196 | $img->addFilter( 197 | 'text', // Filter name 198 | 'Some image description', // Text to render on image 199 | './resources/font.ttf' // TTF font file 200 | [ // Additionals params 201 | 'font-size' => 48, // Font size in px 202 | 'color' => array('#ff7f00', '#f00'), // Or one color as string 203 | 204 | // Stroke 205 | 'stroke-color' => array('#f00', '#ff7f00'), // Or one color as string 206 | 'stroke-size' => 3, // Stroke size in px 207 | 'stroke-spacing' => 5, // Letter spacing in px (only for stroke mode) 208 | 209 | // Position of text 210 | 'offset-x' => -140, // X offset in px 211 | 'offset-y' => 100, // Y offset in px 212 | 'position' => 't', // top|t|Helper::TOP| ... More details in the method Helper::position() 213 | 214 | // Experimental 215 | 'angle' => 0, // Angle for each letter 216 | ]) 217 | ->saveAs('./dist/new-file.png'); // Save it to new file 218 | ``` 219 | 220 | 221 | ### Unit testing and Code Quality 222 | ```sh 223 | make update 224 | make test-all 225 | ``` 226 | 227 | 228 | ### License 229 | MIT 230 | 231 | 232 | ## See Also 233 | 234 | - [CI-Report-Converter](https://github.com/JBZoo/CI-Report-Converter) - The tool converts different error reporting standards for deep compatibility with popular CI systems. 235 | - [Composer-Diff](https://github.com/JBZoo/Composer-Diff) - See what packages have changed after `composer update`. 236 | - [Composer-Graph](https://github.com/JBZoo/Composer-Graph) - Dependency graph visualization for composer.json (PHP + Composer) based on mermaid-js. 237 | - [Mermaid-PHP](https://github.com/JBZoo/Mermaid-PHP) - Generate diagrams and flowcharts with the help of the mermaid script language. 238 | - [Utils](https://github.com/JBZoo/Utils) - Collection of useful PHP functions, mini-classes, and snippets for every day. 239 | - [Data](https://github.com/JBZoo/Data) - Extended implementation of ArrayObject. Use files as config/array. 240 | - [Retry](https://github.com/JBZoo/Retry) - Tiny PHP library providing retry/backoff functionality with multiple backoff strategies and jitter support. 241 | - [SimpleTypes](https://github.com/JBZoo/SimpleTypes) - Converting any values and measures - money, weight, exchange rates, length, ... 242 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "jbzoo/image", 3 | "type" : "library", 4 | "description" : "A PHP class that simplifies working with images", 5 | "license" : "MIT", 6 | "keywords" : [ 7 | "image", 8 | "resize", 9 | "images", 10 | "jbzoo", 11 | "crop", 12 | "filters", 13 | "thumbnail", 14 | "watermark", 15 | "gd" 16 | ], 17 | "authors" : [ 18 | { 19 | "name" : "Denis Smetannikov", 20 | "email" : "admin@jbzoo.com", 21 | "role" : "lead" 22 | }, 23 | { 24 | "name" : "Cory LaViska", 25 | "homepage" : "https://www.abeautifulsite.net/", 26 | "role" : "Developer" 27 | }, 28 | { 29 | "name" : "Nazar Mokrynskyi", 30 | "email" : "nazar@mokrynskyi.com", 31 | "homepage" : "https://cleverstyle.org/", 32 | "role" : "Developer" 33 | } 34 | ], 35 | 36 | "minimum-stability" : "dev", 37 | "prefer-stable" : true, 38 | 39 | "require" : { 40 | "php" : "^8.1", 41 | "ext-gd" : "*", 42 | "ext-exif" : "*", 43 | "ext-ctype" : "*", 44 | 45 | "jbzoo/utils" : "^7.1", 46 | "jbzoo/data" : "^7.1" 47 | }, 48 | 49 | "require-dev" : { 50 | "jbzoo/toolbox-dev" : "^7.1" 51 | }, 52 | 53 | "autoload" : { 54 | "psr-4" : {"JBZoo\\Image\\" : "src"} 55 | }, 56 | 57 | "autoload-dev" : { 58 | "psr-4" : {"JBZoo\\PHPUnit\\" : "tests"} 59 | }, 60 | 61 | "config" : { 62 | "optimize-autoloader" : true, 63 | "allow-plugins" : {"composer/package-versions-deprecated" : true} 64 | }, 65 | 66 | "extra" : { 67 | "branch-alias" : { 68 | "dev-master" : "7.x-dev" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | '#333', 335 | 'size' => 1, 336 | ], $params); 337 | 338 | $size = Vars::range((int)$params['size'], 1, 1000); 339 | $rgba = Helper::normalizeColor((string)$params['color']); 340 | $width = \imagesx($image); 341 | $height = \imagesy($image); 342 | 343 | $posX1 = 0; 344 | $posY1 = 0; 345 | $posX2 = $width - 1; 346 | $posY2 = $height - 1; 347 | 348 | $color = (int)\imagecolorallocatealpha($image, $rgba[0], $rgba[1], $rgba[2], $rgba[3]); 349 | 350 | for ($i = 0; $i < $size; $i++) { 351 | \imagerectangle($image, $posX1++, $posY1++, $posX2--, $posY2--, $color); 352 | } 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/Image.php: -------------------------------------------------------------------------------- 1 | orient = null; 65 | $this->filename = null; 66 | $this->mime = null; 67 | 68 | if ( 69 | $filename !== '' 70 | && \is_string($filename) 71 | && \ctype_print($filename) 72 | && FS::isFile($filename) 73 | ) { 74 | $this->loadFile($filename); 75 | } elseif ($filename instanceof \GdImage) { 76 | $this->loadResource($filename); 77 | } elseif (!isStrEmpty($filename)) { 78 | $this->loadString($filename, $strict); 79 | } 80 | } 81 | 82 | /** 83 | * Destroy image resource. 84 | */ 85 | public function __destruct() 86 | { 87 | $this->cleanup(); 88 | } 89 | 90 | public function getInfo(): array 91 | { 92 | return [ 93 | 'filename' => $this->filename, 94 | 'width' => $this->width, 95 | 'height' => $this->height, 96 | 'mime' => $this->mime, 97 | 'quality' => $this->quality, 98 | 'exif' => $this->exif, 99 | 'orient' => $this->orient, 100 | ]; 101 | } 102 | 103 | /** 104 | * Get the current width. 105 | */ 106 | public function getWidth(): int 107 | { 108 | return $this->width; 109 | } 110 | 111 | /** 112 | * Get the current height. 113 | */ 114 | public function getHeight(): int 115 | { 116 | return $this->height; 117 | } 118 | 119 | /** 120 | * Get the current image resource. 121 | */ 122 | public function getImage(): ?\GdImage 123 | { 124 | return $this->image; 125 | } 126 | 127 | public function setQuality(int $newQuality): self 128 | { 129 | $this->quality = Helper::quality($newQuality); 130 | 131 | return $this; 132 | } 133 | 134 | /** 135 | * Save an image. The resulting format will be determined by the file extension. 136 | * @param null|int $quality Output image quality in percents 0-100 137 | */ 138 | public function save(?int $quality = null): self 139 | { 140 | $quality ??= $this->quality; 141 | 142 | if ($this->filename !== null && $this->filename !== '') { 143 | $this->internalSave($this->filename, $quality); 144 | 145 | return $this; 146 | } 147 | 148 | throw new Exception('Filename is not defined'); 149 | } 150 | 151 | /** 152 | * Save an image. The resulting format will be determined by the file extension. 153 | * @param string $filename If omitted - original file will be overwritten 154 | * @param null|int $quality Output image quality in percents 0-100 155 | */ 156 | public function saveAs(string $filename, ?int $quality = null): self 157 | { 158 | if (isStrEmpty($filename)) { 159 | throw new Exception('Empty filename to save image'); 160 | } 161 | 162 | $dir = FS::dirName($filename); 163 | if (\realpath($dir) !== false && \is_dir($dir)) { 164 | $this->internalSave($filename, $quality); 165 | } else { 166 | throw new Exception("Target directory \"{$dir}\" not exists"); 167 | } 168 | 169 | return $this; 170 | } 171 | 172 | public function isGif(): bool 173 | { 174 | return Helper::isGif($this->mime); 175 | } 176 | 177 | public function isPng(): bool 178 | { 179 | return Helper::isPng($this->mime); 180 | } 181 | 182 | public function isWebp(): bool 183 | { 184 | return Helper::isWebp($this->mime); 185 | } 186 | 187 | public function isJpeg(): bool 188 | { 189 | return Helper::isJpeg($this->mime); 190 | } 191 | 192 | /** 193 | * Load an image. 194 | * @param string $filename Path to image file 195 | */ 196 | public function loadFile(string $filename): self 197 | { 198 | $cleanFilename = FS::clean($filename); 199 | if (!FS::isFile($cleanFilename)) { 200 | throw new Exception('Image file not found: ' . $filename); 201 | } 202 | 203 | $this->cleanup(); 204 | $this->filename = $cleanFilename; 205 | $this->loadMeta(); 206 | 207 | return $this; 208 | } 209 | 210 | /** 211 | * Load an image. 212 | * @param null|string $imageString Binary images 213 | */ 214 | public function loadString(?string $imageString, bool $strict = false): self 215 | { 216 | if ($imageString === null || $imageString === '') { 217 | throw new Exception('Image string is empty!'); 218 | } 219 | 220 | $this->cleanup(); 221 | $this->loadMeta($imageString, $strict); 222 | 223 | return $this; 224 | } 225 | 226 | /** 227 | * Load image resource. 228 | * @param null|\GdImage|string $imageRes Image GD Resource 229 | */ 230 | public function loadResource(null|\GdImage|string $imageRes = null): self 231 | { 232 | if (!$imageRes instanceof \GdImage) { 233 | throw new Exception('Image is not GD resource!'); 234 | } 235 | 236 | $this->cleanup(); 237 | $this->loadMeta($imageRes); 238 | 239 | return $this; 240 | } 241 | 242 | /** 243 | * Clean whole image object. 244 | */ 245 | public function cleanup(): self 246 | { 247 | $this->filename = null; 248 | 249 | $this->mime = null; 250 | $this->width = 0; 251 | $this->height = 0; 252 | $this->exif = []; 253 | $this->orient = null; 254 | $this->quality = self::DEFAULT_QUALITY; 255 | 256 | $this->destroyImage(); 257 | 258 | return $this; 259 | } 260 | 261 | public function isPortrait(): bool 262 | { 263 | return $this->orient === self::PORTRAIT; 264 | } 265 | 266 | public function isLandscape(): bool 267 | { 268 | return $this->orient === self::LANDSCAPE; 269 | } 270 | 271 | public function isSquare(): bool 272 | { 273 | return $this->orient === self::SQUARE; 274 | } 275 | 276 | /** 277 | * Create an image from scratch. 278 | * @param int $width Image width 279 | * @param null|int $height If omitted - assumed equal to $width 280 | * @param null|array|string $color Hex color string, array(red, green, blue) or array(red, green, blue, alpha). 281 | * Where red, green, blue - integers 0-255, alpha - integer 0-127 282 | */ 283 | public function create(int $width, ?int $height = null, null|array|string $color = null): self 284 | { 285 | $this->cleanup(); 286 | 287 | $height = (int)$height === 0 ? $width : $height; 288 | 289 | $this->width = VarFilter::int($width); 290 | $this->height = VarFilter::int($height); 291 | 292 | $newImageRes = \imagecreatetruecolor($this->width, $this->height); 293 | if ($newImageRes !== false) { 294 | $this->image = $newImageRes; 295 | } else { 296 | throw new Exception("Can't create empty image resource"); 297 | } 298 | 299 | $this->mime = self::DEFAULT_MIME; 300 | $this->exif = []; 301 | 302 | $this->orient = $this->getOrientation(); 303 | 304 | if ($color !== null) { 305 | return $this->addFilter('fill', $color); 306 | } 307 | 308 | return $this; 309 | } 310 | 311 | /** 312 | * Resize an image to the specified dimensions. 313 | * @phan-suppress PhanPossiblyFalseTypeArgumentInternal 314 | */ 315 | public function resize(float $width, float $height): self 316 | { 317 | $width = VarFilter::int($width); 318 | $height = VarFilter::int($height); 319 | 320 | // Generate new GD image 321 | $newImage = \imagecreatetruecolor($width, $height); 322 | if ($newImage === false) { 323 | throw new Exception("Can't create new image resource"); 324 | } 325 | 326 | if ($this->image === null) { 327 | throw new Exception('Image resource in not defined'); 328 | } 329 | 330 | if ($this->isGif()) { 331 | // Preserve transparency in GIFs 332 | $transIndex = \imagecolortransparent($this->image); 333 | $palletSize = \imagecolorstotal($this->image); 334 | 335 | if ($transIndex > 0 && $transIndex < $palletSize) { 336 | $trColor = \imagecolorsforindex($this->image, $transIndex); 337 | 338 | $red = 0; 339 | $green = 0; 340 | $blue = 0; 341 | 342 | $colorsTypeCount = 3; 343 | 344 | if (\count($trColor) >= $colorsTypeCount) { 345 | $red = VarFilter::int($trColor['red']); 346 | $green = VarFilter::int($trColor['green']); 347 | $blue = VarFilter::int($trColor['blue']); 348 | } 349 | 350 | $transIndex = (int)\imagecolorallocate($newImage, $red, $green, $blue); 351 | 352 | \imagefill($newImage, 0, 0, $transIndex); 353 | \imagecolortransparent($newImage, $transIndex); 354 | } 355 | } else { 356 | // Preserve transparency in PNG 357 | Helper::addAlpha($newImage, false); 358 | } 359 | 360 | // Resize 361 | \imagecopyresampled($newImage, $this->image, 0, 0, 0, 0, $width, $height, $this->width, $this->height); 362 | 363 | // Update meta data 364 | $this->replaceImage($newImage); 365 | $this->width = $width; 366 | $this->height = $height; 367 | 368 | return $this; 369 | } 370 | 371 | /** 372 | * Best fit (proportionally resize to fit in specified width/height) 373 | * Shrink the image proportionally to fit inside a $width x $height box. 374 | */ 375 | public function bestFit(int $maxWidth, int $maxHeight): self 376 | { 377 | // If it already fits, there's nothing to do 378 | if ($this->width <= $maxWidth && $this->height <= $maxHeight) { 379 | return $this; 380 | } 381 | 382 | // Determine aspect ratio 383 | $aspectRatio = $this->height / $this->width; 384 | 385 | $width = $this->width; 386 | $height = $this->height; 387 | 388 | // Make width fit into new dimensions 389 | if ($this->width > $maxWidth) { 390 | $width = $maxWidth; 391 | $height = $width * $aspectRatio; 392 | } 393 | 394 | // Make height fit into new dimensions 395 | if ($height > $maxHeight) { 396 | $height = $maxHeight; 397 | $width = $height / $aspectRatio; 398 | } 399 | 400 | return $this->resize($width, $height); 401 | } 402 | 403 | /** 404 | * Thumbnail. 405 | * This function attempts to get the image to as close to the provided dimensions as possible, and then crops the 406 | * remaining overflow (from the center) to get the image to be the size specified. Useful for generating thumbnails. 407 | * @param null|int $height If omitted - assumed equal to $width 408 | * @param bool $topIsZero Force top offset = 0 409 | */ 410 | public function thumbnail(int $width, ?int $height = null, bool $topIsZero = false): self 411 | { 412 | $width = VarFilter::int($width); 413 | $height = VarFilter::int($height); 414 | 415 | // Determine height 416 | $height = $height === 0 ? $width : $height; 417 | 418 | // Determine aspect ratios 419 | $currentAspectRatio = $this->height / $this->width; 420 | $newAspectRatio = $height / $width; 421 | 422 | // Fit to height/width 423 | if ($newAspectRatio > $currentAspectRatio) { 424 | $this->fitToHeight($height); 425 | } else { 426 | $this->fitToWidth($width); 427 | } 428 | 429 | $left = (int)\floor(($this->width / 2) - ($width / 2)); 430 | $top = (int)\floor(($this->height / 2) - ($height / 2)); 431 | 432 | // Return trimmed image 433 | $right = $width + $left; 434 | $bottom = $height + $top; 435 | 436 | if ($topIsZero) { 437 | $bottom -= $top; 438 | $top = 0; 439 | } 440 | 441 | return $this->crop($left, $top, $right, $bottom); 442 | } 443 | 444 | /** 445 | * Fit to height (proportionally resize to specified height). 446 | */ 447 | public function fitToHeight(int $height): self 448 | { 449 | $height = VarFilter::int($height); 450 | $width = $height / ($this->height / $this->width); 451 | 452 | return $this->resize($width, $height); 453 | } 454 | 455 | /** 456 | * Fit to width (proportionally resize to specified width). 457 | */ 458 | public function fitToWidth(int $width): self 459 | { 460 | $width = VarFilter::int($width); 461 | $height = $width * ($this->height / $this->width); 462 | 463 | return $this->resize($width, $height); 464 | } 465 | 466 | /** 467 | * Crop an image. 468 | * @param int $left Left 469 | * @param int $top Top 470 | * @param int $right Right 471 | * @param int $bottom Bottom 472 | */ 473 | public function crop(int $left, int $top, int $right, int $bottom): self 474 | { 475 | $left = VarFilter::int($left); 476 | $top = VarFilter::int($top); 477 | $right = VarFilter::int($right); 478 | $bottom = VarFilter::int($bottom); 479 | 480 | // Determine crop size 481 | if ($right < $left) { 482 | [$left, $right] = [$right, $left]; 483 | } 484 | 485 | if ($bottom < $top) { 486 | [$top, $bottom] = [$bottom, $top]; 487 | } 488 | 489 | $croppedW = $right - $left; 490 | $croppedH = $bottom - $top; 491 | 492 | // Perform crop 493 | $newImage = \imagecreatetruecolor($croppedW, $croppedH); 494 | if ($newImage === false) { 495 | throw new Exception("Can't crop image, imagecreatetruecolor() failed"); 496 | } 497 | 498 | Helper::addAlpha($newImage); 499 | 500 | if ($this->image instanceof \GdImage) { 501 | \imagecopyresampled($newImage, $this->image, 0, 0, $left, $top, $croppedW, $croppedH, $croppedW, $croppedH); 502 | } else { 503 | throw new Exception("Can't crop image, image resource is undefined"); 504 | } 505 | 506 | // Update meta data 507 | $this->replaceImage($newImage); 508 | $this->width = $croppedW; 509 | $this->height = $croppedH; 510 | 511 | return $this; 512 | } 513 | 514 | /** 515 | * Rotates and/or flips an image automatically so the orientation will be correct (based on exif 'Orientation'). 516 | */ 517 | public function autoOrient(): self 518 | { 519 | if (!\array_key_exists('Orientation', $this->exif)) { 520 | return $this; 521 | } 522 | 523 | $orient = (int)$this->exif['Orientation']; 524 | 525 | if ($orient === self::FLIP_HORIZONTAL) { 526 | $this->addFilter('flip', 'x'); 527 | } elseif ($orient === self::FLIP_180_COUNTERCLOCKWISE) { 528 | $this->addFilter('rotate', -180); 529 | } elseif ($orient === self::FLIP_VERTICAL) { 530 | $this->addFilter('flip', 'y'); 531 | } elseif ($orient === self::FLIP_ROTATE_90_CLOCKWISE_AND_VERTICALLY) { 532 | $this->addFilter('flip', 'y'); 533 | $this->addFilter('rotate', 90); 534 | } elseif ($orient === self::FLIP_ROTATE_90_CLOCKWISE) { 535 | $this->addFilter('rotate', 90); 536 | } elseif ($orient === self::FLIP_ROTATE_90_CLOCKWISE_AND_HORIZONTALLY) { 537 | $this->addFilter('flip', 'x'); 538 | $this->addFilter('rotate', 90); 539 | } elseif ($orient === self::FLIP_ROTATE_90_COUNTERCLOCKWISE) { 540 | $this->addFilter('rotate', -90); 541 | } 542 | 543 | return $this; 544 | } 545 | 546 | /** 547 | * Overlay an image on top of another, works with 24-bit PNG alpha-transparency. 548 | * @param Image|string $overlay An image filename or an Image object 549 | * @param string $position center|top|left|bottom|right|top left|top right|bottom left|bottom right 550 | * @param float $opacity Overlay opacity 0-1 or 0-100 551 | * @param int $globOffsetX Horizontal offset in pixels 552 | * @param int $globOffsetY Vertical offset in pixels 553 | */ 554 | public function overlay( 555 | self|string $overlay, 556 | string $position = 'bottom right', 557 | float $opacity = .4, 558 | int $globOffsetX = 0, 559 | int $globOffsetY = 0, 560 | ): self { 561 | // Load overlay image 562 | if (!$overlay instanceof self) { 563 | $overlay = new self($overlay); 564 | } 565 | 566 | // Convert opacity 567 | $opacity = Helper::opacity($opacity); 568 | $globOffsetX = VarFilter::int($globOffsetX); 569 | $globOffsetY = VarFilter::int($globOffsetY); 570 | 571 | // Determine position 572 | $offsetCoords = Helper::getInnerCoords( 573 | $position, 574 | [$this->width, $this->height], 575 | [$overlay->getWidth(), $overlay->getHeight()], 576 | [$globOffsetX, $globOffsetY], 577 | ); 578 | 579 | $xOffset = (int)($offsetCoords[0] ?? null); 580 | $yOffset = (int)($offsetCoords[1] ?? null); 581 | 582 | if ($this->image === null) { 583 | throw new Exception("Can't overlay image, image resource is undefined"); 584 | } 585 | 586 | $overlayImage = $overlay->getImage(); 587 | if ($overlayImage === null) { 588 | throw new Exception("Can't overlay image, overlay image resource is undefined"); 589 | } 590 | 591 | // Perform the overlay 592 | Helper::imageCopyMergeAlpha( 593 | $this->image, 594 | $overlayImage, 595 | [$xOffset, $yOffset], 596 | [0, 0], 597 | [$overlay->getWidth(), $overlay->getHeight()], 598 | $opacity, 599 | ); 600 | 601 | return $this; 602 | } 603 | 604 | /** 605 | * Add filter to current image. 606 | */ 607 | public function addFilter(mixed $filter): self 608 | { 609 | $args = \func_get_args(); 610 | $args[0] = $this->image; 611 | 612 | if (\is_string($filter)) { 613 | if (\method_exists(Filter::class, $filter)) { 614 | /** @var \Closure $filterFunc */ 615 | $filterFunc = [Filter::class, $filter]; 616 | $newImage = $filterFunc(...$args); 617 | } else { 618 | throw new Exception("Undefined Image Filter: {$filter}"); 619 | } 620 | } elseif (\is_callable($filter)) { 621 | $newImage = $filter(...$args); 622 | } else { 623 | throw new Exception('Undefined filter type'); 624 | } 625 | 626 | if ($newImage instanceof \GdImage) { 627 | $this->replaceImage($newImage); 628 | } 629 | 630 | return $this; 631 | } 632 | 633 | /** 634 | * Outputs image as data base64 to use as img src. 635 | * 636 | * @param null|string $format If omitted or null - format of original file will be used, may be gif|jpg|png 637 | * @param null|int $quality Output image quality in percents 0-100 638 | */ 639 | public function getBase64(?string $format = 'gif', ?int $quality = null, bool $addMime = true): string 640 | { 641 | [$mimeType, $binaryData] = $this->renderBinary($format, $quality); 642 | 643 | $result = \base64_encode($binaryData); 644 | 645 | if ($addMime) { 646 | $result = 'data:' . $mimeType . ';base64,' . $result; 647 | } 648 | 649 | return $result; 650 | } 651 | 652 | /** 653 | * Outputs image as binary data. 654 | * 655 | * @param null|string $format If omitted or null - format of original file will be used, may be gif|jpg|png 656 | * @param null|int $quality Output image quality in percents 0-100 657 | */ 658 | public function getBinary(?string $format = null, ?int $quality = null): string 659 | { 660 | $result = $this->renderBinary($format, $quality); 661 | 662 | return $result[1]; 663 | } 664 | 665 | /** 666 | * Get relative path to image. 667 | */ 668 | public function getPath(): string 669 | { 670 | if ($this->filename === null || $this->filename === '') { 671 | throw new Exception('Filename is empty'); 672 | } 673 | 674 | return Url::pathToRel($this->filename); 675 | } 676 | 677 | /** 678 | * Get full URL to image (if not CLI mode). 679 | */ 680 | public function getUrl(): string 681 | { 682 | $rootPath = Url::root(); 683 | $relPath = $this->getPath(); 684 | 685 | return "{$rootPath}/{$relPath}"; 686 | } 687 | 688 | private function savePng(string $filename, int $quality = self::DEFAULT_QUALITY): bool 689 | { 690 | if ($this->image !== null) { 691 | return \imagepng( 692 | $this->image, 693 | $filename === '' ? null : $filename, 694 | (int)\round(9 * $quality / 100), 695 | ); 696 | } 697 | 698 | throw new Exception('Image resource ins not defined'); 699 | } 700 | 701 | private function saveJpeg(string $filename, int $quality = self::DEFAULT_QUALITY): bool 702 | { 703 | if ($this->image !== null) { 704 | // imageinterlace($this->image, true); 705 | return \imagejpeg( 706 | $this->image, 707 | $filename === '' ? null : $filename, 708 | (int)\round($quality), 709 | ); 710 | } 711 | 712 | throw new Exception('Image resource ins not defined'); 713 | } 714 | 715 | private function saveGif(string $filename): bool 716 | { 717 | if ($this->image !== null) { 718 | return \imagegif( 719 | $this->image, 720 | $filename === '' ? null : $filename, 721 | ); 722 | } 723 | 724 | throw new Exception('Image resource ins not defined'); 725 | } 726 | 727 | private function saveWebP(string $filename, int $quality = self::DEFAULT_QUALITY): bool 728 | { 729 | if (!\function_exists('\imagewebp')) { 730 | throw new Exception('Function imagewebp() is not available. Rebuild your ext-gd for PHP'); 731 | } 732 | 733 | if ($this->image !== null) { 734 | return \imagewebp( 735 | $this->image, 736 | $filename === '' ? null : $filename, 737 | (int)\round($quality), 738 | ); 739 | } 740 | 741 | throw new Exception('Image resource ins not defined'); 742 | } 743 | 744 | /** 745 | * Save image to file. 746 | */ 747 | private function internalSave(string $filename, ?int $quality): bool 748 | { 749 | $quality = $quality > 0 ? $quality : $this->quality; 750 | $quality = Helper::quality($quality); 751 | 752 | $format = \strtolower(FS::ext($filename)); 753 | if (!Helper::isSupportedFormat($format)) { 754 | $format = $this->mime; 755 | } 756 | 757 | $filename = FS::clean($filename); 758 | 759 | // Create the image 760 | if ($this->renderImageByFormat($format, $filename, $quality) !== null) { 761 | $this->loadFile($filename); 762 | $this->quality = $quality; 763 | 764 | return true; 765 | } 766 | 767 | return false; 768 | } 769 | 770 | /** 771 | * Render image resource as binary. 772 | * @SuppressWarnings(PHPMD.CyclomaticComplexity) 773 | */ 774 | private function renderImageByFormat( 775 | ?string $format, 776 | string $filename, 777 | int $quality = self::DEFAULT_QUALITY, 778 | ): ?string { 779 | if ($this->image === null) { 780 | throw new Exception('Image resource not defined'); 781 | } 782 | 783 | $format = $format === null || $format === '' ? $this->mime : $format; 784 | 785 | $result = null; 786 | if (Helper::isJpeg($format)) { 787 | if ($this->saveJpeg($filename, $quality)) { 788 | $result = 'image/jpeg'; 789 | } 790 | } elseif (Helper::isPng($format)) { 791 | if ($this->savePng($filename, $quality)) { 792 | $result = 'image/png'; 793 | } 794 | } elseif (Helper::isGif($format)) { 795 | if ($this->saveGif($filename)) { 796 | $result = 'image/gif'; 797 | } 798 | } elseif (Helper::isWebp($format)) { 799 | if ($this->saveWebP($filename)) { 800 | $result = 'image/webp'; 801 | } 802 | } else { 803 | throw new Exception("Undefined format: {$format}"); 804 | } 805 | 806 | return $result; 807 | } 808 | 809 | /** 810 | * Get metadata of image or base64 string. 811 | * @SuppressWarnings(PHPMD.CyclomaticComplexity) 812 | */ 813 | private function loadMeta(null|\GdImage|string $image = null, bool $strict = false): self 814 | { 815 | // Gather meta data 816 | if ($image === null && $this->filename !== null && $this->filename !== '') { 817 | $imageInfo = \getimagesize($this->filename); 818 | if ($imageInfo !== false) { 819 | // @phan-suppress-next-line PhanPartialTypeMismatchArgument 820 | $this->image = $this->imageCreate($imageInfo['mime']); 821 | } 822 | } elseif ($image instanceof \GdImage) { 823 | $this->image = $image; 824 | $imageInfo = [ 825 | '0' => \imagesx($this->image), 826 | '1' => \imagesy($this->image), 827 | 'mime' => self::DEFAULT_MIME, 828 | ]; 829 | } elseif (\is_string($image)) { 830 | if ($strict) { 831 | $cleanedString = \str_replace( 832 | ' ', 833 | '+', 834 | (string)\preg_replace('#^data:image/[^;]+;base64,#', '', $image), 835 | ); 836 | 837 | if (\base64_decode($cleanedString, true) === false) { 838 | throw new Exception('Invalid image source.'); 839 | } 840 | } 841 | 842 | $imageBin = Helper::strToBin($image); 843 | if ($imageBin !== null) { 844 | $imageInfo = \getimagesizefromstring($imageBin); 845 | if ($imageInfo === false) { 846 | throw new Exception('Invalid image source. Can\'tget image info from string'); 847 | } 848 | 849 | $newImage = \imagecreatefromstring($imageBin); 850 | $this->image = $newImage !== false ? $newImage : null; 851 | } 852 | } else { 853 | throw new Exception('Undefined format of source. Only "resource|string" are expected'); 854 | } 855 | 856 | // Set internal state 857 | if (isset($imageInfo) && \is_array($imageInfo)) { 858 | $this->mime = $imageInfo['mime']; 859 | $this->width = $imageInfo['0']; 860 | $this->height = $imageInfo['1']; 861 | } 862 | $this->exif = $this->getExif(); 863 | $this->orient = $this->getOrientation(); 864 | 865 | // Prepare alpha chanel 866 | if ($this->image !== null) { 867 | Helper::addAlpha($this->image); 868 | } else { 869 | throw new Exception('Image resource not defined'); 870 | } 871 | 872 | return $this; 873 | } 874 | 875 | /** 876 | * Destroy image resource if not empty. 877 | */ 878 | private function destroyImage(): void 879 | { 880 | if ($this->image instanceof \GdImage) { 881 | \imagedestroy($this->image); 882 | $this->image = null; 883 | } 884 | } 885 | 886 | private function getExif(): array 887 | { 888 | $result = []; 889 | 890 | if ( 891 | $this->filename !== '' 892 | && $this->filename !== null 893 | && Sys::isFunc('exif_read_data') 894 | && Helper::isJpeg($this->mime) 895 | ) { 896 | $exif = \exif_read_data($this->filename); 897 | $result = $exif === false ? [] : $exif; 898 | } 899 | 900 | return $result; 901 | } 902 | 903 | /** 904 | * Create image resource. 905 | */ 906 | private function imageCreate(?string $format): \GdImage 907 | { 908 | if ($this->filename === '' || $this->filename === null) { 909 | throw new Exception('Filename is undefined'); 910 | } 911 | 912 | if (Helper::isJpeg($format)) { 913 | $result = \imagecreatefromjpeg($this->filename); 914 | } elseif (Helper::isPng($format)) { 915 | $result = \imagecreatefrompng($this->filename); 916 | } elseif (Helper::isGif($format)) { 917 | $result = \imagecreatefromgif($this->filename); 918 | } elseif (\function_exists('imagecreatefromwebp') && Helper::isWebp($format)) { 919 | $result = \imagecreatefromwebp($this->filename); 920 | } else { 921 | throw new Exception("Invalid image: {$this->filename}"); 922 | } 923 | 924 | if ($result === false) { 925 | throw new Exception("Can't create new image resource by filename: {$this->filename}; format: {$format}"); 926 | } 927 | 928 | return $result; 929 | } 930 | 931 | /** 932 | * Get the current orientation. 933 | */ 934 | private function getOrientation(): string 935 | { 936 | if ($this->width > $this->height) { 937 | return self::LANDSCAPE; 938 | } 939 | 940 | if ($this->width < $this->height) { 941 | return self::PORTRAIT; 942 | } 943 | 944 | return self::SQUARE; 945 | } 946 | 947 | private function replaceImage(\GdImage $newImage): void 948 | { 949 | if (!self::isSameResource($this->image, $newImage)) { 950 | $this->destroyImage(); 951 | $this->image = $newImage; 952 | $this->width = \imagesx($this->image); 953 | $this->height = \imagesy($this->image); 954 | } 955 | } 956 | 957 | private function renderBinary(?string $format, ?int $quality): array 958 | { 959 | if ($this->image === null) { 960 | throw new Exception('Image resource not defined'); 961 | } 962 | 963 | \ob_start(); 964 | $mimeType = $this->renderImageByFormat($format, '', (int)$quality); 965 | $imageData = \ob_get_clean(); 966 | 967 | return [$mimeType, $imageData]; 968 | } 969 | 970 | private static function isSameResource(?\GdImage $image1 = null, ?\GdImage $image2 = null): bool 971 | { 972 | if ($image1 === null || $image2 === null) { 973 | return false; 974 | } 975 | 976 | return \spl_object_id($image1) === \spl_object_id($image2); 977 | } 978 | } 979 | -------------------------------------------------------------------------------- /src/Text.php: -------------------------------------------------------------------------------- 1 | 'bottom', 28 | 'angle' => 0, 29 | 'font-size' => 32, 30 | 'color' => '#ffffff', 31 | 'offset-x' => 0, 32 | 'offset-y' => 20, 33 | 'stroke-color' => '#222', 34 | 'stroke-size' => 2, 35 | 'stroke-spacing' => 3, 36 | ]; 37 | 38 | /** 39 | * Add text to an image. 40 | * @param \GdImage $image GD resource 41 | * @param string $text Some text to output on image as watermark 42 | * @param string $fontFile TTF font file path 43 | * @param array $params Additional render params 44 | * @SuppressWarnings(PHPMD.CyclomaticComplexity) 45 | * @SuppressWarnings(PHPMD.ExcessiveMethodLength) 46 | */ 47 | public static function render(\GdImage $image, string $text, string $fontFile, array $params = []): void 48 | { 49 | // Set vars 50 | $params = \array_merge(self::$default, $params); 51 | $angle = Helper::rotate((float)$params['angle']); 52 | $position = Helper::position((string)$params['position']); 53 | 54 | $fSize = (int)$params['font-size']; 55 | 56 | $offsetX = (int)$params['offset-x']; 57 | $offsetY = (int)$params['offset-y']; 58 | 59 | $strokeSize = (int)$params['stroke-size']; 60 | $strokeSpacing = (int)$params['stroke-spacing']; 61 | 62 | $imageWidth = \imagesx($image); 63 | $imageHeight = \imagesy($image); 64 | 65 | $color = \is_string($params['color']) ? $params['color'] : (array)$params['color']; 66 | $strokeColor = \is_string($params['stroke-color']) ? $params['stroke-color'] : (array)$params['stroke-color']; 67 | 68 | $colorArr = self::getColor($image, $color); 69 | 70 | [$textWidth, $textHeight] = self::getTextBoxSize($fSize, $angle, $fontFile, $text); 71 | 72 | $textCoords = Helper::getInnerCoords( 73 | $position, 74 | [$imageWidth, $imageHeight], 75 | [$textWidth, $textHeight], 76 | [$offsetX, $offsetY], 77 | ); 78 | 79 | $textX = (int)($textCoords[0] ?? null); 80 | $textY = (int)($textCoords[1] ?? null); 81 | 82 | if ( 83 | $strokeSize > 0 84 | && (\is_array($strokeColor) || !isStrEmpty($strokeColor)) 85 | ) { 86 | if (\is_array($color) || \is_array($strokeColor)) { 87 | // Multi colored text and/or multi colored stroke 88 | $strokeColor = self::getColor($image, $strokeColor); 89 | $chars = \str_split($text); 90 | 91 | foreach ($chars as $key => $char) { 92 | if ($key > 0) { 93 | $textX = self::getStrokeX($fSize, $angle, $fontFile, $chars, $key, $strokeSpacing, $textX); 94 | } 95 | 96 | // If the next letter is empty, we just move forward to the next letter 97 | if ($char === ' ') { 98 | continue; 99 | } 100 | 101 | self::renderStroke( 102 | $image, 103 | $char, 104 | [$fontFile, $fSize, \current($colorArr), $angle], 105 | [$textX, $textY], 106 | [$strokeSize, \current($strokeColor)], 107 | ); 108 | 109 | // #000 is 0, black will reset the array, so we write it this way 110 | if (\next($colorArr) === false) { 111 | \reset($colorArr); 112 | } 113 | 114 | // #000 is 0, black will reset the array, so we write it this way 115 | if (\next($strokeColor) === false) { 116 | \reset($strokeColor); 117 | } 118 | } 119 | } else { 120 | $rgba = Helper::normalizeColor($strokeColor); 121 | $strokeColor = \imagecolorallocatealpha($image, $rgba[0], $rgba[1], $rgba[2], $rgba[3]); 122 | self::renderStroke( 123 | $image, 124 | $text, 125 | [$fontFile, $fSize, \current($colorArr), $angle], 126 | [$textX, $textY], 127 | [$strokeSize, $strokeColor], 128 | ); 129 | } 130 | } elseif (\is_array($color)) { // Multi colored text 131 | $chars = \str_split($text); 132 | 133 | foreach ($chars as $key => $char) { 134 | if ($key > 0) { 135 | $textX = self::getStrokeX($fSize, $angle, $fontFile, $chars, $key, $strokeSpacing, $textX); 136 | } 137 | 138 | // If the next letter is empty, we just move forward to the next letter 139 | if ($char === ' ') { 140 | continue; 141 | } 142 | 143 | $fontInfo = [$fontFile, $fSize, \current($colorArr), $angle]; 144 | self::internalRender($image, $char, $fontInfo, [$textX, $textY]); 145 | 146 | // #000 is 0, black will reset the array, so we write it this way 147 | if (\next($colorArr) === false) { 148 | \reset($colorArr); 149 | } 150 | } 151 | } else { 152 | self::internalRender($image, $text, [$fontFile, $fSize, $colorArr[0], $angle], [$textX, $textY]); 153 | } 154 | } 155 | 156 | /** 157 | * Determine text color. 158 | * @param \GdImage $image GD resource 159 | */ 160 | private static function getColor(\GdImage $image, array|string $colors): array 161 | { 162 | $colors = (array)$colors; 163 | 164 | $result = []; 165 | 166 | foreach ($colors as $color) { 167 | $rgba = Helper::normalizeColor($color); 168 | $result[] = \imagecolorallocatealpha($image, $rgba[0], $rgba[1], $rgba[2], $rgba[3]); 169 | } 170 | 171 | return $result; 172 | } 173 | 174 | /** 175 | * Determine textbox size. 176 | */ 177 | private static function getTextBoxSize(int $fontSize, int $angle, string $fontFile, string $text): array 178 | { 179 | // Determine textbox size 180 | $fontPath = FS::clean($fontFile); 181 | 182 | if (!FS::isFile($fontPath)) { 183 | throw new Exception("Unable to load font: {$fontFile}"); 184 | } 185 | 186 | $box = \imagettfbbox($fontSize, $angle, $fontFile, $text); 187 | if ($box !== false) { 188 | $boxWidth = (int)\abs($box[6] - $box[2]); 189 | $boxHeight = (int)\abs($box[7] - $box[1]); 190 | } else { 191 | throw new Exception("Can't get box size for {$fontSize}; {$angle}; {$fontFile}; {$text}"); 192 | } 193 | 194 | return [$boxWidth, $boxHeight]; 195 | } 196 | 197 | /** 198 | * Compact args for imagettftext(). 199 | * @param \GdImage $image A GD image object 200 | * @param string $text The text to output 201 | * @param array $font [$fontfile, $fontsize, $color, $angle] 202 | * @param array $coords [X,Y] Coordinate of the starting position 203 | */ 204 | private static function internalRender(\GdImage $image, string $text, array $font, array $coords): void 205 | { 206 | [$coordX, $coordY] = $coords; 207 | [$file, $size, $color, $angle] = $font; 208 | 209 | \imagettftext($image, $size, $angle, $coordX, $coordY, $color, $file, $text); 210 | } 211 | 212 | /** 213 | * Same as imagettftext(), but allows for a stroke color and size. 214 | * @param \GdImage $image A GD image object 215 | * @param string $text The text to output 216 | * @param array $font [$fontfile, $fontsize, $color, $angle] 217 | * @param array $coords [X,Y] Coordinate of the starting position 218 | * @param array $stroke [$strokeSize, $strokeColor] 219 | */ 220 | private static function renderStroke( 221 | \GdImage $image, 222 | string $text, 223 | array $font, 224 | array $coords, 225 | array $stroke, 226 | ): void { 227 | [$coordX, $coordY] = $coords; 228 | [$file, $size, $color, $angle] = $font; 229 | [$strokeSize, $strokeColor] = $stroke; 230 | 231 | for ($x = ($coordX - \abs($strokeSize)); $x <= ($coordX + \abs($strokeSize)); $x++) { 232 | for ($y = ($coordY - \abs($strokeSize)); $y <= ($coordY + \abs($strokeSize)); $y++) { 233 | \imagettftext($image, $size, $angle, (int)$x, (int)$y, $strokeColor, $file, $text); 234 | } 235 | } 236 | 237 | \imagettftext($image, $size, $angle, $coordX, $coordY, $color, $file, $text); 238 | } 239 | 240 | /** 241 | * Get X offset for stroke rendering mode. 242 | * @noinspection PhpTooManyParametersInspection 243 | */ 244 | private static function getStrokeX( 245 | float $fontSize, 246 | int $angle, 247 | string $fontFile, 248 | array $letters, 249 | int $charKey, 250 | int $strokeSpacing, 251 | int $textX, 252 | ): int { 253 | $charSize = \imagettfbbox($fontSize, $angle, $fontFile, $letters[$charKey - 1]); 254 | if ($charSize === false) { 255 | throw new Exception("Can't get StrokeX"); 256 | } 257 | 258 | $textX += \abs($charSize[4] - $charSize[0]) + $strokeSpacing; 259 | 260 | return (int)$textX; 261 | } 262 | } 263 | --------------------------------------------------------------------------------