├── .gitignore ├── LICENSE ├── README.md ├── composer.json └── src ├── Adapters ├── AbstractImage.php ├── Gd.php ├── Gmagick.php ├── ImageAdapter.php └── Imagick.php ├── Base ├── Box.php ├── BoxInterface.php ├── Font.php ├── FontInterface.php ├── Matrix.php ├── Point.php └── PointInterface.php ├── Captcha.php ├── Colors.php ├── HintCaptcha.php ├── ICaptcha.php ├── Ico.php ├── Image.php ├── ImageCompare.php ├── ImageHelper.php ├── ImageManager.php ├── Node ├── BaseNode.php ├── BorderNode.php ├── BoxNode.php ├── CircleNode.php ├── ImgNode.php ├── LineNode.php ├── NodeHelper.php ├── RectNode.php └── TextNode.php ├── QrCode.php ├── Renderer └── QrCodeImageRenderer.php ├── SlideCaptcha.php ├── ThumbImage.php └── WaterMark.php /.gitignore: -------------------------------------------------------------------------------- 1 | composer.phar 2 | /vendor/ 3 | 4 | # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file 5 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 6 | # composer.lock 7 | /.idea 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 zx648383079 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 | # image 2 | 使用 gd 对图片处理的封装,包含 验证码、水印、缩略图、二维码、图片拖拽验证 3 | 4 | ## 当前状态:一直开发中 5 | 6 | ## 简单使用教程 7 | 8 | [验证码](#captcha) 9 | 10 | [滑动验证码](#slider) 11 | 12 | [文字点击验证](#hint) 13 | 14 | [二维码](#qr) 15 | 16 | [水印](#water) 17 | 18 | [缩略图](#thumb) 19 | 20 | [图片比较(简单版)](#compare) 21 | 22 | [内容生成图片](#draw) 23 | 24 | 25 | ### 使用 iconfont 等类似字体图标 26 | 27 | ```php 28 | $image = new Image(); 29 | $res = $image->instance(); 30 | $res->create(ImageManager::createSize(200, 200)); 31 | $res->fill('#fff'); 32 | $res->text("\u{e709}", ImageManager::createFont(app_path('data/fonts/iconfont.ttf')), ImageManager::createPoint(100, 100)); 33 | $image->show(); 34 | ``` 35 | 36 | ** 请注意:`\u{e709}` 表示一个字符,且字符串必须用双引号 ** 37 | 38 | ### ico 生成 39 | 40 | ```php 41 | use Zodream\Image\Ico; 42 | 43 | $image = new Ico('1.png'); 44 | $image->saveAsSize('1.ico', $image->getSizes()); 45 | ``` 46 | 47 | 48 | ### 验证码 49 | 50 | ```PHP 51 | use Zodream\Image\Captcha; 52 | 53 | $captcha = new Captcha(); 54 | $captcha->setConfigs([ 55 | 'width' => 200, 56 | 'fontSize' => 20, 57 | 'fontFamily' => 'Ubuntu_regular.ttf' 58 | ]); 59 | $source = $captcha->generate(); 60 | 61 | $captcha->verify($_POST['captcha'], $source); 62 | ``` 63 | 64 | 默认配置 65 | 66 | ```PHP 67 | 68 | [ 69 | 'characters' => 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', //随机因子 70 | 'length' => 4, //验证码长度 71 | 'fontSize' => 0, //指定字体大小 72 | 'fontColor' => '', //指定字体颜色, 可以是数组 73 | 'fontFamily' => null, //指定的字体 74 | 'width' => 100, // 图片宽 75 | 'height' => 30, // 图片高 76 | 'angle' => 0, //角度 77 | 'sensitive' => true, // 大小写敏感 78 | 'mode' => 0 // 验证码模式: 0 文字 1 公式 79 | ] 80 | 81 | ``` 82 | 83 | 84 | ### 二维码 85 | 86 | ```PHP 87 | use Zodream\Image\QrCode; 88 | 89 | $qr = new QrCode(); 90 | $image = $qr->encode('123123'); 91 | $image->save(); 92 | 93 | ``` 94 | 95 | 96 | ### 水印 97 | 98 | 文字水印 99 | 100 | ```php 101 | $image = new WaterMark(); 102 | $image->instance()->loadResource('1.jpg'); 103 | $image->addTextByDirection('water'); 104 | ``` 105 | 106 | 自定义水印 107 | ```php 108 | $image = new WaterMark(); 109 | $image->instance()->loadResource($file); 110 | $font = new Font((string)app_path(config('disk.font')), 12, '#fff'); 111 | $textBox = $image->instance()->fontSize($text, $font); 112 | // 根据文字的尺寸获取水印的位置 113 | list($x, $y) = $image->getPointByDirection(WaterMark::RightTop, $textBox->getWidth(), $textBox->getHeight(), 20); 114 | // 给文字添加阴影 115 | $image->addText($text, $x + 2, $y + 2, $font->getSize(), '#777', $font->getFile()); 116 | $image->addText($text, $x, $y, $font->getSize(), $font->getColor(), $font->getFile()); 117 | ``` 118 | 119 | 120 | ### 滑动验证码 121 | 122 | ```PHP 123 | use Zodream\Image\SlideCaptcha; 124 | 125 | $captcha = new SlideCaptcha(); 126 | $captcha->setConfigs([ 127 | 'width' => 300, 128 | 'height' => 130, 129 | ]); 130 | $captcha->instance()->open('bg.jpg'); 131 | $captcha->setShape('shape.jpg'); // 根据图片抠图 132 | $source = $captcha->generate(); 133 | 134 | $captcha->verify($_POST['captcha'], $source); 135 | 136 | $imgData = $captcha->toArray(); 137 | $html = ''; 138 | foreach ($imgData['imageItems'] as $point) { 139 | $html .= sprintf('
', $point['x'], $point['y']); 140 | } 141 | 142 | $html = << 144 | .slide-box { 145 | width: {$imgData['width']}px; 146 | height: {$imgData['height']}px; 147 | position: relative; 148 | } 149 | .slide-box .slide-img { 150 | float: left; 151 | margin: 0; 152 | padding: 0; 153 | background-image: url({$imgData['image']}); 154 | background-repeat: no-repeat; 155 | width: {$imgData['imageItems'][0]['width']}px; 156 | height: {$imgData['imageItems'][0]['height']}px; 157 | } 158 | .slide-box .slide-cut { 159 | position: absolute; 160 | top: {$imgData['controlY']}px; 161 | background-image: url({$imgData['control']}); 162 | background-repeat: no-repeat; 163 | width: {$imgData['controlWidth']}px; 164 | height: {$imgData['controlHeight']}px; 165 | z-index: 9; 166 | } 167 | 168 |
169 |
170 |
171 | {$html} 172 |
173 | 174 |
175 | HTML; 176 | 177 | ``` 178 | 179 | 180 | ### 点击验证码 181 | 依次点击图片上的文字 182 | 183 | ```php 184 | $captcha = new HintCaptcha(); 185 | $items = ['我', '就', '你', '哈']; 186 | $captcha->setConfigs([ 187 | 'width' => 300, 188 | 'height' => 130, 189 | 'fontSize' => 20, 190 | 'fontFamily' => 'Yahei.ttf', 191 | 'words' => $items, 192 | 'count' => 3, 193 | ]); 194 | $captcha->instance()->open('images/banner.jpg'); 195 | $source = $captcha->generate(); 196 | 197 | $captcha->verify($_POST['captcha'], $source); 198 | 199 | $imgData = $captcha->toArray(); 200 | ``` 201 | 202 | 203 | 204 | 205 | ## 内容生成图片 206 | 207 | ```php 208 | $str = << 10, 227 | 'background' => 'white', 228 | 'width' => 470 229 | ])->append( 230 | ImgNode::create($img, [ 231 | 'width' => '100%', 232 | 'height' => '100%' 233 | ]), 234 | TextNode::create('sbfajahaa', [ 235 | 'size' => 20, 236 | 'letterSpace' => 20, 237 | 'padding' => [ 238 | 10, 239 | 0, 240 | ], 241 | 'bold' => true, 242 | 'font' => $font 243 | ]), 244 | TextNode::create('1234avccg', [ 245 | 'size' => 12, 246 | 'font' => $font, 247 | 'letterSpace' => 4, 248 | 'lineSpace' => 4, 249 | 'color' => '#ccc' 250 | ]), 251 | ImgNode::create($img, [ 252 | 'width' => '100', 253 | 'height' => '100', 254 | 'center' => true 255 | ]), 256 | TextNode::create('sbfajahaa', [ 257 | 'size' => 12, 258 | 'color' => '#ccc', 259 | 'letterSpace' => 4, 260 | 'lineSpace' => 4, 261 | 'wrap' => false, 262 | 'font' => $font, 263 | 'center' => true 264 | ]), 265 | BorderNode::create([ 266 | 'size' => 1, 267 | 'fixed' => true, 268 | 'margin' => 10 269 | ]), 270 | LineNode::create(10, 10, 10, 100, [ 271 | 'size' => 1, 272 | 'fixed' => true, 273 | 'color' => 'black' 274 | ]), 275 | RectNode::create([ 276 | 'points' => [ 277 | [0, 0], 278 | [200, 0], 279 | [0, 200], 280 | ], 281 | 'color' => 'black' 282 | ]) 283 | ); 284 | $box->beginDraw()->show(); 285 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zodream/image", 3 | "description": "image, captcha", 4 | "keywords": ["image gd", "zodream"], 5 | "homepage": "https://github.com/zodream/image", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name" : "Jason Zou", 10 | "email" : "zodream@fixmail.com", 11 | "homepage" : "https://www.zodream.cn/", 12 | "role" : "Developer" 13 | } 14 | ], 15 | "require": { 16 | "php": ">=8.2", 17 | "khanamiryan/qrcode-detector-decoder": "^2.0.2", 18 | "bacon/bacon-qr-code": "^2.0.8" 19 | }, 20 | "suggest": { 21 | "ext-gd": "*", 22 | "ext-imagick": "*", 23 | "ext-gmagick": "*" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Zodream\\Image\\": "src/" 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/Adapters/AbstractImage.php: -------------------------------------------------------------------------------- 1 | array( 12 | 'jpg', 13 | 'jpeg', 14 | 'jpe', 15 | 'jpc', 16 | 'jpeg2000', 17 | 'jp2', 18 | 'jb2' 19 | ), 20 | 'webp' => 'webp', 21 | 'png' => 'png', 22 | 'gif' => 'gif', 23 | 'wbmp' => 'wbmp', 24 | 'xbm' => 'xbm', 25 | 'gd' => 'gd', 26 | 'gd2' => 'gd2' 27 | ); 28 | 29 | protected string|null $file = null; 30 | 31 | protected int $width; 32 | 33 | protected int $height; 34 | 35 | protected string|null $type; 36 | 37 | protected string|null $realType; 38 | 39 | protected $resource; 40 | 41 | public function loadResource(mixed $file) { 42 | if (is_null($file)) { 43 | return $this; 44 | } 45 | if ($file instanceof File) { 46 | $this->open((string)$file); 47 | return $this; 48 | } 49 | if (is_string($file) && is_file($file)) { 50 | $this->open($file); 51 | return $this; 52 | } 53 | if (is_string($file)) { 54 | $this->load($file); 55 | return $this; 56 | } 57 | $this->read($file); 58 | return $this; 59 | } 60 | 61 | /** 62 | * @return mixed 63 | */ 64 | public function getResource() { 65 | return $this->resource; 66 | } 67 | 68 | public function setEmptyImage() { 69 | $this->resource = null; 70 | return $this; 71 | } 72 | 73 | public function getHeight(): int { 74 | return $this->height; 75 | } 76 | 77 | public function getWidth(): int { 78 | return $this->width; 79 | } 80 | 81 | public function getSize(): BoxInterface { 82 | return new Box($this->getWidth(), $this->getHeight()); 83 | } 84 | 85 | public function getRealType(): string { 86 | return $this->realType; 87 | } 88 | 89 | /** 90 | * 设置真实类型 91 | * @param $type 92 | * @return static 93 | */ 94 | public function setRealType(string $type) { 95 | if (empty($type)) { 96 | return $this; 97 | } 98 | foreach (self::ALLOW_TYPES as $key => $item) { 99 | if ((!is_array($item) && $item == $type) 100 | || (is_array($item) && in_array($type, $item))) { 101 | $this->realType = $type; 102 | return $this; 103 | } 104 | } 105 | return $this; 106 | } 107 | 108 | public function save() { 109 | return $this->saveAs($this->file); 110 | } 111 | 112 | public function toBase64(): string { 113 | ob_start (); 114 | $this->saveAs(); 115 | $data = ob_get_contents(); 116 | ob_end_clean(); 117 | return 'data:image/'.$this->getRealType().';base64,'.base64_encode($data); 118 | } 119 | } -------------------------------------------------------------------------------- /src/Adapters/Gd.php: -------------------------------------------------------------------------------- 1 | getWidth(); 26 | $height = $size->getHeight(); 27 | 28 | $resource = imagecreatetruecolor($width, $height); 29 | 30 | if (false === $resource) { 31 | throw new RuntimeException('Create operation failed'); 32 | } 33 | 34 | if (empty($color)) { 35 | $color = '#fff'; 36 | } 37 | 38 | // $index = imagecolorallocatealpha($resource, $color->getRed(), $color->getGreen(), $color->getBlue(), round(127 * (100 - $color->getAlpha()) / 100)); 39 | // 40 | // if (false === $index) { 41 | // throw new RuntimeException('Unable to allocate color'); 42 | // } 43 | // 44 | // if (false === imagefill($resource, 0, 0, $index)) { 45 | // throw new RuntimeException('Could not set background color fill'); 46 | // } 47 | // 48 | // if ($color->getAlpha() <= 5) { 49 | // imagecolortransparent($resource, $index); 50 | // } 51 | $this->resource = $resource; 52 | $this->height = $height; 53 | $this->width = $width; 54 | $this->setRealType('jpeg'); 55 | $this->fill($color); 56 | return $this; 57 | } 58 | 59 | public function open(mixed $path) { 60 | $path = (string)$path; 61 | if (!$this->check($path)) { 62 | throw new \Exception('file error'); 63 | } 64 | $this->file = $path; 65 | $imageInfo = getimagesize($path); 66 | $this->width = $imageInfo[0]; 67 | $this->height = $imageInfo[1]; 68 | $this->type = empty($type) 69 | ? image_type_to_extension($imageInfo[2], false) 70 | : $type; 71 | $this->setRealType($this->type); 72 | if (false === $this->realType) { 73 | throw new \Exception('image type error'); 74 | } 75 | $resource = call_user_func('imagecreatefrom'.$this->realType, $path); 76 | $this->wrap($resource); 77 | return $this; 78 | } 79 | 80 | public function load($string) { 81 | $this->wrap(imagecreatefromstring($string)); 82 | $this->height = imagesy($this->resource); 83 | $this->width = imagesx($this->resource); 84 | return $this; 85 | } 86 | 87 | public function read($resource) { 88 | $this->wrap($resource); 89 | $this->height = imagesy($this->resource); 90 | $this->width = imagesx($this->resource); 91 | return $this; 92 | } 93 | 94 | protected function wrap($resource) { 95 | if (!imageistruecolor($resource)) { 96 | if (\function_exists('imagepalettetotruecolor')) { 97 | if (false === imagepalettetotruecolor($resource)) { 98 | throw new RuntimeException('Could not convert a palette based image to true color'); 99 | } 100 | } else { 101 | list($width, $height) = array(imagesx($resource), imagesy($resource)); 102 | 103 | // create transparent truecolor canvas 104 | $truecolor = imagecreatetruecolor($width, $height); 105 | $transparent = imagecolorallocatealpha($truecolor, 255, 255, 255, 127); 106 | 107 | imagealphablending($truecolor, false); 108 | imagefilledrectangle($truecolor, 0, 0, $width, $height, $transparent); 109 | imagealphablending($truecolor, false); 110 | 111 | imagecopy($truecolor, $resource, 0, 0, 0, 0, $width, $height); 112 | 113 | imagedestroy($resource); 114 | $resource = $truecolor; 115 | } 116 | } 117 | if (false === imagealphablending($resource, false) || false === imagesavealpha($resource, true)) { 118 | throw new RuntimeException('Could not set alphablending, savealpha and antialias values'); 119 | } 120 | if (\function_exists('imageantialias')) { 121 | imageantialias($resource, true); 122 | } 123 | $this->resource = $resource; 124 | if ($this->realType === 'png') { 125 | $this->transparent([0, 0, 0, 1]); 126 | } 127 | } 128 | 129 | final public function crop(PointInterface $start, BoxInterface $size) 130 | { 131 | if (!$start->in($this->getSize())) { 132 | throw new OutOfBoundsException('Crop coordinates must start at minimum 0, 0 position from top left corner, crop height and width must be positive integers and must not exceed the current image borders'); 133 | } 134 | 135 | $width = $size->getWidth(); 136 | $height = $size->getHeight(); 137 | 138 | $dest = $this->createImage($size, 'crop'); 139 | 140 | if (false === imagecopy($dest, $this->resource, 0, 0, $start->getX(), $start->getY(), $width, $height)) { 141 | imagedestroy($dest); 142 | throw new RuntimeException('Image crop operation failed'); 143 | } 144 | 145 | imagedestroy($this->resource); 146 | 147 | $this->resource = $dest; 148 | 149 | return $this; 150 | } 151 | 152 | final public function paste(ImageAdapter $image, PointInterface $start, int|float $alpha = 100) 153 | { 154 | if (!$image instanceof self) { 155 | throw new InvalidArgumentException(sprintf('Gd\Image can only paste() Gd\Image instances, %s given', get_class($image))); 156 | } 157 | 158 | $alpha = (int) round($alpha); 159 | if ($alpha < 0 || $alpha > 100) { 160 | throw new InvalidArgumentException(sprintf('The %1$s argument can range from %2$d to %3$d, but you specified %4$d.', '$alpha', 0, 100, $alpha)); 161 | } 162 | 163 | $size = $image->getSize(); 164 | 165 | if ($alpha === 100) { 166 | imagealphablending($this->resource, true); 167 | imagealphablending($image->resource, true); 168 | 169 | $success = imagecopy($this->resource, $image->resource, $start->getX(), $start->getY(), 0, 0, $size->getWidth(), $size->getHeight()); 170 | 171 | imagealphablending($this->resource, false); 172 | imagealphablending($image->resource, false); 173 | 174 | if ($success === false) { 175 | throw new RuntimeException('Image paste operation failed'); 176 | } 177 | } elseif ($alpha > 0) { 178 | if (false === imagecopymerge(/*dst_im*/$this->resource, /*src_im*/$image->resource, /*dst_x*/$start->getX(), /*dst_y*/$start->getY(), /*src_x*/0, /*src_y*/0, /*src_w*/$size->getWidth(), /*src_h*/$size->getHeight(), /*pct*/$alpha)) { 179 | throw new RuntimeException('Image paste operation failed'); 180 | } 181 | } 182 | 183 | return $this; 184 | } 185 | 186 | /** 187 | * 截取一部分图片放在什么位置 188 | * @param ImageAdapter $src 源图 189 | * @param PointInterface $srcStart 源图截取的位置 190 | * @param BoxInterface $srcBox 源图截取的大小 191 | * @param PointInterface $start 放在什么位置 192 | * @param BoxInterface|null $box 是否放大 193 | * @param int $alpha 未实现 194 | * @return Gd 195 | */ 196 | public function pastePart(ImageAdapter $src, PointInterface $srcStart, BoxInterface $srcBox, PointInterface $start, BoxInterface|null $box = null, int|float $alpha = 100) { 197 | if (!$src instanceof self) { 198 | throw new InvalidArgumentException(sprintf('Gd\Image can only paste() Gd\Image instances, %s given', get_class($src))); 199 | } 200 | 201 | $alpha = (int) round($alpha); 202 | if ($alpha < 0 || $alpha > 100) { 203 | throw new InvalidArgumentException(sprintf('The %1$s argument can range from %2$d to %3$d, but you specified %4$d.', '$alpha', 0, 100, $alpha)); 204 | } 205 | if ($box === null) { 206 | $box = $srcBox; 207 | } 208 | 209 | imagealphablending($this->resource, true); 210 | imagealphablending($src->resource, true); 211 | 212 | $success = imagecopyresampled($this->resource, $src->resource, 213 | $start->getX(), $start->getY(), $srcStart->getX(), $srcStart->getY(), 214 | $box->getWidth(), $box->getHeight(), $srcBox->getWidth(), $srcBox->getHeight()); 215 | 216 | imagealphablending($this->resource, false); 217 | imagealphablending($src->resource, false); 218 | 219 | if ($success === false) { 220 | throw new RuntimeException('Image paste operation failed'); 221 | } 222 | return $this; 223 | } 224 | 225 | public function thumbnail(BoxInterface $box) { 226 | return $this->scale($box); 227 | } 228 | 229 | final public function resize(BoxInterface $size, $filter = ImageAdapter::FILTER_UNDEFINED) 230 | { 231 | if (ImageAdapter::FILTER_UNDEFINED !== $filter) { 232 | throw new InvalidArgumentException('Unsupported filter type, GD only supports ImageInterface::FILTER_UNDEFINED filter'); 233 | } 234 | 235 | $width = $size->getWidth(); 236 | $height = $size->getHeight(); 237 | 238 | $dest = $this->createImage($size, 'resize'); 239 | 240 | imagealphablending($this->resource, true); 241 | imagealphablending($dest, true); 242 | 243 | $success = imagecopyresampled($dest, $this->resource, 0, 0, 0, 0, $width, $height, imagesx($this->resource), imagesy($this->resource)); 244 | 245 | imagealphablending($this->resource, false); 246 | imagealphablending($dest, false); 247 | 248 | if ($success === false) { 249 | imagedestroy($dest); 250 | throw new RuntimeException('Image resize operation failed'); 251 | } 252 | 253 | imagedestroy($this->resource); 254 | 255 | $this->resource = $dest; 256 | 257 | return $this; 258 | } 259 | 260 | final public function rotate(int|float $angle, mixed $background = null) 261 | { 262 | if ($background === null) { 263 | $background = '#fff'; 264 | } 265 | $color = $this->converterToColor($background); 266 | $resource = imagerotate($this->resource, -1 * $angle, $color); 267 | 268 | if (false === $resource) { 269 | throw new RuntimeException('Image rotate operation failed'); 270 | } 271 | 272 | imagedestroy($this->resource); 273 | $this->resource = $resource; 274 | 275 | return $this; 276 | } 277 | 278 | public function scale(BoxInterface $box) { 279 | $resource = imagecreatetruecolor($box->getWidth(), $box->getHeight()); 280 | $size = $this->getSize(); 281 | imagecopyresampled($resource, $this->resource, 0, 0, 0, 0, 282 | $box->getWidth(), $box->getHeight(), $size->getWidth(), $size->getHeight()); 283 | $this->close(); 284 | $this->read($resource); 285 | return $this; 286 | } 287 | 288 | public function copy() { 289 | return clone $this; 290 | } 291 | 292 | public function fill(mixed $fill) { 293 | $size = $this->getSize(); 294 | 295 | if (is_string($fill) || is_array($fill)) { 296 | imagefilledrectangle( 297 | $this->resource, 298 | 0, 299 | $size->getHeight(), 300 | $size->getWidth(), 301 | 0, 302 | $this->converterToColor($fill) 303 | ); 304 | return $this; 305 | } 306 | if (!$fill instanceof ImageAdapter) { 307 | throw new RuntimeException('Fill operation failed'); 308 | } 309 | for ($x = 0, $width = $size->getWidth(); $x < $width; $x++) { 310 | for ($y = 0, $height = $size->getHeight(); $y < $height; $y++) { 311 | if (false === imagesetpixel($this->resource, $x, $y, 312 | $fill->getColorAt(new Point($x, $y)))) { 313 | throw new RuntimeException('Fill operation failed'); 314 | } 315 | } 316 | } 317 | return $this; 318 | } 319 | 320 | 321 | public function arc(PointInterface $center, BoxInterface $size, int $start, int $end, $color, int $thickness = 1) 322 | { 323 | $thickness = max(0, (int) round($thickness)); 324 | if ($thickness === 0) { 325 | return $this; 326 | } 327 | imagesetthickness($this->resource, $thickness); 328 | 329 | if (false === imagealphablending($this->resource, true)) { 330 | throw new RuntimeException('Draw arc operation failed'); 331 | } 332 | 333 | if (false === imagearc($this->resource, $center->getX(), $center->getY(), $size->getWidth(), $size->getHeight(), $start, $end, $this->converterToColor($color))) { 334 | imagealphablending($this->resource, false); 335 | throw new RuntimeException('Draw arc operation failed'); 336 | } 337 | 338 | if (false === imagealphablending($this->resource, false)) { 339 | throw new RuntimeException('Draw arc operation failed'); 340 | } 341 | 342 | return $this; 343 | } 344 | 345 | public function chord(PointInterface $center, BoxInterface $size, int $start, int $end, $color, bool $fill = false, int $thickness = 1) 346 | { 347 | $thickness = max(0, (int) round($thickness)); 348 | if ($thickness === 0 && !$fill) { 349 | return $this; 350 | } 351 | imagesetthickness($this->resource, $thickness); 352 | 353 | if (false === imagealphablending($this->resource, true)) { 354 | throw new RuntimeException('Draw chord operation failed'); 355 | } 356 | 357 | if ($fill) { 358 | $style = IMG_ARC_CHORD; 359 | if (false === imagefilledarc($this->resource, $center->getX(), $center->getY(), $size->getWidth(), $size->getHeight(), $start, $end, $this->converterToColor($color), $style)) { 360 | imagealphablending($this->resource, false); 361 | throw new RuntimeException('Draw chord operation failed'); 362 | } 363 | } else { 364 | foreach (array(IMG_ARC_NOFILL, IMG_ARC_NOFILL | IMG_ARC_CHORD) as $style) { 365 | if (false === imagefilledarc($this->resource, $center->getX(), $center->getY(), $size->getWidth(), $size->getHeight(), $start, $end, $this->converterToColor($color), $style)) { 366 | imagealphablending($this->resource, false); 367 | throw new RuntimeException('Draw chord operation failed'); 368 | } 369 | } 370 | } 371 | 372 | if (false === imagealphablending($this->resource, false)) { 373 | throw new RuntimeException('Draw chord operation failed'); 374 | } 375 | 376 | return $this; 377 | } 378 | 379 | public function circle(PointInterface $center, int|float $radius, $color, bool $fill = false, int $thickness = 1) 380 | { 381 | $diameter = $radius * 2; 382 | 383 | return $this->ellipse($center, new Box($diameter, $diameter), $color, $fill, $thickness); 384 | } 385 | 386 | public function ellipse(PointInterface $center, BoxInterface $size, $color, bool $fill = false, int $thickness = 1) 387 | { 388 | $thickness = max(0, (int) round($thickness)); 389 | if ($thickness === 0 && !$fill) { 390 | return $this; 391 | } 392 | if (function_exists('imageantialias')) { 393 | imageantialias($this->resource, true); 394 | } 395 | imagesetthickness($this->resource, $thickness); 396 | 397 | if ($fill) { 398 | $callback = 'imagefilledellipse'; 399 | } else { 400 | $callback = 'imageellipse'; 401 | } 402 | 403 | if (function_exists('imageantialias')) { 404 | imageantialias($this->resource, true); 405 | } 406 | if (false === imagealphablending($this->resource, true)) { 407 | throw new RuntimeException('Draw ellipse operation failed'); 408 | } 409 | 410 | if (function_exists('imageantialias')) { 411 | imageantialias($this->resource, true); 412 | } 413 | if (false === $callback($this->resource, $center->getX(), $center->getY(), $size->getWidth(), $size->getHeight(), $this->converterToColor($color))) { 414 | imagealphablending($this->resource, false); 415 | throw new RuntimeException('Draw ellipse operation failed'); 416 | } 417 | 418 | if (false === imagealphablending($this->resource, false)) { 419 | throw new RuntimeException('Draw ellipse operation failed'); 420 | } 421 | 422 | return $this; 423 | } 424 | 425 | public function line(PointInterface $start, PointInterface $end, $outline, int $thickness = 1) 426 | { 427 | $thickness = max(0, (int) round($thickness)); 428 | if ($thickness === 0) { 429 | return $this; 430 | } 431 | imagesetthickness($this->resource, $thickness); 432 | 433 | if (false === imagealphablending($this->resource, true)) { 434 | throw new RuntimeException('Draw line operation failed'); 435 | } 436 | 437 | if (false === imageline($this->resource, $start->getX(), $start->getY(), $end->getX(), $end->getY(), $this->converterToColor($outline))) { 438 | imagealphablending($this->resource, false); 439 | throw new RuntimeException('Draw line operation failed'); 440 | } 441 | 442 | if (false === imagealphablending($this->resource, false)) { 443 | throw new RuntimeException('Draw line operation failed'); 444 | } 445 | 446 | return $this; 447 | } 448 | 449 | public function pieSlice(PointInterface $center, BoxInterface $size, int $start, int $end, $color, bool $fill = false, int $thickness = 1) 450 | { 451 | $thickness = max(0, (int) round($thickness)); 452 | if ($thickness === 0 && !$fill) { 453 | return $this; 454 | } 455 | imagesetthickness($this->resource, $thickness); 456 | 457 | if ($fill) { 458 | $style = IMG_ARC_EDGED; 459 | } else { 460 | $style = IMG_ARC_EDGED | IMG_ARC_NOFILL; 461 | } 462 | 463 | if (false === imagealphablending($this->resource, true)) { 464 | throw new RuntimeException('Draw chord operation failed'); 465 | } 466 | 467 | if (false === imagefilledarc($this->resource, $center->getX(), $center->getY(), $size->getWidth(), $size->getHeight(), $start, $end, $this->converterToColor($color), $style)) { 468 | imagealphablending($this->resource, false); 469 | throw new RuntimeException('Draw chord operation failed'); 470 | } 471 | 472 | if (false === imagealphablending($this->resource, false)) { 473 | throw new RuntimeException('Draw chord operation failed'); 474 | } 475 | 476 | return $this; 477 | } 478 | 479 | public function dot(PointInterface $position, $color) 480 | { 481 | if (false === imagealphablending($this->resource, true)) { 482 | throw new RuntimeException('Draw point operation failed'); 483 | } 484 | 485 | if (false === imagesetpixel($this->resource, $position->getX(), $position->getY(), $this->converterToColor($color))) { 486 | imagealphablending($this->resource, false); 487 | throw new RuntimeException('Draw point operation failed'); 488 | } 489 | 490 | if (false === imagealphablending($this->resource, false)) { 491 | throw new RuntimeException('Draw point operation failed'); 492 | } 493 | 494 | return $this; 495 | } 496 | 497 | public function rectangle(PointInterface $leftTop, PointInterface $rightBottom, $color, bool $fill = false, int $thickness = 1) 498 | { 499 | $thickness = max(0, (int) round($thickness)); 500 | if ($thickness === 0 && !$fill) { 501 | return $this; 502 | } 503 | imagesetthickness($this->resource, $thickness); 504 | 505 | $minX = min($leftTop->getX(), $rightBottom->getX()); 506 | $maxX = max($leftTop->getX(), $rightBottom->getX()); 507 | $minY = min($leftTop->getY(), $rightBottom->getY()); 508 | $maxY = max($leftTop->getY(), $rightBottom->getY()); 509 | 510 | if ($fill) { 511 | $callback = 'imagefilledrectangle'; 512 | } else { 513 | $callback = 'imagerectangle'; 514 | } 515 | 516 | if (false === imagealphablending($this->resource, true)) { 517 | throw new RuntimeException('Draw polygon operation failed'); 518 | } 519 | 520 | if (false === $callback($this->resource, $minX, $minY, $maxX, $maxY, $this->converterToColor($color))) { 521 | imagealphablending($this->resource, false); 522 | throw new RuntimeException('Draw polygon operation failed'); 523 | } 524 | 525 | if (false === imagealphablending($this->resource, false)) { 526 | throw new RuntimeException('Draw polygon operation failed'); 527 | } 528 | 529 | return $this; 530 | } 531 | 532 | public function polygon(array $coordinates, $color, bool $fill = false, int $thickness = 1) 533 | { 534 | $thickness = max(0, (int) round($thickness)); 535 | if ($thickness === 0 && !$fill) { 536 | return $this; 537 | } 538 | imagesetthickness($this->resource, $thickness); 539 | 540 | if (count($coordinates) < 3) { 541 | throw new InvalidArgumentException(sprintf('A polygon must consist of at least 3 points, %d given', count($coordinates))); 542 | } 543 | 544 | $points = call_user_func_array('array_merge', array_map(function (PointInterface $p) { 545 | return array($p->getX(), $p->getY()); 546 | }, $coordinates)); 547 | 548 | if ($fill) { 549 | $callback = 'imagefilledpolygon'; 550 | } else { 551 | $callback = 'imagepolygon'; 552 | } 553 | 554 | if (false === imagealphablending($this->resource, true)) { 555 | throw new RuntimeException('Draw polygon operation failed'); 556 | } 557 | 558 | if (false === $callback($this->resource, $points, count($coordinates), $this->converterToColor($color))) { 559 | imagealphablending($this->resource, false); 560 | throw new RuntimeException('Draw polygon operation failed'); 561 | } 562 | 563 | if (false === imagealphablending($this->resource, false)) { 564 | throw new RuntimeException('Draw polygon operation failed'); 565 | } 566 | 567 | return $this; 568 | } 569 | 570 | public function text(string $string, FontInterface $font, PointInterface $position, int|float $angle = 0, int $width = 0) 571 | { 572 | $angle = -1 * $angle; 573 | $fontsize = $font->getSize(); 574 | $fontFile = $font->getFile(); 575 | $x = $position->getX(); 576 | $y = $position->getY();// + $fontsize; 577 | 578 | if ($width !== 0) { 579 | $string = $font->wrapText($string, $width, $angle); 580 | } 581 | 582 | // if (false === imagealphablending($this->resource, true)) { 583 | // throw new RuntimeException('Font mask operation failed'); 584 | // } 585 | 586 | if (!is_numeric($fontFile) && $fontFile && DIRECTORY_SEPARATOR === '\\') { 587 | // On Windows imagefttext() throws a "Could not find/open font" error if $fontfile is not an absolute path. 588 | $fontFileRealpath = realpath($fontFile); 589 | if ($fontFileRealpath !== false) { 590 | $fontFile = $fontFileRealpath; 591 | } 592 | } 593 | if (is_numeric($fontFile)) { 594 | //$y -= $fontsize; 595 | if (false === imagestring($this->resource, intval($fontFile), $x, $y, $string, $this->converterToColor($font->getColor()))) { 596 | imagealphablending($this->resource, false); 597 | throw new RuntimeException('Font mask operation failed'); 598 | } 599 | } else if (false === imagefttext($this->resource, $fontsize, $angle, $x, $y, $this->converterToColor($font->getColor()), $fontFile, $string)) { 600 | imagealphablending($this->resource, false); 601 | throw new RuntimeException('Font mask operation failed'); 602 | } 603 | if (false === imagealphablending($this->resource, false)) { 604 | throw new RuntimeException('Font mask operation failed'); 605 | } 606 | 607 | return $this; 608 | } 609 | 610 | public function char(string|int $code, FontInterface $font, PointInterface $position, 611 | int|float $angle = 0) { 612 | return $this->text(is_int($code) ? 613 | mb_chr($code)// sprintf('&#%d;', $code) 614 | : $code, $font, $position, $angle, 0); 615 | // $fontFile = is_numeric($font->getFile()) ? intval($font->getFile()) : 616 | // imageloadfont($font->getFile()); 617 | // if ($fontFile === false) { 618 | // throw new \Exception('font is error'); 619 | // } 620 | // if (false === imagechar($this->resource, $fontFile, $position->getX(), 621 | // $position->getY(), is_int($code) ? mb_chr($code, 'UTF-8') : $code, 622 | // $this->converterToColor($font->getColor()))) { 623 | // throw new RuntimeException('Font mask operation failed'); 624 | // } 625 | // return $this; 626 | } 627 | 628 | public function fontSize(string $string, FontInterface $font, int|float $angle = 0) { 629 | $box = imagettfbbox($font->getSize(), $angle, $font->getFile(), $string); 630 | return new Box(abs($box[4] - $box[0]), abs($box[5] - $box[1])); 631 | } 632 | 633 | /** 634 | * 将某个颜色定义为透明色 635 | * @param $color 636 | * @return static 637 | */ 638 | public function transparent($color) { 639 | imagecolortransparent($this->resource, $this->converterToColor($color)); 640 | imagealphablending($this->resource, false); 641 | imagesavealpha($this->resource, true); 642 | return $this; 643 | } 644 | 645 | public function gamma(float $correction) 646 | { 647 | if (false === imagegammacorrect($this->resource, 1.0, $correction)) { 648 | throw new RuntimeException('Failed to apply gamma correction to the image'); 649 | } 650 | 651 | return $this; 652 | } 653 | 654 | public function negative() 655 | { 656 | if (false === imagefilter($this->resource, IMG_FILTER_NEGATE)) { 657 | throw new RuntimeException('Failed to negate the image'); 658 | } 659 | 660 | return $this; 661 | } 662 | 663 | public function grayscale() 664 | { 665 | if (false === imagefilter($this->resource, IMG_FILTER_GRAYSCALE)) { 666 | throw new RuntimeException('Failed to grayscale the image'); 667 | } 668 | 669 | return $this; 670 | } 671 | 672 | public function colorize($color) 673 | { 674 | $color = Colors::converter(...func_get_args()); 675 | if (false === imagefilter($this->resource, IMG_FILTER_COLORIZE, $color[0], $color[1], $color[2])) { 676 | throw new RuntimeException('Failed to colorize the image'); 677 | } 678 | return $this; 679 | } 680 | 681 | public function sharpen() 682 | { 683 | $sharpenMatrix = array(array(-1, -1, -1), array(-1, 16, -1), array(-1, -1, -1)); 684 | $divisor = array_sum(array_map('array_sum', $sharpenMatrix)); 685 | 686 | if (false === imageconvolution($this->resource, $sharpenMatrix, $divisor, 0)) { 687 | throw new RuntimeException('Failed to sharpen the image'); 688 | } 689 | 690 | return $this; 691 | } 692 | 693 | public function blur(float $sigma = 1) 694 | { 695 | if (false === imagefilter($this->resource, IMG_FILTER_GAUSSIAN_BLUR)) { 696 | throw new RuntimeException('Failed to blur the image'); 697 | } 698 | 699 | return $this; 700 | } 701 | 702 | public function brightness(float $brightness) 703 | { 704 | $gdBrightness = (int) round($brightness / 100 * 255); 705 | if ($gdBrightness < -255 || $gdBrightness > 255) { 706 | throw new InvalidArgumentException(sprintf('The %1$s argument can range from %2$d to %3$d, but you specified %4$d.', '$brightness', -100, 100, $brightness)); 707 | } 708 | if (false === imagefilter($this->resource, IMG_FILTER_BRIGHTNESS, $gdBrightness)) { 709 | throw new RuntimeException('Failed to brightness the image'); 710 | } 711 | 712 | return $this; 713 | } 714 | 715 | public function convolve(Matrix $matrix) 716 | { 717 | if ($matrix->getWidth() !== 3 || $matrix->getHeight() !== 3) { 718 | throw new InvalidArgumentException(sprintf('A convolution matrix must be 3x3 (%dx%d provided).', $matrix->getWidth(), $matrix->getHeight())); 719 | } 720 | if (false === imageconvolution($this->resource, $matrix->getMatrix(), 1, 0)) { 721 | throw new RuntimeException('Failed to convolve the image'); 722 | } 723 | 724 | return $this; 725 | } 726 | 727 | public function converterToColor(mixed $color): mixed { 728 | $color = Colors::converter(...func_get_args()); 729 | if (is_integer($color)) { 730 | return $color; 731 | } 732 | return imagecolorallocate($this->resource, (int)$color[0], (int)$color[1], (int)$color[2]); 733 | } 734 | 735 | public function converterFromColor(mixed $color): array { 736 | $result = imagecolorsforindex($this->resource, $color); 737 | return array( 738 | $result['red'], 739 | $result['green'], 740 | $result['blue'], 741 | $result['alpha'], 742 | ); 743 | } 744 | 745 | public function getColorAt(PointInterface $point) { 746 | if (!$point->in($this->getSize())) { 747 | throw new RuntimeException(sprintf('Error getting color at point [%s,%s]. The point must be inside the image of size [%s,%s]', $point->getX(), $point->getY(), $this->getSize()->getWidth(), $this->getSize()->getHeight())); 748 | } 749 | 750 | return imagecolorat($this->resource, $point->getX(), $point->getY()); 751 | //$info = imagecolorsforindex($this->resource, $index); 752 | 753 | //return $this->palette->color(array($info['red'], $info['green'], $info['blue']), max(min(100 - (int) round($info['alpha'] / 127 * 100), 100), 0)); 754 | } 755 | 756 | /** 757 | * 另存为 758 | * @param string|null $output 如果为null 表示输出 759 | * @param string $type 760 | * @return bool 761 | */ 762 | public function saveAs(mixed $output = null, string $type = ''): bool { 763 | $this->setRealType($type); 764 | if (!is_null($output)) { 765 | $output = (string)$output; 766 | } 767 | return call_user_func('image'.$this->realType, $this->resource, $output); 768 | } 769 | 770 | public function close() { 771 | if (is_resource($this->resource) && 'gd' === get_resource_type($this->resource)) { 772 | imagedestroy($this->resource); 773 | } 774 | $this->resource = null; 775 | } 776 | 777 | public function __destruct() { 778 | $this->close(); 779 | } 780 | 781 | public function __clone() { 782 | $size = $this->getSize(); 783 | $copy = $this->createImage($size, 'copy'); 784 | if (false === imagecopy($copy, $this->resource, 0, 0, 0, 0, $size->getWidth(), $size->getHeight())) { 785 | imagedestroy($copy); 786 | throw new RuntimeException('Image copy operation failed'); 787 | } 788 | $this->resource = $copy; 789 | } 790 | 791 | protected function check($file) { 792 | return is_file($file) && getimagesize($file) && extension_loaded('gd'); 793 | } 794 | 795 | private function createImage(BoxInterface $size, $operation) 796 | { 797 | $resource = imagecreatetruecolor($size->getWidth(), $size->getHeight()); 798 | 799 | if (false === $resource) { 800 | throw new RuntimeException('Image ' . $operation . ' failed'); 801 | } 802 | 803 | if (false === imagealphablending($resource, false) || false === imagesavealpha($resource, true)) { 804 | throw new RuntimeException('Image ' . $operation . ' failed'); 805 | } 806 | 807 | if (function_exists('imageantialias')) { 808 | imageantialias($resource, true); 809 | } 810 | 811 | $transparent = imagecolorallocatealpha($resource, 255, 255, 255, 127); 812 | imagefill($resource, 0, 0, $transparent); 813 | imagecolortransparent($resource, $transparent); 814 | 815 | return $resource; 816 | } 817 | } -------------------------------------------------------------------------------- /src/Adapters/Gmagick.php: -------------------------------------------------------------------------------- 1 | resource = new \Gmagick((string)$path); 32 | } catch (\GmagickException $e) { 33 | throw new RuntimeException(sprintf('Unable to open image %s', $path), $e->getCode(), $e); 34 | } 35 | 36 | return $this; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | * 42 | */ 43 | public function create(BoxInterface $size, $color = null) 44 | { 45 | $width = $size->getWidth(); 46 | $height = $size->getHeight(); 47 | 48 | if (empty($color)) { 49 | $color = '#fff'; 50 | } 51 | try { 52 | $gmagick = new \Gmagick(); 53 | $pixel = $this->converterToColor($color); 54 | 55 | $gmagick->newimage($width, $height, $pixel->getcolor(false)); 56 | $gmagick->setimagecolorspace(\Gmagick::COLORSPACE_TRANSPARENT); 57 | $gmagick->setimagebackgroundcolor($pixel); 58 | 59 | $this->resource = $gmagick; 60 | $this->refreshMeta(); 61 | return $this; 62 | } catch (\GmagickException $e) { 63 | throw new RuntimeException('Could not create empty image', $e->getCode(), $e); 64 | } 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | * 70 | */ 71 | public function load($string) { 72 | try { 73 | $imagick = new \Gmagick(); 74 | 75 | $imagick->readImageBlob($string); 76 | $this->resource = $imagick; 77 | $this->refreshMeta(); 78 | return $this; 79 | } catch (\GmagickException $e) { 80 | throw new RuntimeException('Could not load image from string', $e->getCode(), $e); 81 | } 82 | } 83 | 84 | /** 85 | * {@inheritdoc} 86 | * 87 | */ 88 | public function read($resource) 89 | { 90 | if (!is_resource($resource)) { 91 | throw new InvalidArgumentException('Variable does not contain a stream resource'); 92 | } 93 | 94 | $content = stream_get_contents($resource); 95 | 96 | if (false === $content) { 97 | throw new InvalidArgumentException('Couldn\'t read given resource'); 98 | } 99 | 100 | try { 101 | $imagick = new \Gmagick(); 102 | $imagick->readImageBlob($content); 103 | $this->resource = $imagick; 104 | $this->refreshMeta(); 105 | } catch (\GmagickException $e) { 106 | throw new RuntimeException('Could not read image from resource', $e->getCode(), $e); 107 | } 108 | 109 | return $this; 110 | } 111 | 112 | protected function refreshMeta() { 113 | $this->width = $this->resource->getImageWidth(); 114 | $this->height = $this->resource->getImageHeight(); 115 | if (empty($this->realType)) { 116 | $this->setRealType('png'); 117 | } 118 | } 119 | 120 | 121 | /** 122 | * {@inheritdoc} 123 | * 124 | */ 125 | public function arc(PointInterface $center, BoxInterface $size, int $start, int $end, $color, int $thickness = 1) 126 | { 127 | $thickness = max(0, (int) round($thickness)); 128 | if ($thickness === 0) { 129 | return $this; 130 | } 131 | $x = $center->getX(); 132 | $y = $center->getY(); 133 | $width = $size->getWidth(); 134 | $height = $size->getHeight(); 135 | 136 | try { 137 | $pixel = $this->converterToColor($color); 138 | $arc = new \GmagickDraw(); 139 | 140 | $arc->setstrokecolor($pixel); 141 | $arc->setstrokewidth($thickness); 142 | $arc->setfillcolor('transparent'); 143 | $arc->arc( 144 | $x - $width / 2, 145 | $y - $height / 2, 146 | $x + $width / 2, 147 | $y + $height / 2, 148 | $start, 149 | $end 150 | ); 151 | 152 | $this->resource->drawImage($arc); 153 | 154 | $pixel = null; 155 | 156 | $arc = null; 157 | } catch (\GmagickException $e) { 158 | throw new RuntimeException('Draw arc operation failed', $e->getCode(), $e); 159 | } 160 | 161 | return $this; 162 | } 163 | 164 | /** 165 | * {@inheritdoc} 166 | * 167 | */ 168 | public function chord(PointInterface $center, BoxInterface $size, int $start, int $end, $color, bool $fill = false, int $thickness = 1) 169 | { 170 | $thickness = max(0, (int) round($thickness)); 171 | if ($thickness === 0 && !$fill) { 172 | return $this; 173 | } 174 | $x = $center->getX(); 175 | $y = $center->getY(); 176 | $width = $size->getWidth(); 177 | $height = $size->getHeight(); 178 | 179 | try { 180 | $pixel = $this->converterToColor($color); 181 | $chord = new \GmagickDraw(); 182 | 183 | $chord->setstrokecolor($pixel); 184 | $chord->setstrokewidth($thickness); 185 | 186 | if ($fill) { 187 | $chord->setfillcolor($pixel); 188 | } else { 189 | $x1 = (int)round($x + $width / 2 * cos(deg2rad($start))); 190 | $y1 = (int)round($y + $height / 2 * sin(deg2rad($start))); 191 | $x2 = (int)round($x + $width / 2 * cos(deg2rad($end))); 192 | $y2 = (int)round($y + $height / 2 * sin(deg2rad($end))); 193 | 194 | $this->line(new Point($x1, $y1), new Point($x2, $y2), $color, $thickness); 195 | 196 | $chord->setfillcolor('transparent'); 197 | } 198 | 199 | $chord->arc($x - $width / 2, $y - $height / 2, $x + $width / 2, $y + $height / 2, $start, $end); 200 | 201 | $this->resource->drawImage($chord); 202 | 203 | $pixel = null; 204 | 205 | $chord = null; 206 | } catch (\GmagickException $e) { 207 | throw new RuntimeException('Draw chord operation failed', $e->getCode(), $e); 208 | } 209 | 210 | return $this; 211 | } 212 | 213 | /** 214 | * {@inheritdoc} 215 | * 216 | */ 217 | public function circle(PointInterface $center, int|float $radius, $color, bool $fill = false, int $thickness = 1) 218 | { 219 | $diameter = $radius * 2; 220 | 221 | return $this->ellipse($center, new Box($diameter, $diameter), $color, $fill, $thickness); 222 | } 223 | 224 | /** 225 | * {@inheritdoc} 226 | * 227 | */ 228 | public function ellipse(PointInterface $center, BoxInterface $size, $color, bool $fill = false, int $thickness = 1) 229 | { 230 | $thickness = max(0, (int) round($thickness)); 231 | if ($thickness === 0 && !$fill) { 232 | return $this; 233 | } 234 | $width = $size->getWidth(); 235 | $height = $size->getHeight(); 236 | 237 | try { 238 | $pixel = $this->converterToColor($color); 239 | $ellipse = new \GmagickDraw(); 240 | 241 | $ellipse->setstrokecolor($pixel); 242 | $ellipse->setstrokewidth($thickness); 243 | 244 | if ($fill) { 245 | $ellipse->setfillcolor($pixel); 246 | } else { 247 | $ellipse->setfillcolor('transparent'); 248 | } 249 | 250 | $ellipse->ellipse( 251 | $center->getX(), 252 | $center->getY(), 253 | $width / 2, 254 | $height / 2, 255 | 0, 360 256 | ); 257 | 258 | $this->resource->drawImage($ellipse); 259 | 260 | $pixel = null; 261 | 262 | $ellipse = null; 263 | } catch (\GmagickException $e) { 264 | throw new RuntimeException('Draw ellipse operation failed', $e->getCode(), $e); 265 | } 266 | 267 | return $this; 268 | } 269 | 270 | /** 271 | * {@inheritdoc} 272 | * 273 | */ 274 | public function line(PointInterface $start, PointInterface $end, $outline, int $thickness = 1) 275 | { 276 | $thickness = max(0, (int) round($thickness)); 277 | if ($thickness === 0) { 278 | return $this; 279 | } 280 | try { 281 | $pixel = $this->converterToColor($outline); 282 | $line = new \GmagickDraw(); 283 | 284 | $line->setstrokecolor($pixel); 285 | $line->setstrokewidth($thickness); 286 | $line->setfillcolor($pixel); 287 | $line->line( 288 | $start->getX(), 289 | $start->getY(), 290 | $end->getX(), 291 | $end->getY() 292 | ); 293 | 294 | $this->resource->drawImage($line); 295 | 296 | $pixel = null; 297 | 298 | $line = null; 299 | } catch (\GmagickException $e) { 300 | throw new RuntimeException('Draw line operation failed', $e->getCode(), $e); 301 | } 302 | 303 | return $this; 304 | } 305 | 306 | /** 307 | * {@inheritdoc} 308 | * 309 | */ 310 | public function pieSlice(PointInterface $center, BoxInterface $size, int $start, int $end, $color, bool $fill = false, int $thickness = 1) 311 | { 312 | $thickness = max(0, (int) round($thickness)); 313 | if ($thickness === 0 && !$fill) { 314 | return $this; 315 | } 316 | $width = $size->getWidth(); 317 | $height = $size->getHeight(); 318 | 319 | $x1 = (int)round($center->getX() + $width / 2 * cos(deg2rad($start))); 320 | $y1 = (int)round($center->getY() + $height / 2 * sin(deg2rad($start))); 321 | $x2 = (int)round($center->getX() + $width / 2 * cos(deg2rad($end))); 322 | $y2 = (int)round($center->getY() + $height / 2 * sin(deg2rad($end))); 323 | 324 | if ($fill) { 325 | $this->chord($center, $size, $start, $end, $color, true, $thickness); 326 | $this->polygon( 327 | array( 328 | $center, 329 | new Point($x1, $y1), 330 | new Point($x2, $y2), 331 | ), 332 | $color, 333 | true, 334 | $thickness 335 | ); 336 | } else { 337 | $this->arc($center, $size, $start, $end, $color, $thickness); 338 | $this->line($center, new Point($x1, $y1), $color, $thickness); 339 | $this->line($center, new Point($x2, $y2), $color, $thickness); 340 | } 341 | 342 | return $this; 343 | } 344 | 345 | /** 346 | * {@inheritdoc} 347 | * 348 | */ 349 | public function dot(PointInterface $position, $color) 350 | { 351 | $x = $position->getX(); 352 | $y = $position->getY(); 353 | 354 | try { 355 | $pixel = $this->converterToColor($color); 356 | $point = new \GmagickDraw(); 357 | 358 | $point->setfillcolor($pixel); 359 | $point->point($x, $y); 360 | 361 | $this->resource->drawimage($point); 362 | 363 | $pixel = null; 364 | $point = null; 365 | } catch (\GmagickException $e) { 366 | throw new RuntimeException('Draw point operation failed', $e->getCode(), $e); 367 | } 368 | 369 | return $this; 370 | } 371 | 372 | /** 373 | * {@inheritdoc} 374 | * 375 | */ 376 | public function rectangle(PointInterface $leftTop, PointInterface $rightBottom, $color, bool $fill = false, int $thickness = 1) 377 | { 378 | $thickness = max(0, (int) round($thickness)); 379 | if ($thickness === 0 && !$fill) { 380 | return $this; 381 | } 382 | $minX = min($leftTop->getX(), $rightBottom->getX()); 383 | $maxX = max($leftTop->getX(), $rightBottom->getX()); 384 | $minY = min($leftTop->getY(), $rightBottom->getY()); 385 | $maxY = max($leftTop->getY(), $rightBottom->getY()); 386 | 387 | try { 388 | $pixel = $this->converterToColor($color); 389 | $rectangle = new \GmagickDraw(); 390 | 391 | $rectangle->setstrokecolor($pixel); 392 | $rectangle->setstrokewidth($thickness); 393 | 394 | if ($fill) { 395 | $rectangle->setfillcolor($pixel); 396 | } else { 397 | $rectangle->setfillcolor('transparent'); 398 | } 399 | $rectangle->rectangle($minX, $minY, $maxX, $maxY); 400 | $this->resource->drawImage($rectangle); 401 | } catch (\GmagickException $e) { 402 | throw new RuntimeException('Draw polygon operation failed', $e->getCode(), $e); 403 | } 404 | 405 | return $this; 406 | } 407 | 408 | /** 409 | * {@inheritdoc} 410 | * 411 | */ 412 | public function polygon(array $coordinates, $color, bool $fill = false, int $thickness = 1) 413 | { 414 | if (count($coordinates) < 3) { 415 | throw new InvalidArgumentException(sprintf('Polygon must consist of at least 3 coordinates, %d given', count($coordinates))); 416 | } 417 | $thickness = max(0, (int) round($thickness)); 418 | if ($thickness === 0 && !$fill) { 419 | return $this; 420 | } 421 | 422 | $points = array_map(function (PointInterface $p) { 423 | return array('x' => $p->getX(), 'y' => $p->getY()); 424 | }, $coordinates); 425 | 426 | try { 427 | $pixel = $this->converterToColor($color); 428 | $polygon = new \GmagickDraw(); 429 | 430 | $polygon->setstrokecolor($pixel); 431 | $polygon->setstrokewidth($thickness); 432 | 433 | if ($fill) { 434 | $polygon->setfillcolor($pixel); 435 | } else { 436 | $polygon->setfillcolor('transparent'); 437 | } 438 | 439 | $polygon->polygon($points); 440 | 441 | $this->resource->drawImage($polygon); 442 | 443 | unset($pixel, $polygon); 444 | } catch (\GmagickException $e) { 445 | throw new RuntimeException('Draw polygon operation failed', $e->getCode(), $e); 446 | } 447 | 448 | return $this; 449 | } 450 | 451 | /** 452 | * {@inheritdoc} 453 | * 454 | */ 455 | public function text(string $string, FontInterface $font, PointInterface $position, int|float $angle = 0, int $width = 0) 456 | { 457 | try { 458 | $pixel = $this->converterToColor($font->getColor()); 459 | $text = new \GmagickDraw(); 460 | 461 | $text->setfont($font->getFile()); 462 | /* 463 | * @see http://www.php.net/manual/en/imagick.queryfontmetrics.php#101027 464 | * 465 | * ensure font resolution is the same as GD's hard-coded 96 466 | */ 467 | $text->setfontsize((int) ($font->getSize() * (96 / 72))); 468 | $text->setfillcolor($pixel); 469 | 470 | if ($width !== 0) { 471 | $string = $font->wrapText($string, $width, $angle); 472 | } 473 | 474 | $info = $this->resource->queryfontmetrics($text, $string); 475 | $rad = deg2rad($angle); 476 | $cos = cos($rad); 477 | $sin = sin($rad); 478 | 479 | $x1 = round(0 * $cos - 0 * $sin); 480 | $x2 = round($info['textWidth'] * $cos - $info['textHeight'] * $sin); 481 | $y1 = round(0 * $sin + 0 * $cos); 482 | $y2 = round($info['textWidth'] * $sin + $info['textHeight'] * $cos); 483 | 484 | $xdiff = 0 - min($x1, $x2); 485 | $ydiff = 0 - min($y1, $y2); 486 | 487 | $this->resource->annotateimage($text, $position->getX() + $x1 + $xdiff, $position->getY() + $y2 + $ydiff, $angle, $string); 488 | 489 | unset($pixel, $text); 490 | } catch (\GmagickException $e) { 491 | throw new RuntimeException('Draw text operation failed', $e->getCode(), $e); 492 | } 493 | 494 | return $this; 495 | } 496 | 497 | public function char(string|int $code, FontInterface $font, PointInterface $position, int|float $angle = 0) { 498 | return $this->text(is_int($code) ? mb_chr($code, 'UTF-8') : $code, $font, $position, $angle, 0); 499 | } 500 | 501 | public function fontSize(string $string, FontInterface $font, int|float $angle = 0) 502 | { 503 | $text = new \GmagickDraw(); 504 | 505 | $text->setfont($font->getFile()); 506 | /* 507 | * @see http://www.php.net/manual/en/imagick.queryfontmetrics.php#101027 508 | * 509 | * ensure font resolution is the same as GD's hard-coded 96 510 | */ 511 | $text->setfontsize((int) ($font->getSize() * (96 / 72))); 512 | $text->setfontstyle(\Gmagick::STYLE_OBLIQUE); 513 | 514 | $info = $this->resource->queryfontmetrics($text, $string); 515 | return new Box($info['textWidth'], $info['textHeight']); 516 | } 517 | 518 | /** 519 | * {@inheritdoc} 520 | * 521 | */ 522 | public function gamma(float $correction) 523 | { 524 | try { 525 | $this->resource->gammaimage($correction); 526 | } catch (\GmagickException $e) { 527 | throw new RuntimeException('Failed to apply gamma correction to the image', $e->getCode(), $e); 528 | } 529 | 530 | return $this; 531 | } 532 | 533 | /** 534 | * {@inheritdoc} 535 | * 536 | */ 537 | public function negative() 538 | { 539 | if (!method_exists($this->resource, 'negateimage')) { 540 | throw new Exception('Gmagick version 1.1.0 RC3 is required for negative effect'); 541 | } 542 | 543 | try { 544 | $this->resource->negateimage(false, \Gmagick::CHANNEL_ALL); 545 | } catch (\GmagickException $e) { 546 | throw new RuntimeException('Failed to negate the image', $e->getCode(), $e); 547 | } 548 | 549 | return $this; 550 | } 551 | 552 | /** 553 | * {@inheritdoc} 554 | * 555 | */ 556 | public function grayscale() 557 | { 558 | try { 559 | $this->resource->setImageType(2); 560 | } catch (\GmagickException $e) { 561 | throw new RuntimeException('Failed to grayscale the image', $e->getCode(), $e); 562 | } 563 | 564 | return $this; 565 | } 566 | 567 | /** 568 | * {@inheritdoc} 569 | * 570 | */ 571 | public function colorize($color) 572 | { 573 | throw new Exception('Gmagick does not support colorize'); 574 | } 575 | 576 | /** 577 | * {@inheritdoc} 578 | * 579 | */ 580 | public function sharpen() 581 | { 582 | throw new Exception('Gmagick does not support sharpen yet'); 583 | } 584 | 585 | /** 586 | * {@inheritdoc} 587 | * 588 | */ 589 | public function blur(float $sigma = 1) 590 | { 591 | try { 592 | $this->resource->blurImage(0, $sigma); 593 | } catch (\GmagickException $e) { 594 | throw new RuntimeException('Failed to blur the image', $e->getCode(), $e); 595 | } 596 | 597 | return $this; 598 | } 599 | 600 | /** 601 | * {@inheritdoc} 602 | * 603 | */ 604 | public function brightness(float $brightness) 605 | { 606 | $brightness = (int) round($brightness); 607 | if ($brightness < -100 || $brightness > 100) { 608 | throw new InvalidArgumentException(sprintf('The %1$s argument can range from %2$d to %3$d, but you specified %4$d.', '$brightness', -100, 100, $brightness)); 609 | } 610 | try { 611 | // This *emulates* setting the brightness 612 | $sign = $brightness < 0 ? -1 : 1; 613 | $v = abs($brightness) / 100; 614 | if ($sign > 0) { 615 | $v = (2 / (sin(($v * .99999 * M_PI_2) + M_PI_2))) - 2; 616 | } 617 | $this->resource->modulateimage(100 + $sign * $v * 100, 100, 100); 618 | } catch (\GmagickException $e) { 619 | throw new RuntimeException('Failed to brightness the image'); 620 | } 621 | 622 | return $this; 623 | } 624 | 625 | /** 626 | * {@inheritdoc} 627 | * 628 | */ 629 | public function convolve(Matrix $matrix) 630 | { 631 | if (!method_exists($this->resource, 'convolveimage')) { 632 | // convolveimage has been added in gmagick 2.0.1RC2 633 | throw new Exception('The version of Gmagick extension is too old: it does not support convolve.'); 634 | } 635 | if ($matrix->getWidth() !== 3 || $matrix->getHeight() !== 3) { 636 | throw new InvalidArgumentException(sprintf('A convolution matrix must be 3x3 (%dx%d provided).', $matrix->getWidth(), $matrix->getHeight())); 637 | } 638 | try { 639 | $this->resource->convolveimage($matrix->getValueList()); 640 | } catch (\ImagickException $e) { 641 | throw new RuntimeException('Failed to convolve the image'); 642 | } 643 | 644 | return $this; 645 | } 646 | 647 | /** 648 | * {@inheritdoc} 649 | * 650 | */ 651 | public function strip() 652 | { 653 | try { 654 | try { 655 | // $this->profile($this->palette->profile()); 656 | } catch (\Exception $e) { 657 | // here we discard setting the profile as the previous incorporated profile 658 | // is corrupted, let's now strip the image 659 | } 660 | $this->resource->stripimage(); 661 | } catch (\GmagickException $e) { 662 | throw new RuntimeException('Strip operation failed', $e->getCode(), $e); 663 | } 664 | 665 | return $this; 666 | } 667 | 668 | /** 669 | * {@inheritdoc} 670 | * 671 | */ 672 | public function paste(ImageAdapter $image, PointInterface $start, int|float $alpha = 100) 673 | { 674 | if (!$image instanceof self) { 675 | throw new InvalidArgumentException(sprintf('Gmagick\Image can only paste() Gmagick\Image instances, %s given', get_class($image))); 676 | } 677 | 678 | $alpha = (int) round($alpha); 679 | if ($alpha < 0 || $alpha > 100) { 680 | throw new InvalidArgumentException(sprintf('The %1$s argument can range from %2$d to %3$d, but you specified %4$d.', '$alpha', 0, 100, $alpha)); 681 | } 682 | 683 | if ($alpha === 100) { 684 | try { 685 | $this->resource->compositeimage($image->resource, \Gmagick::COMPOSITE_DEFAULT, $start->getX(), $start->getY()); 686 | } catch (\GmagickException $e) { 687 | throw new RuntimeException('Paste operation failed', $e->getCode(), $e); 688 | } 689 | } elseif ($alpha > 0) { 690 | throw new Exception('Gmagick doesn\'t support paste with alpha.', 1); 691 | } 692 | 693 | return $this; 694 | } 695 | 696 | /** 697 | * {@inheritdoc} 698 | * 699 | */ 700 | public function resize(BoxInterface $size, $filter = ImageAdapter::FILTER_UNDEFINED) 701 | { 702 | static $supportedFilters = array( 703 | 704 | ); 705 | 706 | if (!array_key_exists($filter, $supportedFilters)) { 707 | throw new InvalidArgumentException('Unsupported filter type'); 708 | } 709 | 710 | try { 711 | $this->resource->resizeimage($size->getWidth(), $size->getHeight(), $supportedFilters[$filter], 1); 712 | } catch (\GmagickException $e) { 713 | throw new RuntimeException('Resize operation failed', $e->getCode(), $e); 714 | } 715 | 716 | return $this; 717 | } 718 | 719 | /** 720 | * {@inheritdoc} 721 | * 722 | */ 723 | public function rotate(int|float $angle, mixed $background = null) 724 | { 725 | try { 726 | if ($background === null) { 727 | $background = '#fff'; 728 | } 729 | $pixel = $this->converterToColor($background); 730 | 731 | $this->resource->rotateimage($pixel, $angle); 732 | 733 | unset($pixel); 734 | } catch (\GmagickException $e) { 735 | throw new RuntimeException('Rotate operation failed', $e->getCode(), $e); 736 | } 737 | 738 | return $this; 739 | } 740 | 741 | /** 742 | * Applies options before save or output. 743 | * 744 | * @param \Gmagick $image 745 | * @param array $options 746 | * @param string $path 747 | * 748 | */ 749 | private function applyImageOptions(\Gmagick $image, array $options, $path) 750 | { 751 | if (isset($options['format'])) { 752 | $format = $options['format']; 753 | } elseif ('' !== $extension = pathinfo($path, \PATHINFO_EXTENSION)) { 754 | $format = $extension; 755 | } else { 756 | $format = pathinfo($image->getImageFilename(), \PATHINFO_EXTENSION); 757 | } 758 | 759 | $format = strtolower($format); 760 | 761 | switch ($format) { 762 | case 'jpeg': 763 | case 'jpg': 764 | case 'pjpeg': 765 | if (!isset($options['jpeg_quality'])) { 766 | if (isset($options['quality'])) { 767 | $options['jpeg_quality'] = $options['quality']; 768 | } 769 | } 770 | if (isset($options['jpeg_quality'])) { 771 | $image->setCompressionQuality($options['jpeg_quality']); 772 | } 773 | if (isset($options['jpeg_sampling_factors'])) { 774 | if (!is_array($options['jpeg_sampling_factors']) || \count($options['jpeg_sampling_factors']) < 1) { 775 | throw new InvalidArgumentException('jpeg_sampling_factors option should be an array of integers'); 776 | } 777 | $image->setSamplingFactors(array_map(function ($factor) { 778 | return (int) $factor; 779 | }, $options['jpeg_sampling_factors'])); 780 | } 781 | break; 782 | case 'png': 783 | if (!isset($options['png_compression_level'])) { 784 | if (isset($options['quality'])) { 785 | $options['png_compression_level'] = round((100 - $options['quality']) * 9 / 100); 786 | } 787 | } 788 | if (isset($options['png_compression_level'])) { 789 | if ($options['png_compression_level'] < 0 || $options['png_compression_level'] > 9) { 790 | throw new InvalidArgumentException('png_compression_level option should be an integer from 0 to 9'); 791 | } 792 | } 793 | if (isset($options['png_compression_filter'])) { 794 | if ($options['png_compression_filter'] < 0 || $options['png_compression_filter'] > 9) { 795 | throw new InvalidArgumentException('png_compression_filter option should be an integer from 0 to 9'); 796 | } 797 | } 798 | if (isset($options['png_compression_level']) || isset($options['png_compression_filter'])) { 799 | // first digit: compression level (default: 7) 800 | $compression = isset($options['png_compression_level']) ? $options['png_compression_level'] * 10 : 70; 801 | // second digit: compression filter (default: 5) 802 | $compression += $options['png_compression_filter'] ?? 5; 803 | $image->setCompressionQuality($compression); 804 | } 805 | break; 806 | case 'webp': 807 | if (!isset($options['webp_quality'])) { 808 | if (isset($options['quality'])) { 809 | $options['webp_quality'] = $options['quality']; 810 | } 811 | } 812 | if (isset($options['webp_quality'])) { 813 | $image->setCompressionQuality($options['webp_quality']); 814 | } 815 | break; 816 | } 817 | if (isset($options['resolution-units']) && isset($options['resolution-x']) && isset($options['resolution-y'])) { 818 | switch ($options['resolution-units']) { 819 | case ImageAdapter::RESOLUTION_PIXELSPERCENTIMETER: 820 | $image->setimageunits(\Gmagick::RESOLUTION_PIXELSPERCENTIMETER); 821 | break; 822 | case ImageAdapter::RESOLUTION_PIXELSPERINCH: 823 | $image->setimageunits(\Gmagick::RESOLUTION_PIXELSPERINCH); 824 | break; 825 | default: 826 | throw new InvalidArgumentException('Unsupported image unit format'); 827 | } 828 | $image->setimageresolution($options['resolution-x'], $options['resolution-y']); 829 | } 830 | } 831 | 832 | /** 833 | * {@inheritdoc} 834 | * 835 | */ 836 | public function save() 837 | { 838 | $path = $this->file; 839 | 840 | if ('' === trim($path)) { 841 | throw new RuntimeException('You can omit save path only if image has been open from a file'); 842 | } 843 | 844 | try { 845 | $this->resource->writeimage($path, true); 846 | } catch (\GmagickException $e) { 847 | throw new RuntimeException('Save operation failed', $e->getCode(), $e); 848 | } 849 | 850 | return $this; 851 | } 852 | 853 | 854 | /** 855 | * Destroys allocated gmagick resources. 856 | */ 857 | public function __destruct() 858 | { 859 | if ($this->resource instanceof \Gmagick) { 860 | $this->resource->clear(); 861 | $this->resource->destroy(); 862 | } 863 | } 864 | 865 | /** 866 | * {@inheritdoc} 867 | * 868 | */ 869 | public function __clone() 870 | { 871 | $this->resource = clone $this->resource; 872 | } 873 | 874 | /** 875 | * Gets specifically formatted color string from Color instance. 876 | * 877 | * @return \GmagickPixel 878 | */ 879 | public function converterToColor(mixed $color): GmagickPixel 880 | { 881 | // if () { 882 | // throw new InvalidArgumentException('Gmagick doesn\'t support transparency'); 883 | // } 884 | 885 | return new \GmagickPixel((string) $color); 886 | } 887 | 888 | public function getHeight(): int 889 | { 890 | return $this->resource->getimageheight(); 891 | } 892 | 893 | public function getWidth(): int 894 | { 895 | return $this->resource->getimagewidth(); 896 | } 897 | 898 | public function scale(BoxInterface $box) 899 | { 900 | $this->resource->scaleimage($box->getWidth(), $box->getHeight()); 901 | return $this; 902 | } 903 | 904 | public function getColorAt(PointInterface $point) 905 | { 906 | } 907 | 908 | public function copy() 909 | { 910 | // TODO: Implement copy() method. 911 | } 912 | 913 | public function crop(PointInterface $start, BoxInterface $size) 914 | { 915 | $this->resource->cropimage($size->getWidth(), $size->getHeight(), $start->getX(), $start->getY()); 916 | return $this; 917 | } 918 | 919 | public function saveAs(mixed $output = null, string $type = ''): bool 920 | { 921 | $this->resource->writeimage($output, true); 922 | return true; 923 | } 924 | 925 | public function fill($fill) 926 | { 927 | // TODO: Implement fill() method. 928 | } 929 | 930 | public function pastePart(ImageAdapter $src, PointInterface $srcStart, BoxInterface $srcBox, PointInterface $start, BoxInterface|null $box = null, int|float $alpha = 100) 931 | { 932 | // TODO: Implement pastePart() method. 933 | } 934 | 935 | public function transparent(mixed $color) 936 | { 937 | throw new Exception('gmagick is not support transparent'); 938 | } 939 | 940 | public function converterFromColor(mixed $color): mixed 941 | { 942 | if (is_array($color)) { 943 | return $color; 944 | } 945 | if (!$color instanceof \GmagickPixel) { 946 | $color = new \GmagickPixel((string)$color); 947 | } 948 | $res = $color->getColor(true); 949 | return [$res['r'], $res['g'], $res['b'], $res['a']]; 950 | } 951 | 952 | public function thumbnail(BoxInterface $box) 953 | { 954 | $this->resource->thumbnailimage($box->getWidth(), $box->getHeight()); 955 | return $this; 956 | } 957 | 958 | protected function addFont(\GmagickDraw $draw, int|string $fontName) { 959 | if (is_file($fontName)) { 960 | $draw->setFont($fontName); 961 | } else { 962 | $draw->setFont($this->converterFont($fontName)); 963 | } 964 | } 965 | 966 | protected function converterFont(int|string $fontName): string { 967 | if (!is_numeric($fontName)) { 968 | return $fontName; 969 | } 970 | $fontItems = $this->resource->queryFonts(); 971 | $i = intval($fontName); 972 | if (isset($fontItems[$i])) { 973 | return $fontItems[$i]; 974 | } 975 | throw new \Exception('Font is error'); 976 | } 977 | 978 | private function getFilter(string $filter): int { 979 | return match ($filter) { 980 | ImageAdapter::FILTER_UNDEFINED => \Gmagick::FILTER_UNDEFINED, 981 | ImageAdapter::FILTER_BESSEL => \Gmagick::FILTER_BESSEL, 982 | ImageAdapter::FILTER_BLACKMAN => \Gmagick::FILTER_BLACKMAN, 983 | ImageAdapter::FILTER_BOX => \Gmagick::FILTER_BOX, 984 | ImageAdapter::FILTER_CATROM => \Gmagick::FILTER_CATROM, 985 | ImageAdapter::FILTER_CUBIC => \Gmagick::FILTER_CUBIC, 986 | ImageAdapter::FILTER_GAUSSIAN => \Gmagick::FILTER_GAUSSIAN, 987 | ImageAdapter::FILTER_HANNING => \Gmagick::FILTER_HANNING, 988 | ImageAdapter::FILTER_HAMMING => \Gmagick::FILTER_HAMMING, 989 | ImageAdapter::FILTER_HERMITE => \Gmagick::FILTER_HERMITE, 990 | ImageAdapter::FILTER_LANCZOS => \Gmagick::FILTER_LANCZOS, 991 | ImageAdapter::FILTER_MITCHELL => \Gmagick::FILTER_MITCHELL, 992 | ImageAdapter::FILTER_POINT => \Gmagick::FILTER_POINT, 993 | ImageAdapter::FILTER_QUADRATIC => \Gmagick::FILTER_QUADRATIC, 994 | ImageAdapter::FILTER_SINC => \Gmagick::FILTER_SINC, 995 | ImageAdapter::FILTER_TRIANGLE => \Gmagick::FILTER_TRIANGLE, 996 | default => throw new \Exception('error filter'), 997 | }; 998 | } 999 | } -------------------------------------------------------------------------------- /src/Adapters/ImageAdapter.php: -------------------------------------------------------------------------------- 1 | width = $width; 27 | $this->height = $height; 28 | if ($this->width < 1 || $this->height < 1) { 29 | throw new InvalidArgumentException(sprintf('Length of either side cannot be 0 or negative, current size is %sx%s', $width, $height)); 30 | } 31 | } 32 | 33 | public function getWidth(): int { 34 | return $this->width; 35 | } 36 | 37 | public function getHeight(): int { 38 | return $this->height; 39 | } 40 | 41 | public function scale(float|int $ratio) { 42 | $width = max(1, round($ratio * $this->width)); 43 | $height = max(1, round($ratio * $this->height)); 44 | 45 | return new self($width, $height); 46 | } 47 | 48 | public function increase(int $size) { 49 | return new self($size + $this->width, $size + $this->height); 50 | } 51 | 52 | public function contains(BoxInterface $box, PointInterface|null $start = null): bool { 53 | $start = $start ? $start : new Point(0, 0); 54 | 55 | return $start->in($this) && $this->width >= $box->getWidth() + $start->getX() && $this->height >= $box->getHeight() + $start->getY(); 56 | } 57 | 58 | public function square(): int { 59 | return $this->width * $this->height; 60 | } 61 | 62 | public function __toString(): string { 63 | return sprintf('%dx%d px', $this->width, $this->height); 64 | } 65 | 66 | public function widen(int $width) { 67 | return $this->scale($width / $this->width); 68 | } 69 | 70 | public function heighten(int $height) { 71 | return $this->scale($height / $this->height); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Base/BoxInterface.php: -------------------------------------------------------------------------------- 1 | file = is_string($file) ? $file : intval($file); 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | * 33 | */ 34 | final public function getFile() 35 | { 36 | return $this->file; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | * 42 | */ 43 | final public function getSize(): int 44 | { 45 | return $this->size; 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | * 51 | */ 52 | final public function getColor(): mixed 53 | { 54 | return $this->color; 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | * 60 | */ 61 | public function wrapText(mixed $string, int $maxWidth, int $angle = 0): string 62 | { 63 | $string = (string) $string; 64 | if ($string === '') { 65 | return $string; 66 | } 67 | $maxWidth = (int) round($maxWidth); 68 | if ($maxWidth < 1) { 69 | throw new InvalidArgumentException(sprintf('The $maxWidth parameter of wrapText must be greater than 0.')); 70 | } 71 | $words = explode(' ', $string); 72 | $lines = array(); 73 | $currentLine = null; 74 | foreach ($words as $word) { 75 | if ($currentLine === null) { 76 | $currentLine = $word; 77 | } else { 78 | $testLine = $currentLine . ' ' . $word; 79 | $testBox = $this->box($testLine, $angle); 80 | if ($testBox->getWidth() <= $maxWidth) { 81 | $currentLine = $testLine; 82 | } else { 83 | $lines[] = $currentLine; 84 | $currentLine = $word; 85 | } 86 | } 87 | } 88 | if ($currentLine !== null) { 89 | $lines[] = $currentLine; 90 | } 91 | 92 | return implode("\n", $lines); 93 | } 94 | 95 | public function box(string $string, int $angle = 0): Box { 96 | return ImageManager::create()->fontSize($string, $this, $angle); 97 | } 98 | } -------------------------------------------------------------------------------- /src/Base/FontInterface.php: -------------------------------------------------------------------------------- 1 | width = (int) round($width); 41 | if ($this->width < 1) { 42 | throw new InvalidArgumentException('width has to be > 0'); 43 | } 44 | $this->height = (int) round($height); 45 | if ($this->height < 1) { 46 | throw new InvalidArgumentException('height has to be > 0'); 47 | } 48 | $expectedElements = $width * $height; 49 | $providedElements = count($elements); 50 | if ($providedElements > $expectedElements) { 51 | throw new InvalidArgumentException('there are more provided elements than space in the matrix'); 52 | } 53 | $this->elements = array_values($elements); 54 | if ($providedElements < $expectedElements) { 55 | $this->elements = array_merge( 56 | $this->elements, 57 | array_fill($providedElements, $expectedElements - $providedElements, 0) 58 | ); 59 | } 60 | } 61 | 62 | /** 63 | * Get the matrix width. 64 | * 65 | * @return int 66 | */ 67 | public function getWidth(): int 68 | { 69 | return $this->width; 70 | } 71 | 72 | /** 73 | * Get the matrix height. 74 | * 75 | * @return int 76 | */ 77 | public function getHeight(): int 78 | { 79 | return $this->height; 80 | } 81 | 82 | /** 83 | * Set the value of a cell. 84 | * 85 | * @param int $x 86 | * @param int $y 87 | * @param int|float $value 88 | */ 89 | public function setElementAt($x, $y, $value) 90 | { 91 | $this->elements[$this->calculatePosition($x, $y)] = $value; 92 | } 93 | 94 | /** 95 | * Get the value of a cell. 96 | * 97 | * @param int $x 98 | * @param int $y 99 | * 100 | * @return int|float 101 | */ 102 | public function getElementAt($x, $y) 103 | { 104 | return $this->elements[$this->calculatePosition($x, $y)]; 105 | } 106 | 107 | /** 108 | * Return all the matrix values, as a monodimensional array. 109 | * 110 | * @return int[]|float[] 111 | */ 112 | public function getValueList() 113 | { 114 | return $this->elements; 115 | } 116 | 117 | /** 118 | * Return all the matrix values, as a bidimensional array (every array item contains the values of a row). 119 | * 120 | * @return int[]|float[] 121 | */ 122 | public function getMatrix() 123 | { 124 | return array_chunk($this->elements, $this->getWidth()); 125 | } 126 | 127 | /** 128 | * Returns a new Matrix instance, representing the normalized value of this matrix. 129 | * 130 | * @return static 131 | */ 132 | public function normalize() 133 | { 134 | $values = $this->getValueList(); 135 | $divisor = array_sum($values); 136 | if ($divisor == 0 || $divisor == 1) { 137 | return clone $this; 138 | } 139 | $normalizedElements = array(); 140 | foreach ($values as $value) { 141 | $normalizedElements[] = $value / $divisor; 142 | } 143 | 144 | return new static($this->getWidth(), $this->getHeight(), $normalizedElements); 145 | } 146 | 147 | /** 148 | * Calculate the offset position of a cell. 149 | * 150 | * @param int $x 151 | * @param int $y 152 | * 153 | * 154 | * @return int 155 | */ 156 | protected function calculatePosition($x, $y) 157 | { 158 | if (0 > $x || 0 > $y || $this->width <= $x || $this->height <= $y) { 159 | throw new OutOfBoundsException(sprintf('There is no position (%s, %s) in this matrix', $x, $y)); 160 | } 161 | 162 | return $y * $this->height + $x; 163 | } 164 | } -------------------------------------------------------------------------------- /src/Base/Point.php: -------------------------------------------------------------------------------- 1 | x; 15 | } 16 | 17 | public function getY(): int { 18 | return $this->y; 19 | } 20 | 21 | public function in(BoxInterface $box): bool { 22 | return $this->x < $box->getWidth() && $this->y < $box->getHeight(); 23 | } 24 | 25 | public function move(int $amount): PointInterface { 26 | return new self($this->x + $amount, $this->y + $amount); 27 | } 28 | 29 | public function __toString(): string { 30 | return sprintf('(%d, %d)', $this->x, $this->y); 31 | } 32 | } -------------------------------------------------------------------------------- /src/Base/PointInterface.php: -------------------------------------------------------------------------------- 1 | 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', //随机因子 33 | 'length' => 4, //验证码长度 34 | 'fontSize' => 0, //指定字体大小 35 | 'fontColor' => '', //指定字体颜色, 可以是数组 36 | 'fontFamily' => 3, //指定的字体 37 | 'width' => 100, 38 | 'height' => 30, 39 | 'angle' => 0, //角度 40 | 'sensitive' => true, // 大小写敏感 41 | 'mode' => 0 // 验证码模式: 0 文字 1 公式 42 | ]; 43 | 44 | public function getRealType(): string { 45 | return 'png'; 46 | } 47 | 48 | public function isOnlyImage(): bool { 49 | return true; 50 | } 51 | 52 | public function setConfigs(array $configs): void { 53 | $this->configs = array_merge($this->configs, $configs); 54 | } 55 | 56 | /** 57 | * 获取验证码 58 | * @return string 59 | * @throws \Exception 60 | */ 61 | public function getCode(): string { 62 | if (empty($this->code)) { 63 | $this->createCode(); 64 | } 65 | return $this->code; 66 | } 67 | 68 | /** 69 | * 生成 70 | * @param int $level 干扰等级 71 | * @throws \Exception 72 | */ 73 | public function generate(): mixed { 74 | $this->getCode(); 75 | $this->width = $this->configs['width']; 76 | $this->height = $this->configs['height']; 77 | $this->createBg(); 78 | $this->createText(); 79 | $this->createLine(intval($this->configs['level'] ?? 1)); 80 | $this->instance()->setRealType($this->getRealType()); 81 | return ['code' => $this->code, 'sensitive' => $this->configs['sensitive']]; 82 | } 83 | 84 | 85 | /** 86 | * 生成随机码 87 | * @param bool $setSession 保存到session里 88 | * @return $this 89 | * @throws \Exception 90 | */ 91 | public function createCode(bool $setSession = true) { 92 | list($this->code, $this->chars) = $this->configs['mode'] == 1 93 | ? $this->generateFormula() : $this->generateRandomChar(); 94 | // if ($setSession) { 95 | // session()->set(self::SESSION_KEY, [ 96 | // 'sensitive' => $this->configs['sensitive'], 97 | // 'key' => Hash::make($this->configs['sensitive'] || is_numeric($this->code) ? $this->code : strtolower($this->code)) 98 | // ]); 99 | // } 100 | return $this; 101 | } 102 | 103 | protected function generateRandomChar(): array { 104 | $charset = $this->configs['characters']; 105 | $_len = strlen($charset) - 1; 106 | $count = intval($this->configs['length']); 107 | $chars = []; 108 | for ($i = 0; $i < $count; $i ++) { 109 | $chars[] = $charset[mt_rand(0, $_len)]; 110 | } 111 | return [implode('', $chars), $chars]; 112 | } 113 | 114 | protected function generateFormula(): array { 115 | $tags = is_numeric($this->configs['fontFamily']) ? 116 | ['+', '-', '*', '/'] : ['加', '减', '乘', '除']; 117 | $tag = mt_rand(0, 3); 118 | $first = mt_rand(1, 99); 119 | $second = mt_rand(1, 99); 120 | $result = 0; 121 | if ($tag == 0) { 122 | $result = $first + $second; 123 | } elseif ($tag == 1) { 124 | if ($first < $second) { 125 | list($first, $second) = [$second, $first]; 126 | } 127 | $result = $first - $second; 128 | } elseif ($tag == 2) { 129 | $result = $first * $second; 130 | } elseif ($tag == 3) { 131 | if ($first < $second) { 132 | list($first, $second) = [$second, $first]; 133 | } 134 | $result = floor($first / $second); 135 | $first = $result * $second; 136 | } 137 | return [$result, [$first, $tags[$tag], $second, '=?']]; 138 | } 139 | 140 | /** 141 | * 生成背景 142 | */ 143 | protected function createBg(): void { 144 | $this->instance()->create(new Box($this->width, $this->height), 145 | [mt_rand(157, 255), mt_rand(157, 255), mt_rand(157, 255)]); 146 | } 147 | 148 | /** 149 | * 生成文字 150 | */ 151 | protected function createText(): void { 152 | $length = count($this->chars); 153 | $width = $this->width / ($length + 1); 154 | $left = $width * .5; 155 | $maxHeight = (int)($this->height - $left); 156 | for ($i = 0; $i < $length; $i ++) { 157 | $size = $this->fontSize(); 158 | $angle = $size > $this->height ? 0 : $this->angle(); 159 | $height = intval((abs(cos($angle)) + abs(sin($angle))) * $size); 160 | $this->instance()->text( 161 | $this->chars[$i], 162 | new Font($this->configs['fontFamily'], $size, $this->fontColor($i)), 163 | new Point((int)($left + $width * $i), 164 | $height > $maxHeight 165 | ? $height : mt_rand($height, $maxHeight)), 166 | $angle 167 | ); 168 | } 169 | } 170 | 171 | /** 172 | * 173 | * @return int 174 | */ 175 | protected function angle(): int { 176 | if (empty($this->configs['angle'])) { 177 | return mt_rand(-30, 30); 178 | } 179 | return mt_rand(-1 * $this->configs['angle'], $this->configs['angle']); 180 | } 181 | 182 | protected function fontSize(): int { 183 | if (!empty($this->configs['fontSize'])) { 184 | return $this->configs['fontSize']; 185 | } 186 | return rand($this->height - 10, $this->height); 187 | } 188 | 189 | /** 190 | * 获取字体颜色 191 | * @param integer $i 192 | * @return array|mixed 193 | */ 194 | protected function fontColor(int $i): mixed { 195 | if (empty($this->configs['fontColor'])) { 196 | return [mt_rand(0, 156), mt_rand(0, 156), mt_rand(0, 156)]; 197 | } 198 | if (!is_array($this->configs['fontColor'])) { 199 | return $this->configs['fontColor']; 200 | } 201 | if (count($this->configs['fontColor']) <= $i) { 202 | return [mt_rand(0, 156), mt_rand(0, 156), mt_rand(0, 156)]; 203 | } 204 | return $this->configs['fontColor'][$i]; 205 | } 206 | 207 | /** 208 | * 生成线条、雪花 209 | * @param int $level 210 | */ 211 | protected function createLine(int $level = 1) { 212 | //线条 213 | for ($i = 0; $i < 6; $i ++) { 214 | $this->instance()->line( 215 | new Point(mt_rand(0, $this->width), 216 | mt_rand(0, $this->height)), 217 | new Point(mt_rand(0, $this->width), 218 | mt_rand(0, $this->height)), 219 | [mt_rand(0, 156), mt_rand(0, 156), mt_rand(0, 156)]); 220 | } 221 | //雪花 222 | for ($i = 0, $length = $level * 20; $i < $length; $i ++) { 223 | $this->instance()->text( 224 | '*', 225 | new Font( 226 | is_numeric($this->configs['fontFamily']) 227 | ? mt_rand(1, 5) : $this->configs['fontFamily'] 228 | , 16, [mt_rand(200, 255), mt_rand(200, 255), mt_rand(200, 255)]), 229 | new Point(mt_rand(0, $this->width), 230 | mt_rand(0, $this->height)) 231 | ); 232 | } 233 | } 234 | 235 | /** 236 | * 验证 237 | * @param string $value 238 | * @return bool 239 | * @throws \Exception 240 | */ 241 | public function verify(mixed $value, mixed $source): bool { 242 | if (!is_array($source) || empty($source)) { 243 | return false; 244 | } 245 | if (!$source['sensitive']) { 246 | $value = strtolower((string)$value); 247 | $source['code'] = strtolower((string)$source['code']); 248 | } 249 | return (string)$value === (string)$source['code']; 250 | // if (!session()->has(self::SESSION_KEY)) { 251 | // return false; 252 | // } 253 | // $data = session()->get(self::SESSION_KEY); 254 | // if (!$data['sensitive'] && !is_numeric($value)) { 255 | // $value = strtolower($value); 256 | // } 257 | // session()->delete(self::SESSION_KEY); 258 | // return Hash::verify($value, $data['key']); 259 | } 260 | 261 | public function toArray(): array { 262 | return [ 263 | 'image' => $this->toBase64(), 264 | 'width' => $this->width, 265 | 'height' => $this->height, 266 | ]; 267 | } 268 | } -------------------------------------------------------------------------------- /src/Colors.php: -------------------------------------------------------------------------------- 1 | '240,248,255', 13 | 'LightSalmon' => '255,160,122', 14 | 'AntiqueWhite' => '250,235,215', 15 | 'LightSeaGreen' => '32,178,170', 16 | 'Aqua' => '0,255,255', 17 | 'LightSkyBlue' => '135,206,250', 18 | 'Aquamarine' => '127,255,212', 19 | 'LightSlateGray' => '119,136,153', 20 | 'Azure' => '240,255,255', 21 | 'LightSteelBlue' => '176,196,222', 22 | 'Beige' => '245,245,220', 23 | 'LightYellow' => '255,255,224', 24 | 'Bisque' => '255,228,196', 25 | 'Lime' => '0,255,0', 26 | 'Black' => '0,0,0', 27 | 'LimeGreen' => '50,205,50', 28 | 'BlanchedAlmond' => '255,255,205', 29 | 'Linen' => '250,240,230', 30 | 'Blue' => '0,0,255', 31 | 'Magenta' => '255,0,255', 32 | 'BlueViolet' => '138,43,226', 33 | 'Maroon' => '128,0,0', 34 | 'Brown' => '165,42,42', 35 | 'MediumAquamarine' => '102,205,170', 36 | 'BurlyWood' => '222,184,135', 37 | 'MediumBlue' => '0,0,205', 38 | 'CadetBlue' => '95,158,160', 39 | 'MediumOrchid' => '186,85,211', 40 | 'Chartreuse' => '127,255,0', 41 | 'MediumPurple' => '147,112,219', 42 | 'Chocolate' => '210,105,30', 43 | 'MediumSeaGreen' => '60,179,113', 44 | 'Coral' => '255,127,80', 45 | 'MediumSlateBlue' => '123,104,238', 46 | 'CornflowerBlue' => '100,149,237', 47 | 'MediumSpringGreen' => '0,250,154', 48 | 'Cornsilk' => '255,248,220', 49 | 'MediumTurquoise' => '72,209,204', 50 | 'Crimson' => '220,20,60', 51 | 'MediumVioletRed' => '199,21,112', 52 | 'Cyan' => '0,255,255', 53 | 'MidnightBlue' => '25,25,112', 54 | 'DarkBlue' => '0,0,139', 55 | 'MintCream' => '245,255,250', 56 | 'DarkCyan' => '0,139,139', 57 | 'MistyRose' => '255,228,225', 58 | 'DarkGoldenrod' => '184,134,11', 59 | 'Moccasin' => '255,228,181', 60 | 'DarkGray' => '169,169,169', 61 | 'NavajoWhite' => '255,222,173', 62 | 'DarkGreen' => '0,100,0', 63 | 'Navy' => '0,0,128', 64 | 'DarkKhaki' => '189,183,107', 65 | 'OldLace' => '253,245,230', 66 | 'DarkMagena' => '139,0,139', 67 | 'Olive' => '128,128,0', 68 | 'DarkOliveGreen' => '85,107,47', 69 | 'OliveDrab' => '107,142,45', 70 | 'DarkOrange' => '255,140,0', 71 | 'Orange' => '255,165,0', 72 | 'DarkOrchid' => '153,50,204', 73 | 'OrangeRed' => '255,69,0', 74 | 'DarkRed' => '139,0,0', 75 | 'Orchid' => '218,112,214', 76 | 'DarkSalmon' => '233,150,122', 77 | 'PaleGoldenrod' => '238,232,170', 78 | 'DarkSeaGreen' => '143,188,143', 79 | 'PaleGreen' => '152,251,152', 80 | 'DarkSlateBlue' => '72,61,139', 81 | 'PaleTurquoise' => '175,238,238', 82 | 'DarkSlateGray' => '40,79,79', 83 | 'PaleVioletRed' => '219,112,147', 84 | 'DarkTurquoise' => '0,206,209', 85 | 'PapayaWhip' => '255,239,213', 86 | 'DarkViolet' => '148,0,211', 87 | 'PeachPuff' => '255,218,155', 88 | 'DeepPink' => '255,20,147', 89 | 'Peru' => '205,133,63', 90 | 'DeepSkyBlue' => '0,191,255', 91 | 'Pink' => '255,192,203', 92 | 'DimGray' => '105,105,105', 93 | 'Plum' => '221,160,221', 94 | 'DodgerBlue' => '30,144,255', 95 | 'PowderBlue' => '176,224,230', 96 | 'Firebrick' => '178,34,34', 97 | 'Purple' => '128,0,128', 98 | 'FloralWhite' => '255,250,240', 99 | 'Red' => '255,0,0', 100 | 'ForestGreen' => '34,139,34', 101 | 'RosyBrown' => '188,143,143', 102 | 'Fuschia' => '255,0,255', 103 | 'RoyalBlue' => '65,105,225', 104 | 'Gainsboro' => '220,220,220', 105 | 'SaddleBrown' => '139,69,19', 106 | 'GhostWhite' => '248,248,255', 107 | 'Salmon' => '250,128,114', 108 | 'Gold' => '255,215,0', 109 | 'SandyBrown' => '244,164,96', 110 | 'Goldenrod' => '218,165,32', 111 | 'SeaGreen' => '46,139,87', 112 | 'Gray' => '128,128,128', 113 | 'Seashell' => '255,245,238', 114 | 'Green' => '0,128,0', 115 | 'Sienna' => '160,82,45', 116 | 'GreenYellow' => '173,255,47', 117 | 'Silver' => '192,192,192', 118 | 'Honeydew' => '240,255,240', 119 | 'SkyBlue' => '135,206,235', 120 | 'HotPink' => '255,105,180', 121 | 'SlateBlue' => '106,90,205', 122 | 'IndianRed' => '205,92,92', 123 | 'SlateGray' => '112,128,144', 124 | 'Indigo' => '75,0,130', 125 | 'Snow' => '255,250,250', 126 | 'Ivory' => '255,240,240', 127 | 'SpringGreen' => '0,255,127', 128 | 'Khaki' => '240,230,140', 129 | 'SteelBlue' => '70,130,180', 130 | 'Lavender' => '230,230,250', 131 | 'Tan' => '210,180,140', 132 | 'LavenderBlush' => '255,240,245', 133 | 'Teal' => '0,128,128', 134 | 'LawnGreen' => '124,252,0', 135 | 'Thistle' => '216,191,216', 136 | 'LemonChiffon' => '255,250,205', 137 | 'Tomato' => '253,99,71', 138 | 'LightBlue' => '173,216,230', 139 | 'Turquoise' => '64,224,208', 140 | 'LightCoral' => '240,128,128', 141 | 'Violet' => '238,130,238', 142 | 'LightCyan' => '224,255,255', 143 | 'Wheat' => '245,222,179', 144 | 'LightGoldenrodYellow' => '250,250,210', 145 | 'White' => '255,255,255', 146 | 'LightGreen' => '144,238,144', 147 | 'WhiteSmoke' => '245,245,245', 148 | 'LightGray' => '211,211,211', 149 | 'Yellow' => '255,255,0', 150 | 'LightPink' => '255,182,193', 151 | 'YellowGreen' => '154,205,50', 152 | ]; 153 | 154 | public static function converter(mixed $color): int|string|array { 155 | if (func_num_args() == 1 && is_int($color)) { 156 | return $color; 157 | } 158 | if (func_num_args() >= 3) { 159 | return func_num_args(); 160 | } 161 | if (is_array($color) && count($color) >= 3) { 162 | return $color; 163 | } 164 | if (is_string($color) && str_starts_with($color, '#')) { 165 | return static::transformRGB($color); 166 | } 167 | $name = Str::studly((string)$color); 168 | if (isset(self::$maps[$name])) { 169 | $args = explode(',', self::$maps[$name], 3); 170 | $args[] = 1; 171 | return $args; 172 | } 173 | throw new Exception( 174 | __('Color[{color}] IS ERROR!', [ 175 | 'color' => $color 176 | ]) 177 | ); 178 | } 179 | 180 | public static function transformRGB(string $color = '#000000'): array { 181 | if (strlen($color) == 4) { 182 | $red = substr($color, 1, 1); 183 | $green = substr($color, 2, 1); 184 | $blue = substr($color, 3, 1); 185 | $red .= $red; 186 | $green .= $green; 187 | $blue .= $blue; 188 | } else { 189 | $red = substr($color, 1, 2); 190 | $green = substr($color, 3, 2); 191 | $blue = substr($color, 5, 2); 192 | } 193 | return array( 194 | hexdec($red), 195 | hexdec($green), 196 | hexdec($blue), 197 | 1, 198 | ); 199 | } 200 | } -------------------------------------------------------------------------------- /src/HintCaptcha.php: -------------------------------------------------------------------------------- 1 | 3, 14 | ]; 15 | 16 | /** 17 | * @var integer[] 18 | */ 19 | protected array $point = []; 20 | 21 | /** 22 | * @var ImageAdapter 23 | */ 24 | protected ImageAdapter|null $shapeImage = null; 25 | 26 | public function isOnlyImage(): bool { 27 | return false; 28 | } 29 | 30 | public function setConfigs(array $configs): void { 31 | $this->configs = array_merge($this->configs, $configs); 32 | } 33 | 34 | public function generate(): mixed { 35 | $this->instance()->scale(new Box($this->configs['width'], 36 | $this->configs['height'])); 37 | $this->drawBox(); 38 | return $this->point; 39 | } 40 | 41 | public function drawBox(): void { 42 | $image = $this->instance(); 43 | $width = $image->getWidth(); 44 | $height = $image->getHeight(); 45 | $font = $this->configs['fontFamily']; 46 | $fontSize = $this->configs['fontSize']; 47 | $words = $this->configs['words']; 48 | $count = $this->configs['count']; 49 | $maxWidth = $width - $fontSize; 50 | $maxHeight = $height - $fontSize; 51 | $this->shapeImage = ImageManager::create()->create(new Box($count * $fontSize, $fontSize), '#fff'); 52 | $darkFont = new Font($font, intval($fontSize * .6), '#000'); 53 | $items = []; 54 | foreach ($words as $i => $word) { 55 | $x = random_int($fontSize, $maxWidth - $fontSize); 56 | $y = random_int($fontSize, $maxHeight - $fontSize); 57 | $this->instance()->char($word, new Font($font, $fontSize, '#000'), 58 | new Point( 59 | $x, $y 60 | )); 61 | if ($count > $i) { 62 | $this->shapeImage->char($word, $darkFont, new Point($i * $fontSize, $darkFont->getSize())); 63 | $items[] = [ 64 | $x, $y 65 | ]; 66 | } 67 | } 68 | $this->point = $items; 69 | } 70 | 71 | public function verify(mixed $value, mixed $source): bool { 72 | if (empty($value) || empty($source)) { 73 | return false; 74 | } 75 | $size = intval($this->configs['fontSize']); 76 | foreach ($source as $i => $p) { 77 | if (!isset($value[$i])) { 78 | return false; 79 | } 80 | $srcX = ImageHelper::x($p); 81 | $srcY = ImageHelper::y($p); 82 | if ( 83 | !ImageHelper::inBound($value[$i], $srcX, $srcY, $size, $size) 84 | ) { 85 | return false; 86 | } 87 | } 88 | return true; 89 | } 90 | 91 | public function toArray(): array { 92 | return [ 93 | 'image' => $this->toBase64(), 94 | 'width' => $this->instance()->getWidth(), 95 | 'height' => $this->instance()->getHeight(), 96 | 'count' => $this->configs['count'], 97 | 'control' => $this->shapeImage->toBase64() 98 | ]; 99 | } 100 | } -------------------------------------------------------------------------------- /src/ICaptcha.php: -------------------------------------------------------------------------------- 1 | instance()->getSize()]; 35 | } 36 | $images = []; 37 | foreach ($sizes as $size) { 38 | $image = $this->createLayer($size); 39 | if (!empty($image)) { 40 | $images[] = $image; 41 | } 42 | } 43 | $data = pack( 'vvv', 0, 1, count( $images ) ); 44 | $pixel_data = ''; 45 | $icon_dir_entry_size = 16; 46 | $offset = 6 + ( $icon_dir_entry_size * count( $images ) ); 47 | foreach ( $images as $image ) { 48 | $data .= pack( 'CCCCvvVV', $image['width'], $image['height'], $image['color_palette_colors'], 0, 1, $image['bits_per_pixel'], $image['size'], $offset ); 49 | $pixel_data .= $image['data']; 50 | $offset += $image['size']; 51 | } 52 | $data .= $pixel_data; 53 | unset( $pixel_data ); 54 | if ( false === ( $fh = fopen((string)$output, 'w' ) ) ) 55 | return false; 56 | 57 | if ( false === (fwrite($fh, $data ) ) ) { 58 | fclose($fh); 59 | return false; 60 | } 61 | fclose($fh); 62 | return true; 63 | } 64 | 65 | private function createLayer($size) { 66 | if (is_array($size)) { 67 | list($width, $height) = $size; 68 | } elseif (is_string($size) && strpos($size, 'x') > 0) { 69 | list($width, $height) = explode('x', $size, 2); 70 | } elseif ($size instanceof BoxInterface) { 71 | $width = $size->getWidth(); 72 | $height = $size->getHeight(); 73 | } else { 74 | $height = $width = intval($size); 75 | } 76 | $new_im = clone $this->instance(); 77 | $new_im->scale(new Box($width, $height)); 78 | 79 | $pixel_data = array(); 80 | $opacity_data = array(); 81 | $current_opacity_val = 0; 82 | 83 | for ( $y = $height - 1; $y >= 0; $y-- ) { 84 | for ( $x = 0; $x < $width; $x++ ) { 85 | $color = $new_im->getColorAt(new Point($x, $y)); 86 | 87 | $alpha = ( $color & 0x7F000000 ) >> 24; 88 | $alpha = ( 1 - ( $alpha / 127 ) ) * 255; 89 | 90 | $color &= 0xFFFFFF; 91 | $color |= 0xFF000000 & ( $alpha << 24 ); 92 | 93 | $pixel_data[] = $color; 94 | 95 | 96 | $opacity = ( $alpha <= 127 ) ? 1 : 0; 97 | 98 | $current_opacity_val = ( $current_opacity_val << 1 ) | $opacity; 99 | 100 | if ( ( ( $x + 1 ) % 32 ) == 0 ) { 101 | $opacity_data[] = $current_opacity_val; 102 | $current_opacity_val = 0; 103 | } 104 | } 105 | 106 | if ( ( $x % 32 ) > 0 ) { 107 | while ( ( $x++ % 32 ) > 0 ) 108 | $current_opacity_val = $current_opacity_val << 1; 109 | 110 | $opacity_data[] = $current_opacity_val; 111 | $current_opacity_val = 0; 112 | } 113 | } 114 | 115 | $image_header_size = 40; 116 | $color_mask_size = $width * $height * 4; 117 | $opacity_mask_size = ( ceil( $width / 32 ) * 4 ) * $height; 118 | 119 | 120 | $data = pack( 'VVVvvVVVVVV', 40, $width, ( $height * 2 ), 1, 32, 0, 0, 0, 0, 0, 0 ); 121 | 122 | foreach ( $pixel_data as $color ) 123 | $data .= pack( 'V', $color ); 124 | 125 | foreach ( $opacity_data as $opacity ) 126 | $data .= pack( 'N', $opacity ); 127 | 128 | 129 | return [ 130 | 'width' => $width, 131 | 'height' => $height, 132 | 'color_palette_colors' => 0, 133 | 'bits_per_pixel' => 32, 134 | 'size' => $image_header_size + $color_mask_size + $opacity_mask_size, 135 | 'data' => $data, 136 | ]; 137 | } 138 | } -------------------------------------------------------------------------------- /src/Image.php: -------------------------------------------------------------------------------- 1 | resource) { 20 | return $this->resource; 21 | } 22 | return $this->resource = ImageManager::create(); 23 | } 24 | 25 | public function getRealType(): string { 26 | return $this->resource->getRealType(); 27 | } 28 | 29 | public function getHashValue(): string { 30 | $w = 8; 31 | $h = 8; 32 | $image = clone $this->instance(); 33 | $image->scale(new Box($w, $h)); 34 | $total = 0; 35 | $array = array(); 36 | for( $y = 0; $y < $h; $y++) { 37 | for ($x = 0; $x < $w; $x++) { 38 | $gray = ($image->getColorAt(new Point($x, $y)) >> 8) & 0xFF; 39 | if(!isset($array[$y])) $array[$y] = array(); 40 | $array[$y][$x] = $gray; 41 | $total += $gray; 42 | } 43 | } 44 | unset($image); 45 | $average = intval($total / ($w * $h * 2)); 46 | $hash = ''; 47 | for($y = 0; $y < $h; $y++) { 48 | for($x = 0; $x < $w; $x++) { 49 | $hash .= ($array[$y][$x] >= $average) ? '1' : '0'; 50 | } 51 | } 52 | return $hash; 53 | } 54 | 55 | /** 56 | * 保存,如果路径不存在则输出 57 | * @return bool 58 | */ 59 | public function save(): bool { 60 | return $this->instance()->save(); 61 | } 62 | 63 | /** 64 | * 另存为 65 | * @param string|null $output 如果为null 表示输出 66 | * @param string $type 67 | * @return bool 68 | */ 69 | public function saveAs(string|null $output = null, string $type = ''): bool { 70 | return $this->instance()->saveAs($output, $type); 71 | } 72 | 73 | public function show() { 74 | if (!function_exists('app')) { 75 | throw new \Exception('not support show'); 76 | } 77 | return app('response')->image($this)->send(); 78 | } 79 | 80 | /** 81 | * 转化成base64编码 82 | * @return string 83 | */ 84 | public function toBase64(): string { 85 | return $this->instance()->toBase64(); 86 | } 87 | } -------------------------------------------------------------------------------- /src/ImageCompare.php: -------------------------------------------------------------------------------- 1 | getHashValue(); 13 | $hash2 = $image->getHashValue(); 14 | if (strlen($hash1) !== strlen($hash2)) { 15 | return false; 16 | } 17 | $count = 0; 18 | $len = strlen($hash1); 19 | for ($i = 0; $i < $len; $i++) { 20 | if ($hash1[$i] !== $hash2[$i]) { 21 | $count++; 22 | } 23 | } 24 | return $count <= 10; 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /src/ImageHelper.php: -------------------------------------------------------------------------------- 1 | instance()->create($source->getSize()); 23 | $length = $max - $min + 1; 24 | $count = ceil($length / $rows); 25 | $width = $source->getWidth() / $count; 26 | $height = $source->getHeight() / $rows; 27 | $points = []; 28 | foreach ($args as $i => $arg) { 29 | $arg = $arg - $min; 30 | $x = ($arg % $count) * $width; 31 | $y = floor($arg / $count) * $height; 32 | $srcX = ($i % $count) * $width; 33 | $srcY = floor($i / $count) * $height; 34 | // 计算显示是图片唯一 35 | $points[] = [- $x, -$y]; 36 | $image->instance()->pastePart($source, new Point((int)$srcX, (int)$srcY), 37 | new Box($width, $height), new Point((int)$x, (int)$y)); 38 | 39 | } 40 | return [$image, $points, [$width, $height]]; 41 | } 42 | 43 | /** 44 | * 计算两点之间的直线距离 45 | * @param int|float $fromX 46 | * @param int|float $fromY 47 | * @param int|float $toX 48 | * @param int|float $toY 49 | * @return float 50 | */ 51 | public static function distance(int|float $fromX, int|float $fromY, 52 | int|float $toX, int|float $toY): float { 53 | return sqrt(pow($fromX - $toX, 2) + pow($fromY - $toY, 2)); 54 | } 55 | 56 | /** 57 | * 获取坐标 x 值 58 | * @param mixed $point 59 | * @return float 60 | */ 61 | public static function x(mixed $point): float { 62 | if ($point instanceof PointInterface) { 63 | return $point->getX(); 64 | } 65 | if (!is_array($point)) { 66 | return floatval($point); 67 | } 68 | if (isset($point['x'])) { 69 | return floatval($point['x']); 70 | } 71 | return isset($point[0]) ? floatval($point[0]) : 0; 72 | } 73 | 74 | /** 75 | * 获取坐标 y 值 76 | * @param mixed $point 77 | * @return float 78 | */ 79 | public static function y(mixed $point): float { 80 | if ($point instanceof PointInterface) { 81 | return $point->getY(); 82 | } 83 | if (!is_array($point)) { 84 | return floatval($point); 85 | } 86 | if (isset($point['y'])) { 87 | return floatval($point['y']); 88 | } 89 | return isset($point[1]) ? floatval($point[1]) : 0; 90 | } 91 | 92 | /** 93 | * 判断点在区域内 94 | * @param mixed $point 95 | * @param int|float $x 96 | * @param int|float $y 97 | * @param int|float $width 98 | * @param int|float $height 99 | * @return bool 100 | */ 101 | public static function inBound(mixed $point, int|float $x, int|float $y, 102 | int|float $width, int|float $height): bool { 103 | $pX = static::x($point); 104 | $pY = static::y($point); 105 | return $pX >= $x && $pX <= $x + $width && $pY >= $y && $pY <= $y + $height; 106 | } 107 | 108 | /** 109 | * 生成成随机数 110 | * @param int $min 111 | * @param int $max 112 | * @param int $count 113 | * @return int[] 114 | */ 115 | public static function randomInt(int $min, int $max, int $count = 2): array { 116 | if ($max - $min <= $count) { 117 | throw new \Exception('range is error'); 118 | } 119 | $items = []; 120 | while ($count > 0) { 121 | $i = random_int($min, $max); 122 | if (in_array($i, $items)) { 123 | continue; 124 | } 125 | $items[] = $i; 126 | $count --; 127 | } 128 | return $items; 129 | } 130 | } -------------------------------------------------------------------------------- /src/ImageManager.php: -------------------------------------------------------------------------------- 1 | Gd::class, 20 | 'gd2' => Gd::class, 21 | 'imagick' => Imagick::class, 22 | 'gmagick' => Gmagick::class, 23 | ]; 24 | 25 | /** 26 | * @param string $driver 27 | * @return ImageAdapter 28 | */ 29 | public static function create(string $driver = ''): ImageAdapter { 30 | if (empty($driver)) { 31 | $driver = !function_exists('config') ? 'gd' : config('image.driver', 'gd'); 32 | } 33 | if (isset(self::$map[$driver])) { 34 | $driver = self::$map[$driver]; 35 | } 36 | return new $driver(); 37 | } 38 | 39 | /** 40 | * 创建字体 41 | * @param mixed $file 42 | * @param int $size 43 | * @param mixed $color 44 | * @return FontInterface 45 | */ 46 | public static function createFont(mixed $file, int $size = 16, mixed $color = '#000') : FontInterface 47 | { 48 | return new Font(is_int($file) ? $file : (string)$file, $size, $color); 49 | } 50 | 51 | /** 52 | * 创建尺寸 53 | * @param float|int $width 54 | * @param float|int $height 55 | * @return BoxInterface 56 | */ 57 | public static function createSize(float|int $width, float|int $height): BoxInterface 58 | { 59 | return new Box($width, $height); 60 | } 61 | 62 | /** 63 | * 创建点 64 | * @param float|int $x 65 | * @param float|int $y 66 | * @return PointInterface 67 | */ 68 | public static function createPoint(float|int $x, float|int $y): PointInterface 69 | { 70 | return new Point((int)$x, (int)$y); 71 | } 72 | } -------------------------------------------------------------------------------- /src/Node/BaseNode.php: -------------------------------------------------------------------------------- 1 | styles = $properties; 28 | $this->computed = []; 29 | return $this; 30 | } 31 | 32 | /** 33 | * 设置属性 34 | * @param string $name 35 | * @param $value 36 | * @return $this 37 | */ 38 | public function style(string $name, mixed $value) { 39 | $this->styles[$name] = $value; 40 | $this->computed = []; 41 | return $this; 42 | } 43 | 44 | /** 45 | * 获取计算完成的属性 46 | * @param $name 47 | * @return mixed|null 48 | */ 49 | public function computedStyle(string $name): mixed { 50 | return $this->computed[$name] ?? null; 51 | } 52 | 53 | public function getTop(): int { 54 | return $this->computed['y'] 55 | - $this->computed['margin'][0]; 56 | } 57 | 58 | public function getLeft(): int { 59 | return $this->computed['x'] - $this->computed['margin'][3]; 60 | } 61 | 62 | public function getRight(): int { 63 | return $this->computed['x'] + $this->computed['width'] 64 | + $this->computed['margin'][1]; 65 | } 66 | 67 | public function getBottom(): int { 68 | return $this->computed['y'] + $this->computed['height'] + $this->computed['margin'][2]; 69 | } 70 | 71 | public function innerX(): int { 72 | return $this->computed['x'] 73 | + $this->computed['padding'][3]; 74 | } 75 | 76 | public function innerY(): int { 77 | return $this->computed['y'] 78 | + $this->computed['padding'][0]; 79 | } 80 | 81 | protected function isFlow(): bool { 82 | return isset($this->computed['position']) && 83 | ($this->computed['position'] === 'absolute' || 84 | $this->computed['position'] === 'fixed'); 85 | } 86 | 87 | /** 88 | * 获取元素在父元素的占位高度 89 | * @return int 90 | */ 91 | public function placeholderHeight(): int { 92 | if (array_key_exists('placeholderHeight', $this->computed)) { 93 | return $this->computed['placeholderHeight']; 94 | } 95 | return $this->isFlow() ? 96 | 0 : $this->computed['outerHeight']; 97 | } 98 | 99 | /** 100 | * 重新计算属性 101 | * @param array $parentStyles 102 | */ 103 | public function refresh(array $parentStyles): void { 104 | $styles = $this->styles; 105 | if (!isset($parentStyles['viewWidth'])) { 106 | $parentStyles['viewWidth'] = $styles['width']; 107 | $parentStyles['viewHeight'] = $styles['height'] ?? 0; 108 | } 109 | $styles['padding'] = NodeHelper::padding($styles); 110 | $styles['margin'] = NodeHelper::padding($styles, 'margin'); 111 | $styles['baseX'] = $parentStyles['x'] ?? 0; 112 | $styles['baseY'] = $parentStyles['y'] ?? 0; 113 | $copyKeys = ['color', 'font-size', 'font', 'viewWidth', 'viewHeight', 'parentX', 'parentY']; 114 | foreach ($copyKeys as $key) { 115 | if (isset($styles[$key])) { 116 | continue; 117 | } 118 | if (isset($parentStyles[$key])) { 119 | $styles[$key] = $parentStyles[$key]; 120 | } 121 | } 122 | $styles = $this->refreshPosition($styles, $parentStyles); 123 | $parentInnerWidth = $parentStyles['innerWidth'] ?? $styles['width']; 124 | $styles = $this->refreshSize($styles, $parentInnerWidth, $parentStyles); 125 | if (isset($styles['center'])) { 126 | $styles['x'] = ($parentStyles['outerWidth'] - $styles['width']) / 2; 127 | } 128 | $this->computed = $styles; 129 | } 130 | 131 | protected function refreshPosition(array $styles, array $parentStyles): array { 132 | if (isset($styles['fixed'])) { 133 | $styles['position'] = 'fixed'; 134 | } 135 | if (isset($styles['x'])) { 136 | $styles['x'] += 137 | (!isset($styles['position']) || $styles['position'] !== 'fixed' 138 | || $styles['position'] !== 'absolute' ? $parentStyles['x'] : 0) + $styles['margin'][1]; 139 | } elseif (isset($styles['margin-left'])) { 140 | $styles['x'] = (isset($parentStyles['brother']) ? 141 | $parentStyles['brother']->getRight() : 0) + $styles['margin'][3]; 142 | $styles['y'] = (isset($parentStyles['brother']) ? $parentStyles['brother']->getTop() : 0) + $styles['margin'][0]; 143 | $styles['position'] = $styles['position'] ?? 'absolute'; 144 | } else { 145 | $styles['x'] = ($parentStyles['x'] ?? 0) 146 | + $styles['margin'][1]; 147 | } 148 | if (!isset($styles['y'])) { 149 | if (isset($styles['margin-top'])) { 150 | $styles['y'] = (isset($parentStyles['brother']) ? $parentStyles['brother']->getBottom() : 0) 151 | + $styles['margin'][0]; 152 | } else { 153 | $styles['y'] = ($parentStyles['y'] ?? 0) 154 | + $styles['margin'][0]; 155 | } 156 | } elseif (isset($this->styles['y'])) { 157 | $styles['y'] += ($parentStyles['parentY'] ?? 0) 158 | + $styles['margin'][0]; 159 | } 160 | 161 | return $styles; 162 | } 163 | 164 | protected function refreshSize(array $styles, int $parentInnerWidth, array $parentStyles): array { 165 | if (isset($styles['width'])) { 166 | $styles['outerWidth'] = $styles['width'] + $styles['margin'][1] + $styles['margin'][3]; 167 | } else { 168 | $styles['outerWidth'] = $parentInnerWidth; 169 | $styles['width'] = $parentInnerWidth - $styles['margin'][1] - $styles['margin'][3]; 170 | } 171 | $styles['innerWidth'] = $styles['width'] - $styles['padding'][1] - $styles['padding'][3]; 172 | if (isset($styles['height'])) { 173 | $styles['outerHeight'] = $styles['height'] + $styles['margin'][2] 174 | + $styles['margin'][0]; 175 | $styles['innerHeight'] = $styles['height'] - $styles['padding'][0] - $styles['padding'][2]; 176 | } 177 | return $styles; 178 | } 179 | 180 | /** 181 | * 绘制元素 182 | * @param Image $box 183 | */ 184 | abstract public function draw(Image $box): void; 185 | } -------------------------------------------------------------------------------- /src/Node/BorderNode.php: -------------------------------------------------------------------------------- 1 | content) { 16 | $styles['width'] = isset($styles['width']) ? 17 | NodeHelper::width($styles['width'], $parentStyles) : $parentInnerWidth; 18 | $styles['height'] = isset($styles['width']) ? 19 | NodeHelper::width($styles['height'], $parentStyles) : 1; 20 | } 21 | if (!isset($styles['width']) || !isset($styles['height'])) { 22 | $this->content->style('width', 'auto'); 23 | $this->content->refresh(array_merge($parentStyles, [ 24 | 'x' => $styles['x'] + $styles['padding'][3], 25 | 'y' => $styles['y'] + $styles['padding'][0], 26 | ])); 27 | $styles['width'] = $styles['width'] ?? $this->content->computedStyle('outerWidth'); 28 | $styles['height'] = $styles['height'] ?? $this->content->computedStyle('outerHeight'); 29 | } 30 | $styles['radius'] = NodeHelper::padding($styles, 'radius'); 31 | return parent::refreshSize($styles, $parentInnerWidth, $parentStyles); 32 | } 33 | 34 | public function draw(Image $box): void { 35 | $startX = $this->computed['x']; 36 | $startY = $this->computed['y']; 37 | $endX = $startX + $this->computed['width']; 38 | $endY = $startY + $this->computed['height']; 39 | $color = $box->instance()->converterToColor($this->computed['color']); 40 | $radius = $this->computed['radius']; 41 | $each = function ($radius, $cb) { 42 | if ($radius < 1) { 43 | return; 44 | } 45 | for ($i = 1; $i < $radius; $i ++) { 46 | $j = $radius - sqrt(pow($radius, 2) - pow(abs($radius - $i), 2)); 47 | $cb($i, intval($j)); 48 | } 49 | }; 50 | // top-left 51 | $each($radius[0], function ($i, $j) use ($box, $startX, $startY, $color) { 52 | $box->instance()->dot(new Point($startX + $i, $startY + $j), $color); 53 | }); 54 | // top 55 | $box->instance()->line(new Point($startX + $radius[0], $startY), 56 | new Point($endX - $radius[1], $startY), $color); 57 | // top-right 58 | $each($radius[1], function ($i, $j) use ($box, $endX, $startY, $color) { 59 | $box->instance()->dot(new Point($endX - $i, $startY + $j), $color); 60 | }); 61 | // right 62 | $box->instance()->line(new Point($endX, $startY + $radius[1]), 63 | new Point($endX, $endY - $radius[2]), $color); 64 | // bottom-right 65 | $each($radius[2], function ($i, $j) use ($box, $endX, $endY, $color) { 66 | $box->instance()->dot(new Point($endX - $i, $endY - $j), $color); 67 | }); 68 | // bottom 69 | $box->instance()->line(new Point($endX - $radius[2], $endY), 70 | new Point($startX + $radius[3], $endY), $color); 71 | // bottom-left 72 | $each($radius[3], function ($i, $j) use ($box, $startX, $endY, $color) { 73 | $box->instance()->dot(new Point($startX + $i, $endY - $j), $color); 74 | }); 75 | // left 76 | $box->instance()->line(new Point($startX, $endY - $radius[3]), 77 | new Point($startX, $startY + $radius[0]), $color); 78 | $this->content->draw($box); 79 | } 80 | 81 | /** 82 | * @param array|BaseNode|null $content 83 | * @param array{size: int, fixed: bool, margin: int} $properties 84 | * @return BaseNode 85 | */ 86 | public static function create(array|BaseNode|null $content, array $properties = []): BaseNode { 87 | if (is_array($content)) { 88 | list($content, $properties) = [null, $content]; 89 | } 90 | return (new static($content))->setStyles($properties); 91 | } 92 | } -------------------------------------------------------------------------------- /src/Node/BoxNode.php: -------------------------------------------------------------------------------- 1 | children = array_merge($this->children, $nodes); 23 | $this->computed = []; 24 | return $this; 25 | } 26 | 27 | protected function refreshSize(array $styles, $parentInnerWidth, array $parentStyles): array { 28 | $styles = parent::refreshSize($styles, $parentInnerWidth, $parentStyles); 29 | $styles['parentX'] = $styles['x']; 30 | $styles['parentY'] = $styles['y']; 31 | $parentStyles = array_merge($parentStyles, $styles, [ 32 | 'x' => $styles['x'] + $styles['padding'][3], 33 | 'y' => $styles['y'] + $styles['padding'][0], 34 | ]); 35 | $oldY = $parentStyles['y']; 36 | $maxY = $oldY; 37 | foreach ($this->children as $node) { 38 | $node->refresh($parentStyles); 39 | $parentStyles['y'] += $node->placeholderHeight(); 40 | $parentStyles['brother'] = $node; 41 | $maxY = max($maxY, $node->getBottom()); 42 | } 43 | if (!isset($this->styles['height'])) { 44 | $maxY -= $oldY; 45 | $styles['innerHeight'] = $maxY; 46 | $styles['height'] = $maxY + $styles['padding'][2] + $styles['padding'][3]; 47 | $styles['outerHeight'] = $styles['height'] + $styles['margin'][2] 48 | + $styles['margin'][0]; 49 | } else { 50 | $styles['outerHeight'] = $this->styles['height'] + $styles['margin'][0] + $styles['margin'][2]; 51 | $styles['innerHeight'] = $this->styles['height'] - $styles['padding'][0] - $styles['padding'][2]; 52 | } 53 | $styles['radius'] = NodeHelper::padding($styles, 'radius'); 54 | return $styles; 55 | } 56 | 57 | public function draw(Image $box): void { 58 | if (isset($this->computed['background'])) { 59 | $x = $this->computed['x']; 60 | $y = $this->computed['y']; 61 | $width = $this->computed['width']; 62 | $height = $this->computed['height']; 63 | $radius = $this->computed['radius']; 64 | if ($this->computed['background'] instanceof ImgNode) { 65 | $this->drawBackgroundImage($box, $this->computed['background'], $x, $y, $width, $height, $radius); 66 | } else { 67 | $this->drawFill($box, $this->computed['background'], $x, $y, $width, $height, $radius); 68 | } 69 | } 70 | foreach ($this->children as $node) { 71 | $node->draw($box); 72 | } 73 | } 74 | 75 | /** 76 | * 从这里开始画 77 | * @return Image 78 | */ 79 | public function beginDraw(): Image { 80 | $this->refresh([ 81 | 'color' => '#000', 82 | 'font-size' => 16 83 | ]); 84 | $box = new Image(); 85 | $box->instance()->create(new Box($this->computed['outerWidth'], $this->computed['outerHeight'])); 86 | if (!isset($this->computed['background'])) { 87 | $this->computed['background'] = '#fff'; 88 | } 89 | $this->draw($box); 90 | return $box; 91 | } 92 | 93 | protected function drawFill(Image $box, string $color, int $x, int $y, int $width, int $height, float|int $radius): void { 94 | if ($this->isEmpty($radius)) { 95 | $box->instance()->fill($color); 96 | return; 97 | } 98 | $img = ImageManager::create() 99 | ->create(new Box($width, $height), $color); 100 | $bg = $img->converterFromColor($img->converterToColor($color)); 101 | $tempColor = [255 - $bg[0], 255 - $bg[1], 255 - $bg[2], 1]; 102 | $this->setColorOutBox($img, $tempColor, $radius); 103 | $img->transparent($tempColor); 104 | $box->instance()->paste($img, new Point($x, $y)); 105 | unset($img); 106 | } 107 | 108 | protected function drawBackgroundImage(Image $box, ImgNode $node, $x, $y, $width, $height, $radius) { 109 | if ($this->isEmpty($radius)) { 110 | $node->refresh($this->computed); 111 | $node->refreshAsBackground($this->computed); 112 | $node->draw($box); 113 | return; 114 | } 115 | $image = clone $node->getImage(); 116 | $image->scale(new Box($width, $height)); 117 | $tempColor = [0, 0, 0, 1]; 118 | $this->setColorOutBox($image, $tempColor, $radius); 119 | $image->transparent($tempColor); 120 | $box->instance()->paste($image, new Point($x, $y)); 121 | } 122 | 123 | protected function setColorOutBox(ImageAdapter $box, $color, $radius) { 124 | $width = $box->getWidth(); 125 | $height = $box->getHeight(); 126 | $each = function ($radius, $cb) { 127 | if ($radius < 1) { 128 | return; 129 | } 130 | for ($i = 0; $i < $radius; $i ++) { 131 | for ($j = 0; $j < $radius; $j ++) { 132 | if (pow($radius - $i, 2) + pow($radius - $j, 2) 133 | > pow($radius, 2)) { 134 | $cb($i, $j); 135 | } else { 136 | break; 137 | } 138 | } 139 | } 140 | }; 141 | $each($radius[0], function ($i, $j) use ($box, $color) { 142 | $box->dot(new Point($i, $j), $color); 143 | }); 144 | $each($radius[1], function ($i, $j) use ($width, $box, $color) { 145 | $box->dot(new Point($width - $i, $j), $color); 146 | }); 147 | $each($radius[2], function ($i, $j) use ($width, $height, $box, $color) { 148 | $box->dot(new Point($width - $i, $height - $j), $color); 149 | }); 150 | $each($radius[3], function ($i, $j) use ($width, $height, $box, $color) { 151 | $box->dot(new Point($i, $height - $j), $color); 152 | }); 153 | } 154 | 155 | protected function isBoxInner($x, $y, $width, $height, $radius): bool { 156 | if ($radius[0] > 0) { 157 | if ($x < $radius[0] && $y < $radius[0]) { 158 | return pow($radius[0] - $x, 2) + pow($radius[0] - $y, 2) 159 | < pow($radius[0], 2); 160 | } 161 | } 162 | if ($radius[1] > 0) { 163 | if ($x > $width - $radius[1] && $y < $radius[1]) { 164 | return pow($x - $width + $radius[1], 2) + pow($radius[1] - $y, 2) 165 | < pow($radius[1], 2); 166 | } 167 | } 168 | if ($radius[2] > 0) { 169 | if ($x > $width - $radius[2] && $y > $height - $radius[2]) { 170 | return pow($x - $width + $radius[2], 2) + pow($y - $height + $radius[2], 2) 171 | < pow($radius[2], 2); 172 | } 173 | } 174 | if ($radius[3] > 0) { 175 | if ($x < $radius[3] && $y > $height - $radius[3]) { 176 | return pow($radius[3] - $x, 2) + pow($y - $height + $radius[3], 2) 177 | < pow($radius[3], 2); 178 | } 179 | } 180 | return $x > 0 && $x < $width && $y > 0 && $y < $height; 181 | } 182 | 183 | protected function isEmpty($data): bool { 184 | if (empty($data)) { 185 | return true; 186 | } 187 | foreach ($data as $val) { 188 | if (!empty($val)) { 189 | return false; 190 | } 191 | } 192 | return true; 193 | } 194 | 195 | /** 196 | * @param array{padding: int, background: string, width: int} $properties 197 | * @return BoxNode 198 | */ 199 | public static function create(array $properties = []) { 200 | return (new static())->setStyles($properties); 201 | } 202 | 203 | public static function parse(string $content): BoxNode { 204 | $lines = explode(PHP_EOL, ltrim($content)); 205 | // 修复linux换行符 206 | if (preg_match('/^\[(.+)\]$/', trim($lines[0]), $match)) { 207 | $box = static::create(static::parseProperties($match[1])); 208 | array_shift($lines); 209 | } else { 210 | $box = new static(); 211 | } 212 | foreach ($lines as $line) { 213 | $box->append(static::parseNode($line)); 214 | } 215 | return $box; 216 | } 217 | 218 | protected static function parseNode(string $content): BaseNode { 219 | if (!preg_match('/^\[(.+)\](.*)$/', $content, $match)) { 220 | return TextNode::create($content); 221 | } 222 | $properties = static::parseProperties($match[1]); 223 | if (isset($properties['img'])) { 224 | return ImgNode::create($match[2], $properties); 225 | } 226 | return TextNode::create($match[2], $properties); 227 | } 228 | 229 | protected static function parseProperties(string $content): array { 230 | $properties = []; 231 | foreach (explode(' ', $content) as $line) { 232 | $args = explode('=', $line, 2); 233 | if (count($args) === 1) { 234 | $args[1] = true; 235 | if (str_starts_with($args[0], '!')) { 236 | $args[1] = false; 237 | $args[0] = substr($args[0], 1); 238 | } 239 | 240 | } 241 | $properties[$args[0]] = static::parseVal($args[1]); 242 | } 243 | return $properties; 244 | } 245 | 246 | protected static function parseVal(mixed $val): mixed { 247 | if (is_numeric($val)) { 248 | return $val; 249 | } 250 | if ($val === 'true') { 251 | return true; 252 | } 253 | if ($val === 'false') { 254 | return false; 255 | } 256 | return $val; 257 | } 258 | } -------------------------------------------------------------------------------- /src/Node/CircleNode.php: -------------------------------------------------------------------------------- 1 | computed['fill'])) { 18 | $box->instance()->circle(new Point($this->computed['x'], $this->computed['y']), $this->computed['radius'], $this->computed['color'], false); 19 | return; 20 | } 21 | if (is_string($this->computed['fill'])) { 22 | $box->instance()->circle(new Point($this->computed['x'], $this->computed['y']), 23 | $this->computed['radius'], $this->computed['fill'], true); 24 | return; 25 | } 26 | $node = $this->computed['fill']; 27 | $width = $this->computed['radius'] * 2; 28 | if ($node instanceof ImgNode) { 29 | $image = clone $node->getImage(); 30 | $size = $image->getSize(); 31 | if ($image->getWidth() < $image->getHeight()) { 32 | $image->scale($size->widen($width)); 33 | } else { 34 | $image->scale($size->heighten($width)); 35 | } 36 | $r = $this->computed['radius']; 37 | $startX = $this->computed['x'] - $r; 38 | $startY = $this->computed['y'] - $r; 39 | $isBlack = function ($rgb) { 40 | return $rgb[0] === 0 && $rgb[1] === 0 && $rgb[2] === 0 && $rgb[3] === 0; 41 | }; 42 | for ($x = 0; $x < $width; $x ++) { 43 | for ($y = 0; $y < $width; $y ++) { 44 | if (pow($x - $r, 2) + pow($y - $r, 2) < pow($r, 2)) { 45 | $color = $image->getColorAt(new Point($x, $y)); 46 | if ($image->getRealType() === 'png' && 47 | $isBlack($image->converterFromColor($color))) { 48 | continue; 49 | } 50 | $box->instance()->dot(new Point($x + $startX, $y + $startY), 51 | $color); 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | /** 59 | * @param string $x 60 | * @param string $y 61 | * @param int|float $radius 62 | * @param string $fill 63 | * @param bool|string $color 64 | * @param int $thickness 65 | * @return CircleNode 66 | */ 67 | public static function create(string $x, string $y, int|float $radius, string $fill, bool|string $color = false, int $thickness = 1) { 68 | return (new static())->setStyles(compact('x', 'y', 'radius', 'fill', 'color', 'thickness')); 69 | } 70 | } -------------------------------------------------------------------------------- /src/Node/ImgNode.php: -------------------------------------------------------------------------------- 1 | setSrc($src); 26 | $this->setStyles($properties); 27 | } 28 | 29 | /** 30 | * @param string $src 31 | * @return ImgNode 32 | */ 33 | public function setSrc(string $src) { 34 | $this->src = trim($src); 35 | $this->image = null; 36 | return $this; 37 | } 38 | 39 | public function getImage() { 40 | if (empty($this->image)) { 41 | $this->image = ImageManager::create()->open($this->src); 42 | } 43 | return $this->image; 44 | } 45 | 46 | protected function refreshSize(array $styles, int $parentInnerWidth, array $parentStyles): array { 47 | $styles['width'] = $this->getWidth($parentStyles); 48 | $styles['height'] = $this->getHeight($parentStyles); 49 | return parent::refreshSize($styles, $parentInnerWidth, $parentStyles); 50 | } 51 | 52 | public function refreshAsBackground(array $parentStyles): void { 53 | if (isset($this->styles['full'])) { 54 | $this->computed['width'] = $parentStyles['width']; 55 | $this->computed['height'] = $parentStyles['height']; 56 | return; 57 | } 58 | if (isset($this->styles['width']) && strpos($this->styles['width'], '%')) { 59 | $this->computed['width'] = NodeHelper::percentage($parentStyles['width'], $this->styles['width']); 60 | } 61 | if (isset($this->styles['height']) && strpos($this->styles['height'], '%')) { 62 | $this->computed['height'] = NodeHelper::percentage($parentStyles['height'], $this->styles['height']); 63 | } 64 | } 65 | 66 | public function outerHeight(): int { 67 | return $this->computed['outerHeight']; 68 | } 69 | 70 | protected function getWidth(array $properties) { 71 | if (!isset($this->styles['width']) 72 | && isset($this->styles['height']) 73 | && is_numeric($this->styles['height'])) { 74 | return $this->styles['height'] * $this->getImage()->getWidth() 75 | / $this->getImage()->getHeight(); 76 | } 77 | $width = NodeHelper::width(isset($this->styles['width']) ? $this->styles['width'] : null, $properties); 78 | if (!empty($width)) { 79 | return $width; 80 | } 81 | return $this->getImage()->getWidth(); 82 | } 83 | 84 | protected function getHeight(array $properties) { 85 | if (!isset($this->styles['height']) 86 | && isset($this->styles['width']) 87 | && is_numeric($this->styles['width'])) { 88 | return $this->styles['width'] * $this->getImage()->getHeight() / $this->getImage()->getWidth(); 89 | } 90 | $width = NodeHelper::width($this->styles['height'] ?? null, $properties, 'height'); 91 | if (!empty($width)) { 92 | return $width; 93 | } 94 | return $this->getImage()->getHeight(); 95 | } 96 | 97 | public function draw(Image $box): void { 98 | $img = $this->getImage(); 99 | if ($img->getWidth() != $this->computed['width'] || $img->getHeight() != $this->computed['height']) { 100 | $img = clone $this->getImage(); 101 | $img->scale(new Box($this->computed['width'], $this->computed['height'])); 102 | } 103 | $box->instance()->paste($img, new Point($this->computed['x'], $this->computed['y'])); 104 | } 105 | 106 | /** 107 | * @param string|File $src 108 | * @param array{width: int, height: int, center: bool} $properties 109 | * @return ImgNode 110 | */ 111 | public static function create(string|File $src, array $properties = []) { 112 | return (new static((string)$src))->setStyles($properties); 113 | } 114 | } -------------------------------------------------------------------------------- /src/Node/LineNode.php: -------------------------------------------------------------------------------- 1 | styles['points'] = $this->styles['points'] ?? []; 12 | if ($this->styles['fixed']) { 13 | $this->computed['placeholderHeight'] = 0; 14 | return; 15 | } 16 | $this->styles['padding'] = NodeHelper::padding($this->styles); 17 | $this->styles['margin'] = NodeHelper::padding($this->styles, 'margin'); 18 | if (!isset($this->styles['points'][0]) || empty($this->styles['points'][0])) { 19 | $this->styles['points'][0] = [ 20 | $parentStyles['x'] + $this->styles['margin'][3] + $this->styles['padding'][3], 21 | $parentStyles['y'] + $this->styles['margin'][0] + $this->styles['padding'][0], 22 | ]; 23 | } 24 | $outerHeight = $this->styles['margin'][0] + $this->styles['padding'][0] + 25 | $this->styles['margin'][2] + $this->styles['padding'][2]; 26 | if (isset($this->styles['points'][1])) { 27 | $this->computed['placeholderHeight'] = abs($this->styles['points'][0][1] - $this->styles['points'][1][1]) 28 | + $outerHeight; 29 | return; 30 | } 31 | if (isset($this->styles['width'])) { 32 | $width = NodeHelper::width($this->styles['width'], $parentStyles); 33 | $this->styles['points'][1] = [ 34 | $this->styles['points'][0][0] + $width, 35 | $this->styles['points'][0][1] 36 | ]; 37 | $this->computed['placeholderHeight'] = $outerHeight; 38 | return; 39 | } 40 | if (isset($this->styles['height'])) { 41 | $height = NodeHelper::width($this->styles['height'], $parentStyles); 42 | $this->styles['points'][1] = [ 43 | $this->styles['points'][0][0], 44 | $this->styles['points'][0][1] + $height 45 | ]; 46 | $this->computed['placeholderHeight'] = $height + $outerHeight; 47 | return; 48 | } 49 | $this->computed['placeholderHeight'] = 0; 50 | } 51 | 52 | public function draw(Image $box): void { 53 | $points = $this->styles['points']; 54 | for ($i = count($points) - 1; $i > 0; $i --) { 55 | $box->instance()->line(new Point($points[$i][0], $points[$i][1]), 56 | new Point($points[$i - 1][0], $points[$i - 1][1]), $this->styles['color']); 57 | } 58 | } 59 | 60 | /** 61 | * @param array|int $x 62 | * @param int $y 63 | * @param int $x2 64 | * @param int $y2 65 | * @param array{size: int, fixed: bool, color: string} $properties 66 | * @return LineNode 67 | */ 68 | public static function create(array|int $x, int $y = 0, int $x2 = 0, int $y2 = 0, array $properties = []) { 69 | if (is_array($x)) { 70 | $properties = $x; 71 | } else { 72 | $properties['points'] = [ 73 | [$x, $y], 74 | [$x2, $y2] 75 | ]; 76 | } 77 | return (new static()) 78 | ->setStyles($properties); 79 | } 80 | 81 | } -------------------------------------------------------------------------------- /src/Node/NodeHelper.php: -------------------------------------------------------------------------------- 1 | $val) { 15 | if (count($map) <= $i) { 16 | break; 17 | } 18 | $name = sprintf('%s-%s', $key, $map[$i]); 19 | if (isset($properties[$name])) { 20 | $data[$i] = $properties[$name]; 21 | } 22 | } 23 | return $data; 24 | }; 25 | if (!isset($properties[$key])) { 26 | return $formatVal([0, 0, 0, 0]); 27 | } 28 | $property = $properties[$key]; 29 | if (!is_array($property)) { 30 | $property = explode(',', $property); 31 | } 32 | $count = count($property); 33 | if ($count === 1) { 34 | return $formatVal([$property[0], $property[0], $property[0], $property[0]]); 35 | } 36 | if ($count === 2) { 37 | return $formatVal([$property[0], $property[1], $property[0], $property[1]]); 38 | } 39 | if ($count === 3) { 40 | return $formatVal([$property[0], $property[1], $property[2], $property[1]]); 41 | } 42 | return $formatVal($property); 43 | } 44 | 45 | public static function width($value, array $parentProperties, $property = 'width') { 46 | if (empty($value)) { 47 | return null; 48 | } 49 | if (is_numeric($value)) { 50 | return $value; 51 | } 52 | if (!preg_match('/([\d\.]+)\s*(%|vw|vh)/', $value, $match)) { 53 | return null; 54 | } 55 | 56 | $arg = floatval($match[1]); 57 | if ($match[2] === '%') { 58 | $key = 'inner'.Str::studly($property); 59 | if (!isset($parentProperties[$key])) { 60 | $key = 'innerWidth'; 61 | } 62 | return $arg * $parentProperties[$key] / 100; 63 | } 64 | if ($match[2] === 'vw') { 65 | return $arg * $parentProperties['viewWidth'] / 100; 66 | } 67 | return $arg * $parentProperties['viewHeight'] / 100; 68 | } 69 | 70 | /** 71 | * 根据百分比算值 72 | * @param $length 73 | * @param $val 74 | * @return int 75 | */ 76 | public static function percentage($length, $val) { 77 | if (is_numeric($val)) { 78 | return intval($val); 79 | } 80 | if (!preg_match('/([\d\.]+)\s*(%|vw|vh)/', $val, $match)) { 81 | return intval($val); 82 | } 83 | return intval($length * floatval($match[1]) / 100); 84 | } 85 | 86 | public static function step($cb, $start, $end, $step = 1) { 87 | $diff = ($start > $end ? -1 : 1) * abs($step === 0 ? 1 : $step); 88 | while (true) { 89 | $cb($start); 90 | if (($diff > 0 && $start >= $end) || ($diff < 0 && $start <= $end)) { 91 | return; 92 | } 93 | $start += $diff; 94 | if (($diff > 0 && $start > $end) || ($diff < 0 && $start < $end)) { 95 | $start = $end; 96 | } 97 | } 98 | } 99 | 100 | public static function orDefault($key, array $args, array $args1, mixed $default = 0): mixed { 101 | if (isset($args[$key])) { 102 | return $args[$key]; 103 | } 104 | if (isset($args1[$key])) { 105 | return $args1[$key]; 106 | } 107 | return $default; 108 | } 109 | 110 | } -------------------------------------------------------------------------------- /src/Node/RectNode.php: -------------------------------------------------------------------------------- 1 | instance()->rectangle(new Point($this->computed['x'], $this->computed['y'] + $this->computed['height']), 12 | new Point($this->computed['x'] + $this->computed['width'], $this->computed['y']), $this->computed['color']); 13 | } 14 | 15 | /** 16 | * @param array{points: array[], color: string} $properties 17 | * @return RectNode 18 | */ 19 | public static function create(array $properties) { 20 | return (new static())->setStyles($properties); 21 | } 22 | } -------------------------------------------------------------------------------- /src/Node/TextNode.php: -------------------------------------------------------------------------------- 1 | text($content); 31 | } 32 | 33 | protected function getTmpImage() { 34 | if (empty($this->tmpImage)) { 35 | $this->tmpImage = ImageManager::create()->create(new Box(100, 30)); 36 | } 37 | return $this->tmpImage; 38 | } 39 | 40 | protected function isWrap() { 41 | return !array_key_exists('wrap', $this->styles) 42 | || $this->styles['wrap'] !== false; 43 | } 44 | 45 | /** 46 | * @param string $content 47 | * @return $this 48 | */ 49 | public function text(string $content) { 50 | $this->content = $content; 51 | return $this; 52 | } 53 | 54 | protected function refreshSize(array $styles, int $parentInnerWidth, array $parentStyles): array 55 | { 56 | $innerWidth = !isset($styles['width']) || $styles['width'] === 'auto' ? $parentInnerWidth : 57 | ($this->styles['width'] - $styles['padding'][1] - $styles['padding'][3]); 58 | $styles['lineCenter'] = $parentStyles['innerWidth'] / 2 + $parentStyles['x']; 59 | $styles['font-size'] = NodeHelper::orDefault('font-size', $styles, $parentStyles, 16); 60 | $styles['lineSpace'] = NodeHelper::orDefault('lineSpace', $styles, $parentStyles, 6); 61 | $styles['letterSpace'] = NodeHelper::orDefault('letterSpace', $styles, $parentStyles, 0); 62 | $styles['color'] = NodeHelper::orDefault('color', $styles, $parentStyles, '#333'); 63 | $styles['font'] = NodeHelper::orDefault('font', $styles, $parentStyles, 1); 64 | 65 | if (str_starts_with($styles['font'], '@')) { 66 | $styles['font'] = $parentStyles[substr($styles['font'], 1)]; 67 | } 68 | $this->font = new Font($styles['font'], $styles['font-size'], 69 | $styles['color']); 70 | $this->computed = $styles; 71 | list($styles['lines'], $styles['contentWidth']) = $this->getLines($innerWidth); 72 | if (isset($styles['width']) && $styles['width'] === 'auto') { 73 | $styles['width'] = $styles['contentWidth'] + $styles['padding'][1] + $styles['padding'][3]; 74 | } 75 | $styles['height'] = count($styles['lines']) 76 | * ($styles['font-size'] + $styles['lineSpace']) + $styles['padding'][0] + $styles['padding'][2]; 77 | return parent::refreshSize($styles, $parentInnerWidth, $parentStyles); 78 | } 79 | 80 | public function draw(Image $box): void { 81 | // $space = ($this->computed['font-size'] + $this->computed['letterSpace']) / 2; 82 | $lineSpace = $this->computed['font-size'] + $this->computed['lineSpace']; 83 | $x = $this->innerX(); 84 | $y = $this->innerY() + $this->computed['font-size']; 85 | $center = isset($this->computed['center']); 86 | foreach ($this->computed['lines'] as $line) { 87 | $startX = $x; 88 | if ($center) { 89 | $startX = $this->computed['lineCenter'] - $this->getFontWidth($line) / 2; 90 | } 91 | $box->instance()->text($line, $this->font, new Point($startX, $y)); 92 | // foreach ($line as $font) { 93 | // if (!is_null($font)) { 94 | // $box->text($font, $startX, $y, $this->computed['font-size'], 95 | // $this->computed['color'], $this->computed['font']); 96 | // } 97 | // $startX += $space; 98 | // } 99 | $y += $lineSpace; 100 | } 101 | } 102 | 103 | protected function getLines(int $maxWidth): array { 104 | if (!$this->isWrap()) { 105 | return [ 106 | [$this->content], 107 | min($this->getFontWidth($this->content), $maxWidth), 108 | ]; 109 | } 110 | $lines = []; 111 | $length = mb_strlen($this->content); 112 | $start = 0; 113 | $width = 0; 114 | for ($i = 1; $i <= $length; $i ++) { 115 | $line = mb_substr($this->content, $start, $i - $start); 116 | $w = $this->getFontWidth($line); 117 | if ( 118 | $w > $maxWidth 119 | ) { 120 | $lines[] = mb_substr($this->content, $start, $i - $start - 1); 121 | $width = $maxWidth; 122 | $start = $i - 1; 123 | } elseif ($i === $length) { 124 | $lines[] = $line; 125 | $width = max($width, $w); 126 | break; 127 | } 128 | } 129 | return [$lines, $width]; 130 | } 131 | 132 | protected function getFontWidth($font) { 133 | $box = $this->getTmpImage()->fontSize($font, $this->font, 0); 134 | return $box->getWidth(); 135 | } 136 | 137 | /** 138 | * @param string $content 139 | * @param array{size: int, color: string, letterSpace: int, lineSpace: int, wrap: bool, font: FontInterface, center: bool} $properties 140 | * @return TextNode 141 | */ 142 | public static function create(string $content, array $properties = []) { 143 | return (new static($content))->setStyles($properties); 144 | } 145 | } -------------------------------------------------------------------------------- /src/QrCode.php: -------------------------------------------------------------------------------- 1 | level = $level; 42 | return $this; 43 | } 44 | 45 | /** 46 | * 尺寸 47 | * @param int $width 48 | * @param int $height 49 | * @return $this 50 | */ 51 | public function setSize(int $width, int $height) { 52 | $this->width = $width; 53 | $this->height = $height; 54 | return $this; 55 | } 56 | 57 | /** 58 | * @param string $encoding 59 | * @return QrCode 60 | */ 61 | public function setEncoding(string $encoding) { 62 | $this->encoding = $encoding; 63 | return $this; 64 | } 65 | 66 | /** 67 | * 生成二维码 68 | * @param string $value 69 | * @return $this 70 | */ 71 | public function encode(string $value) { 72 | $renderer = new QrCodeImageRenderer(new RendererStyle($this->width)); 73 | $renderer->render(Encoder::encode($value, ErrorCorrectionLevel::forBits($this->level), $this->encoding)); 74 | $this->resource = $renderer->getImage(); 75 | return $this; 76 | } 77 | 78 | /** 79 | * 添加LOGO 80 | * @param string|Image|resource $logo 81 | * @return $this 82 | */ 83 | public function addLogo($logo) { 84 | if ($logo instanceof Image) { 85 | $logo = $logo->instance(); 86 | } elseif (!$logo instanceof ImageAdapter) { 87 | $logo = ImageManager::create()->loadResource($logo); 88 | } 89 | $width = ($this->width - $logo->getWidth()) / 2; 90 | $logoWidth = $this->width / 5; 91 | $logo->scale(new Box($logoWidth, $logoWidth)); 92 | $this->instance()->paste($logo, new Point($width, 93 | $width)); 94 | return $this; 95 | } 96 | 97 | /** 98 | * 发送邮件二维码 99 | * @param $email 100 | * @param null $subject 101 | * @param null $body 102 | * @return QrCode 103 | */ 104 | public function email($email, $subject = null, $body = null) { 105 | $email = 'mailto:'.$email; 106 | if (!is_null($subject) || !is_null($body)) { 107 | $email .= '?'.http_build_query(compact('subject', 'body')); 108 | } 109 | return $this->encode($email); 110 | } 111 | 112 | /** 113 | * 地理位置二维码 114 | * @param $latitude 115 | * @param $longitude 116 | * @return QrCode 117 | */ 118 | public function geo($latitude, $longitude) { 119 | return $this->encode(sprintf('geo:%s,%s', $latitude, $longitude)); 120 | } 121 | 122 | /** 123 | * 电话二维码 124 | * @param $phone 125 | * @return QrCode 126 | */ 127 | public function tel($phone) { 128 | return $this->encode('tel:'.$phone); 129 | } 130 | 131 | /** 132 | * 发送短信二维码 133 | * @param $phone 134 | * @param null $message 135 | * @return QrCode 136 | */ 137 | public function sms($phone, $message = null) { 138 | $phone = 'sms:'.$phone; 139 | if (!is_null($message)) { 140 | $phone .= ':'. $message; 141 | } 142 | return $this->encode($phone); 143 | } 144 | 145 | /** 146 | * WIFI 二维码 147 | * @param string $ssid 网络的SSID 148 | * @param string $password 149 | * @param string $encryption WPA/WEP 150 | * @param bool $hidden true/false 是否是隐藏网络 151 | * @return QrCode 152 | */ 153 | public function wifi($ssid = null, $password = null, $encryption = null, $hidden = null) { 154 | $wifi = 'WIFI:'; 155 | if (!is_null($encryption)) { 156 | $wifi .= 'T:'.$encryption.';'; 157 | } 158 | if (!is_null($ssid)) { 159 | $wifi .= 'S:'.$ssid.';'; 160 | } 161 | if (!is_null($password)) { 162 | $wifi .= 'P:'.$password.';'; 163 | } 164 | if (!is_null($hidden)) { 165 | $wifi .= 'H:'.($hidden === true ? 'true' : 'false').';'; 166 | } 167 | return $this->encode($wifi); 168 | } 169 | 170 | /** 171 | * 比特币 172 | * @param $address 173 | * @param $amount 174 | * @param $label 175 | * @param $message 176 | * @param $returnAddress 177 | * @return QrCode 178 | */ 179 | public function btc($address, $amount, $label, $message, $returnAddress) { 180 | return $this->encode(sprintf('bitcoin:%s?%s', $address, http_build_query([ 181 | 'amount' => $amount, 182 | 'label' => $label, 183 | '$message' => $message, 184 | 'r' => $returnAddress, 185 | ]))); 186 | } 187 | 188 | /** 189 | * 解码 190 | * @return string 191 | */ 192 | public function decode() { 193 | return (new QrReader($this->instance()->getResource(), QrReader::SOURCE_TYPE_RESOURCE))->text(); 194 | } 195 | } -------------------------------------------------------------------------------- /src/Renderer/QrCodeImageRenderer.php: -------------------------------------------------------------------------------- 1 | rendererStyle->getSize(); 33 | $margin = $this->rendererStyle->getMargin(); 34 | $matrix = $qrCode->getMatrix(); 35 | $matrixSize = $matrix->getWidth(); 36 | 37 | if ($matrixSize !== $matrix->getHeight()) { 38 | throw new InvalidArgumentException('Matrix must have the same width and height'); 39 | } 40 | $totalSize = $matrixSize + ($margin * 2); 41 | $this->blockSize = $size / $totalSize; 42 | 43 | $topPadding = $leftPadding = (int) (($size - ($matrixSize * $this->blockSize)) / 2); 44 | 45 | $fill = $this->rendererStyle->getFill(); 46 | $this->addColor('background', $fill->getBackgroundColor()); 47 | $this->addColor('foreground', $fill->getForegroundColor()); 48 | $this->image = ImageManager::create()->create(new Box($size, $size), $this->colors['background']); 49 | 50 | for ($inputY = 0, $outputY = $topPadding; $inputY < $matrixSize; $inputY++, $outputY += $this->blockSize) { 51 | for ($inputX = 0, $outputX = $leftPadding; $inputX < $matrixSize; $inputX++, $outputX += $this->blockSize) { 52 | if ($matrix->get($inputX, $inputY) === 1) { 53 | $this->drawBlock($outputX, $outputY, 'foreground'); 54 | } 55 | } 56 | } 57 | 58 | return $this->getByteStream(); 59 | } 60 | 61 | 62 | public function getImage() { 63 | return $this->image; 64 | } 65 | 66 | 67 | protected function getByteStream(): string { 68 | ob_start(); 69 | $this->image->saveAs(); 70 | $blob = ob_get_contents(); 71 | ob_end_clean(); 72 | return $blob; 73 | } 74 | 75 | protected function drawBlock(int|float $x, int|float $y, string $colorId) { 76 | $this->image->rectangle( 77 | new Point((int)$x, (int)$y), 78 | new Point((int)($x + $this->blockSize - 1), (int)($y + $this->blockSize - 1)), 79 | $this->colors[$colorId], 80 | true, 81 | ); 82 | } 83 | 84 | protected function addColor(string $id, ColorInterface $color) { 85 | $this->colors[$id] = $this->formatColor($color); 86 | } 87 | 88 | protected function formatColor(ColorInterface $color) : array { 89 | $alpha = 100; 90 | if ($color instanceof Alpha) { 91 | $alpha = $color->getAlpha(); 92 | $color = $color->getBaseColor(); 93 | } 94 | $rgb = $color->toRgb(); 95 | return [$rgb->getRed(), $rgb->getGreen(), $rgb->getBlue(), $alpha / 100]; 96 | } 97 | } -------------------------------------------------------------------------------- /src/SlideCaptcha.php: -------------------------------------------------------------------------------- 1 | .5, 13 | 'slices' => 8, // 切片数量 14 | 'width' => 300, 15 | 'height' => 130, 16 | 'shapeWidth' => 20, 17 | 'shapeHeight' => 20 18 | ]; 19 | 20 | /** 21 | * @var integer[] 22 | */ 23 | protected array $point = []; 24 | 25 | /** 26 | * @var ImageAdapter 27 | */ 28 | protected ImageAdapter|null $shapeImage = null; 29 | 30 | /** 31 | * @var ImageAdapter 32 | */ 33 | protected ImageAdapter|null $slideImage = null; 34 | 35 | public function setConfigs(array $configs): void { 36 | $this->configs = array_merge($this->configs, $configs); 37 | } 38 | 39 | public function isOnlyImage(): bool { 40 | return false; 41 | } 42 | 43 | public function setShape(mixed $shape) { 44 | if ($shape instanceof Image) { 45 | $shape = $shape->instance(); 46 | } elseif (!$shape instanceof ImageAdapter) { 47 | $shape = ImageManager::create()->loadResource($shape); 48 | } 49 | if ($shape->getWidth() > $this->configs['width'] || $shape->getHeight() > $this->configs['height']) { 50 | $shape->scale(new Box($this->configs['shapeWidth'], $this->configs['shapeHeight'])); 51 | } 52 | $this->shapeImage = $shape; 53 | return $this; 54 | } 55 | 56 | public function setPoint(int $x, int $y) { 57 | $this->point = [$x, $y]; 58 | return $this; 59 | } 60 | 61 | /** 62 | * @return integer[] 63 | */ 64 | public function getPoint(): array { 65 | return $this->point; 66 | } 67 | 68 | /** 69 | * @return ImageAdapter 70 | */ 71 | public function getSlideImage(): ImageAdapter { 72 | return $this->slideImage; 73 | } 74 | 75 | public function generate(): mixed { 76 | $this->instance()->scale(new Box($this->configs['width'], 77 | $this->configs['height'])); 78 | $this->drawBox(); 79 | return $this->point; 80 | } 81 | 82 | public function drawBox(): void { 83 | $width = $this->shapeImage->getWidth(); 84 | $height = $this->shapeImage->getHeight(); 85 | if (empty($this->point)) { 86 | $this->point = [ 87 | rand($width, $this->instance()->getWidth() - $width), 88 | rand(0, $this->instance()->getHeight() - $height) 89 | ]; 90 | } 91 | $this->slideImage = ImageManager::create()->create(new Box($width, $height)) 92 | ->setRealType('png'); 93 | $alpha = floatval($this->configs['alpha']); 94 | for ($i = 0; $i < $width; $i ++) { 95 | for ($j = 0; $j < $height; $j ++) { 96 | $current = $this->isValidBound($i, $j); 97 | if (!$current) { 98 | continue; 99 | } 100 | $real_x = $i + $this->point[0]; 101 | $real_y = $j + $this->point[1]; 102 | $color = $this->instance()->getColorAt(new Point($real_x, $real_y)); 103 | $this->slideImage->dot(new Point($i, $j), $color); 104 | list($r, $g, $b) = $this->instance()->converterFromColor($color); 105 | $this->instance()->dot(new Point($real_x, $real_y), [ 106 | floor($r * $alpha), 107 | floor($g * $alpha), 108 | floor($b * $alpha), 109 | ]); 110 | } 111 | } 112 | $this->slideImage->transparent([0, 0, 0]); 113 | } 114 | 115 | /** 116 | * 是否是有效的区域 117 | * @param int $x 118 | * @param int $y 119 | * @return true 120 | */ 121 | public function isValidBound(int $x, int $y): bool { 122 | list($r, $g, $b) = $this->shapeImage->converterFromColor( 123 | $this->shapeImage->getColorAt(new Point($x, $y))); 124 | return $r < 240 || $g < 240 || $b < 240; 125 | } 126 | 127 | /** 128 | * 按指定数值打乱排序重新生成图片 129 | * @param int[] $args 130 | * @param int $rows 多少层 默认2层 131 | * @return array [Image, point[], [width, height]] 132 | */ 133 | public function sortBy(array $args, int $rows = 2): array { 134 | return ImageHelper::sortBy($this->instance(), $args, $rows); 135 | } 136 | 137 | public function verify(mixed $value, mixed $source): bool { 138 | $x = ImageHelper::x($value); 139 | $srcX = ImageHelper::x($source); 140 | return abs($x - $srcX) < 5; 141 | } 142 | 143 | public function toArray(): array { 144 | $args = range(0, intval($this->configs['slices']) - 1); 145 | shuffle($args); 146 | list($bg, $points, $size) = $this->sortBy($args); 147 | return [ 148 | 'image' => $bg->toBase64(), 149 | 'width' => $this->instance()->getWidth(), 150 | 'height' => $this->instance()->getHeight(), 151 | 'imageItems' => array_map(function ($item) use ($size) { 152 | return [ 153 | 'x' => $item[0], 154 | 'y' => $item[1], 155 | 'width' => $size[0], 156 | 'height' => $size[1] 157 | ]; 158 | }, $points), 159 | 'control' => $this->slideImage->toBase64(), 160 | 'controlWidth' => $this->slideImage->getWidth(), 161 | 'controlHeight' => $this->slideImage->getHeight(), 162 | 'controlY' => $this->point[1] 163 | ]; 164 | } 165 | 166 | } -------------------------------------------------------------------------------- /src/ThumbImage.php: -------------------------------------------------------------------------------- 1 | instance()->getWidth(); 23 | $height = $this->instance()->getHeight(); 24 | if ($thumbWidth <= 0) { 25 | $thumbWidth = $auto ? ($thumbHeight / $height * $width) : $width; 26 | } elseif ($thumbHeight <= 0) { 27 | $thumbHeight = $auto ? ($thumbWidth / $width * $height) : $height; 28 | } elseif($auto) { 29 | $rate = min($height / $thumbHeight, $width / $thumbWidth); 30 | $thumbWidth *= $rate; 31 | $thumbHeight *= $rate; 32 | } 33 | $thumb = $this->instance(); 34 | $thumb->thumbnail(new Box($width, $height)); 35 | $thumb->saveAs($output); 36 | return $output; 37 | } 38 | } -------------------------------------------------------------------------------- /src/WaterMark.php: -------------------------------------------------------------------------------- 1 | instance()->getWidth() / 3; 37 | $height = $this->instance()->getHeight() / 3; 38 | return array( 39 | $direction % 3 * $width, 40 | $direction / 3 * $height, 41 | $width, 42 | $height 43 | ); 44 | } 45 | return match ($direction) { 46 | self::Center => array( 47 | ($this->instance()->getWidth() - $width) / 2, 48 | ($this->instance()->getHeight() - $height) / 2, 49 | $width, 50 | $height 51 | ), 52 | self::Left => array( 53 | $padding, 54 | ($this->instance()->getHeight() - $height) / 2, 55 | $width, 56 | $height 57 | ), 58 | self::Top => array( 59 | ($this->instance()->getWidth() - $width) / 2, 60 | $padding, 61 | $width, 62 | $height 63 | ), 64 | self::RightTop => array( 65 | $this->instance()->getWidth() - $width - $padding, 66 | $padding, 67 | $width, 68 | $height 69 | ), 70 | self::Right => array( 71 | $this->instance()->getWidth() - $width - $padding, 72 | ($this->instance()->getHeight() - $height) / 2, 73 | $width, 74 | $height 75 | ), 76 | self::RightBottom => array( 77 | $this->instance()->getWidth() - $width - $padding, 78 | $this->instance()->getHeight() - $height - $padding, 79 | $width, 80 | $height 81 | ), 82 | self::Bottom => array( 83 | ($this->instance()->getWidth() - $width) / 2, 84 | $this->instance()->getHeight() - $height - $padding, 85 | $width, 86 | $height 87 | ), 88 | self::LeftBottom => array( 89 | $padding, 90 | $this->instance()->getHeight() - $height - $padding, 91 | $width, 92 | $height 93 | ), 94 | default => array( 95 | $padding, 96 | $padding, 97 | $width, 98 | $height 99 | ), 100 | }; 101 | } 102 | 103 | /** 104 | * 根据九宫格添加文字 105 | * @param string $text 106 | * @param int $direction 107 | * @param int $fontSize 108 | * @param string $color 109 | * @param string|int $fontFamily 110 | * @return static 111 | */ 112 | public function addTextByDirection(string $text, int $direction = self::Top, 113 | int $fontSize = 16, string $color = '#000', string|int $fontFamily = 5) { 114 | $font = new Font($fontFamily, $fontSize, $color); 115 | $textBox = $this->instance()->fontSize($text, $font); 116 | list($x, $y) = $this->getPointByDirection($direction, $textBox->getWidth(), $textBox->getHeight()); 117 | $this->instance()->text($text, $font, new Point($x, $y)); 118 | return $this; 119 | } 120 | 121 | /** 122 | * 加文字 123 | * @param string $text 124 | * @param int $x 125 | * @param int $y 126 | * @param int $fontSize 127 | * @param string $color 128 | * @param int|string $fontFamily 129 | * @param int $angle 如果 $fontFamily 为 int,则不起作用 130 | * @return static 131 | */ 132 | public function addText(string $text, int $x = 0, int $y = 0, 133 | int $fontSize = 16, string $color = '#000', string|int $fontFamily = 5, 134 | int $angle = 0) { 135 | $this->instance()->text($text, new Font($fontFamily, $fontSize, $color), new Point($x, $y), $angle); 136 | return $this; 137 | } 138 | 139 | /** 140 | * 根据九宫格添加图片 141 | * @param $image 142 | * @param int $direction 143 | * @param int $opacity 144 | * @return static 145 | */ 146 | public function addImageByDirection($image, int $direction = self::Top, int $opacity = 50) { 147 | list($x, $y) = $this->getPointByDirection($direction); 148 | return $this->addImage($image, $x, $y, $opacity); 149 | } 150 | 151 | /** 152 | * 加水印图片 153 | * @param string|Image $image 154 | * @param int $x 155 | * @param int $y 156 | * @param int $opacity 透明度,对png图片不起作用 157 | * @return static 158 | */ 159 | public function addImage($image, int $x = 0, int $y = 0, int $opacity = 50) { 160 | if ($image instanceof Image) { 161 | $image = $image->instance(); 162 | } elseif (!$image instanceof ImageAdapter) { 163 | $image = ImageManager::create()->loadResource($image); 164 | } 165 | $this->instance()->paste($image, new Point($x, $y), $opacity); 166 | return $this; 167 | } 168 | } --------------------------------------------------------------------------------