CaptchaBuilder.php (18598B)
1 <?php 2 3 namespace Gregwar\Captcha; 4 5 use \Exception; 6 7 /** 8 * Builds a new captcha image 9 * Uses the fingerprint parameter, if one is passed, to generate the same image 10 * 11 * @author Gregwar <g.passault@gmail.com> 12 * @author Jeremy Livingston <jeremy.j.livingston@gmail.com> 13 */ 14 class CaptchaBuilder implements CaptchaBuilderInterface 15 { 16 /** 17 * @var array 18 */ 19 protected $fingerprint = array(); 20 21 /** 22 * @var bool 23 */ 24 protected $useFingerprint = false; 25 26 /** 27 * @var array 28 */ 29 protected $textColor = null; 30 31 /** 32 * @var array 33 */ 34 protected $backgroundColor = null; 35 36 /** 37 * @var array 38 */ 39 protected $backgroundImages = array(); 40 41 /** 42 * @var resource 43 */ 44 protected $contents = null; 45 46 /** 47 * @var string 48 */ 49 protected $phrase = null; 50 51 /** 52 * @var PhraseBuilderInterface 53 */ 54 protected $builder; 55 56 /** 57 * @var bool 58 */ 59 protected $distortion = true; 60 61 /** 62 * The maximum number of lines to draw in front of 63 * the image. null - use default algorithm 64 */ 65 protected $maxFrontLines = null; 66 67 /** 68 * The maximum number of lines to draw behind 69 * the image. null - use default algorithm 70 */ 71 protected $maxBehindLines = null; 72 73 /** 74 * The maximum angle of char 75 */ 76 protected $maxAngle = 8; 77 78 /** 79 * The maximum offset of char 80 */ 81 protected $maxOffset = 5; 82 83 /** 84 * Is the interpolation enabled ? 85 * 86 * @var bool 87 */ 88 protected $interpolation = true; 89 90 /** 91 * Ignore all effects 92 * 93 * @var bool 94 */ 95 protected $ignoreAllEffects = false; 96 97 /** 98 * Allowed image types for the background images 99 * 100 * @var array 101 */ 102 protected $allowedBackgroundImageTypes = array('image/png', 'image/jpeg', 'image/gif'); 103 104 /** 105 * The image contents 106 */ 107 public function getContents() 108 { 109 return $this->contents; 110 } 111 112 /** 113 * Enable/Disables the interpolation 114 * 115 * @param $interpolate bool True to enable, false to disable 116 * 117 * @return CaptchaBuilder 118 */ 119 public function setInterpolation($interpolate = true) 120 { 121 $this->interpolation = $interpolate; 122 123 return $this; 124 } 125 126 /** 127 * Temporary dir, for OCR check 128 */ 129 public $tempDir = 'temp/'; 130 131 public function __construct($phrase = null, PhraseBuilderInterface $builder = null) 132 { 133 if ($builder === null) { 134 $this->builder = new PhraseBuilder; 135 } else { 136 $this->builder = $builder; 137 } 138 139 if ($phrase === null) { 140 $phrase = $this->builder->build(); 141 } 142 143 $this->phrase = $phrase; 144 } 145 146 /** 147 * Setting the phrase 148 */ 149 public function setPhrase($phrase) 150 { 151 $this->phrase = (string) $phrase; 152 } 153 154 /** 155 * Enables/disable distortion 156 */ 157 public function setDistortion($distortion) 158 { 159 $this->distortion = (bool) $distortion; 160 161 return $this; 162 } 163 164 public function setMaxBehindLines($maxBehindLines) 165 { 166 $this->maxBehindLines = $maxBehindLines; 167 168 return $this; 169 } 170 171 public function setMaxFrontLines($maxFrontLines) 172 { 173 $this->maxFrontLines = $maxFrontLines; 174 175 return $this; 176 } 177 178 public function setMaxAngle($maxAngle) 179 { 180 $this->maxAngle = $maxAngle; 181 182 return $this; 183 } 184 185 public function setMaxOffset($maxOffset) 186 { 187 $this->maxOffset = $maxOffset; 188 189 return $this; 190 } 191 192 /** 193 * Gets the captcha phrase 194 */ 195 public function getPhrase() 196 { 197 return $this->phrase; 198 } 199 200 /** 201 * Returns true if the given phrase is good 202 */ 203 public function testPhrase($phrase) 204 { 205 return ($this->builder->niceize($phrase) == $this->builder->niceize($this->getPhrase())); 206 } 207 208 /** 209 * Instantiation 210 */ 211 public static function create($phrase = null) 212 { 213 return new self($phrase); 214 } 215 216 /** 217 * Sets the text color to use 218 */ 219 public function setTextColor($r, $g, $b) 220 { 221 $this->textColor = array($r, $g, $b); 222 223 return $this; 224 } 225 226 /** 227 * Sets the background color to use 228 */ 229 public function setBackgroundColor($r, $g, $b) 230 { 231 $this->backgroundColor = array($r, $g, $b); 232 233 return $this; 234 } 235 236 /** 237 * Sets the ignoreAllEffects value 238 * 239 * @param bool $ignoreAllEffects 240 * @return CaptchaBuilder 241 */ 242 public function setIgnoreAllEffects($ignoreAllEffects) 243 { 244 $this->ignoreAllEffects = $ignoreAllEffects; 245 246 return $this; 247 } 248 249 /** 250 * Sets the list of background images to use (one image is randomly selected) 251 */ 252 public function setBackgroundImages(array $backgroundImages) 253 { 254 $this->backgroundImages = $backgroundImages; 255 256 return $this; 257 } 258 259 /** 260 * Draw lines over the image 261 */ 262 protected function drawLine($image, $width, $height, $tcol = null) 263 { 264 if ($tcol === null) { 265 $tcol = imagecolorallocate($image, $this->rand(100, 255), $this->rand(100, 255), $this->rand(100, 255)); 266 } 267 268 if ($this->rand(0, 1)) { // Horizontal 269 $Xa = $this->rand(0, $width/2); 270 $Ya = $this->rand(0, $height); 271 $Xb = $this->rand($width/2, $width); 272 $Yb = $this->rand(0, $height); 273 } else { // Vertical 274 $Xa = $this->rand(0, $width); 275 $Ya = $this->rand(0, $height/2); 276 $Xb = $this->rand(0, $width); 277 $Yb = $this->rand($height/2, $height); 278 } 279 imagesetthickness($image, $this->rand(1, 3)); 280 imageline($image, $Xa, $Ya, $Xb, $Yb, $tcol); 281 } 282 283 /** 284 * Apply some post effects 285 */ 286 protected function postEffect($image) 287 { 288 if (!function_exists('imagefilter')) { 289 return; 290 } 291 292 if ($this->backgroundColor != null || $this->textColor != null) { 293 return; 294 } 295 296 // Negate ? 297 if ($this->rand(0, 1) == 0) { 298 imagefilter($image, IMG_FILTER_NEGATE); 299 } 300 301 // Edge ? 302 if ($this->rand(0, 10) == 0) { 303 imagefilter($image, IMG_FILTER_EDGEDETECT); 304 } 305 306 // Contrast 307 imagefilter($image, IMG_FILTER_CONTRAST, $this->rand(-50, 10)); 308 309 // Colorize 310 if ($this->rand(0, 5) == 0) { 311 imagefilter($image, IMG_FILTER_COLORIZE, $this->rand(-80, 50), $this->rand(-80, 50), $this->rand(-80, 50)); 312 } 313 } 314 315 /** 316 * Writes the phrase on the image 317 */ 318 protected function writePhrase($image, $phrase, $font, $width, $height) 319 { 320 $length = strlen($phrase); 321 if ($length === 0) { 322 return imagecolorallocate($image, 0, 0, 0); 323 } 324 325 // Gets the text size and start position 326 $size = $width / $length - $this->rand(0, 3) - 1; 327 $box = imagettfbbox($size, 0, $font, $phrase); 328 $textWidth = $box[2] - $box[0]; 329 $textHeight = $box[1] - $box[7]; 330 $x = ($width - $textWidth) / 2; 331 $y = ($height - $textHeight) / 2 + $size; 332 333 if (!count($this->textColor)) { 334 $textColor = array($this->rand(0, 150), $this->rand(0, 150), $this->rand(0, 150)); 335 } else { 336 $textColor = $this->textColor; 337 } 338 $col = imagecolorallocate($image, $textColor[0], $textColor[1], $textColor[2]); 339 340 // Write the letters one by one, with random angle 341 for ($i=0; $i<$length; $i++) { 342 $box = imagettfbbox($size, 0, $font, $phrase[$i]); 343 $w = $box[2] - $box[0]; 344 $angle = $this->rand(-$this->maxAngle, $this->maxAngle); 345 $offset = $this->rand(-$this->maxOffset, $this->maxOffset); 346 imagettftext($image, $size, $angle, $x, $y + $offset, $col, $font, $phrase[$i]); 347 $x += $w; 348 } 349 350 return $col; 351 } 352 353 /** 354 * Try to read the code against an OCR 355 */ 356 public function isOCRReadable() 357 { 358 if (!is_dir($this->tempDir)) { 359 @mkdir($this->tempDir, 0755, true); 360 } 361 362 $tempj = $this->tempDir . uniqid('captcha', true) . '.jpg'; 363 $tempp = $this->tempDir . uniqid('captcha', true) . '.pgm'; 364 365 $this->save($tempj); 366 shell_exec("convert $tempj $tempp"); 367 $value = trim(strtolower(shell_exec("ocrad $tempp"))); 368 369 @unlink($tempj); 370 @unlink($tempp); 371 372 return $this->testPhrase($value); 373 } 374 375 /** 376 * Builds while the code is readable against an OCR 377 */ 378 public function buildAgainstOCR($width = 150, $height = 40, $font = null, $fingerprint = null) 379 { 380 do { 381 $this->build($width, $height, $font, $fingerprint); 382 } while ($this->isOCRReadable()); 383 } 384 385 /** 386 * Generate the image 387 */ 388 public function build($width = 150, $height = 40, $font = null, $fingerprint = null) 389 { 390 if (null !== $fingerprint) { 391 $this->fingerprint = $fingerprint; 392 $this->useFingerprint = true; 393 } else { 394 $this->fingerprint = array(); 395 $this->useFingerprint = false; 396 } 397 398 if ($font === null) { 399 $font = __DIR__ . '/Font/captcha'.$this->rand(0, 5).'.ttf'; 400 } 401 402 if (empty($this->backgroundImages)) { 403 // if background images list is not set, use a color fill as a background 404 $image = imagecreatetruecolor($width, $height); 405 if ($this->backgroundColor == null) { 406 $bg = imagecolorallocate($image, $this->rand(200, 255), $this->rand(200, 255), $this->rand(200, 255)); 407 } else { 408 $color = $this->backgroundColor; 409 $bg = imagecolorallocate($image, $color[0], $color[1], $color[2]); 410 } 411 $this->background = $bg; 412 imagefill($image, 0, 0, $bg); 413 } else { 414 // use a random background image 415 $randomBackgroundImage = $this->backgroundImages[rand(0, count($this->backgroundImages)-1)]; 416 417 $imageType = $this->validateBackgroundImage($randomBackgroundImage); 418 419 $image = $this->createBackgroundImageFromType($randomBackgroundImage, $imageType); 420 } 421 422 // Apply effects 423 if (!$this->ignoreAllEffects) { 424 $square = $width * $height; 425 $effects = $this->rand($square/3000, $square/2000); 426 427 // set the maximum number of lines to draw in front of the text 428 if ($this->maxBehindLines != null && $this->maxBehindLines > 0) { 429 $effects = min($this->maxBehindLines, $effects); 430 } 431 432 if ($this->maxBehindLines !== 0) { 433 for ($e = 0; $e < $effects; $e++) { 434 $this->drawLine($image, $width, $height); 435 } 436 } 437 } 438 439 // Write CAPTCHA text 440 $color = $this->writePhrase($image, $this->phrase, $font, $width, $height); 441 442 // Apply effects 443 if (!$this->ignoreAllEffects) { 444 $square = $width * $height; 445 $effects = $this->rand($square/3000, $square/2000); 446 447 // set the maximum number of lines to draw in front of the text 448 if ($this->maxFrontLines != null && $this->maxFrontLines > 0) { 449 $effects = min($this->maxFrontLines, $effects); 450 } 451 452 if ($this->maxFrontLines !== 0) { 453 for ($e = 0; $e < $effects; $e++) { 454 $this->drawLine($image, $width, $height, $color); 455 } 456 } 457 } 458 459 // Distort the image 460 if ($this->distortion && !$this->ignoreAllEffects) { 461 $image = $this->distort($image, $width, $height, $bg); 462 } 463 464 // Post effects 465 if (!$this->ignoreAllEffects) { 466 $this->postEffect($image); 467 } 468 469 $this->contents = $image; 470 471 return $this; 472 } 473 474 /** 475 * Distorts the image 476 */ 477 public function distort($image, $width, $height, $bg) 478 { 479 $contents = imagecreatetruecolor($width, $height); 480 $X = $this->rand(0, $width); 481 $Y = $this->rand(0, $height); 482 $phase = $this->rand(0, 10); 483 $scale = 1.1 + $this->rand(0, 10000) / 30000; 484 for ($x = 0; $x < $width; $x++) { 485 for ($y = 0; $y < $height; $y++) { 486 $Vx = $x - $X; 487 $Vy = $y - $Y; 488 $Vn = sqrt($Vx * $Vx + $Vy * $Vy); 489 490 if ($Vn != 0) { 491 $Vn2 = $Vn + 4 * sin($Vn / 30); 492 $nX = $X + ($Vx * $Vn2 / $Vn); 493 $nY = $Y + ($Vy * $Vn2 / $Vn); 494 } else { 495 $nX = $X; 496 $nY = $Y; 497 } 498 $nY = $nY + $scale * sin($phase + $nX * 0.2); 499 500 if ($this->interpolation) { 501 $p = $this->interpolate( 502 $nX - floor($nX), 503 $nY - floor($nY), 504 $this->getCol($image, floor($nX), floor($nY), $bg), 505 $this->getCol($image, ceil($nX), floor($nY), $bg), 506 $this->getCol($image, floor($nX), ceil($nY), $bg), 507 $this->getCol($image, ceil($nX), ceil($nY), $bg) 508 ); 509 } else { 510 $p = $this->getCol($image, round($nX), round($nY), $bg); 511 } 512 513 if ($p == 0) { 514 $p = $bg; 515 } 516 517 imagesetpixel($contents, $x, $y, $p); 518 } 519 } 520 521 return $contents; 522 } 523 524 /** 525 * Saves the Captcha to a jpeg file 526 */ 527 public function save($filename, $quality = 90) 528 { 529 imagejpeg($this->contents, $filename, $quality); 530 } 531 532 /** 533 * Gets the image GD 534 */ 535 public function getGd() 536 { 537 return $this->contents; 538 } 539 540 /** 541 * Gets the image contents 542 */ 543 public function get($quality = 90) 544 { 545 ob_start(); 546 $this->output($quality); 547 548 return ob_get_clean(); 549 } 550 551 /** 552 * Gets the HTML inline base64 553 */ 554 public function inline($quality = 90) 555 { 556 return 'data:image/jpeg;base64,' . base64_encode($this->get($quality)); 557 } 558 559 /** 560 * Outputs the image 561 */ 562 public function output($quality = 90) 563 { 564 imagejpeg($this->contents, null, $quality); 565 } 566 567 /** 568 * @return array 569 */ 570 public function getFingerprint() 571 { 572 return $this->fingerprint; 573 } 574 575 /** 576 * Returns a random number or the next number in the 577 * fingerprint 578 */ 579 protected function rand($min, $max) 580 { 581 if (!is_array($this->fingerprint)) { 582 $this->fingerprint = array(); 583 } 584 585 if ($this->useFingerprint) { 586 $value = current($this->fingerprint); 587 next($this->fingerprint); 588 } else { 589 $value = mt_rand($min, $max); 590 $this->fingerprint[] = $value; 591 } 592 593 return $value; 594 } 595 596 /** 597 * @param $x 598 * @param $y 599 * @param $nw 600 * @param $ne 601 * @param $sw 602 * @param $se 603 * 604 * @return int 605 */ 606 protected function interpolate($x, $y, $nw, $ne, $sw, $se) 607 { 608 list($r0, $g0, $b0) = $this->getRGB($nw); 609 list($r1, $g1, $b1) = $this->getRGB($ne); 610 list($r2, $g2, $b2) = $this->getRGB($sw); 611 list($r3, $g3, $b3) = $this->getRGB($se); 612 613 $cx = 1.0 - $x; 614 $cy = 1.0 - $y; 615 616 $m0 = $cx * $r0 + $x * $r1; 617 $m1 = $cx * $r2 + $x * $r3; 618 $r = (int) ($cy * $m0 + $y * $m1); 619 620 $m0 = $cx * $g0 + $x * $g1; 621 $m1 = $cx * $g2 + $x * $g3; 622 $g = (int) ($cy * $m0 + $y * $m1); 623 624 $m0 = $cx * $b0 + $x * $b1; 625 $m1 = $cx * $b2 + $x * $b3; 626 $b = (int) ($cy * $m0 + $y * $m1); 627 628 return ($r << 16) | ($g << 8) | $b; 629 } 630 631 /** 632 * @param $image 633 * @param $x 634 * @param $y 635 * 636 * @return int 637 */ 638 protected function getCol($image, $x, $y, $background) 639 { 640 $L = imagesx($image); 641 $H = imagesy($image); 642 if ($x < 0 || $x >= $L || $y < 0 || $y >= $H) { 643 return $background; 644 } 645 646 return imagecolorat($image, $x, $y); 647 } 648 649 /** 650 * @param $col 651 * 652 * @return array 653 */ 654 protected function getRGB($col) 655 { 656 return array( 657 (int) ($col >> 16) & 0xff, 658 (int) ($col >> 8) & 0xff, 659 (int) ($col) & 0xff, 660 ); 661 } 662 663 /** 664 * Validate the background image path. Return the image type if valid 665 * 666 * @param string $backgroundImage 667 * @return string 668 * @throws Exception 669 */ 670 protected function validateBackgroundImage($backgroundImage) 671 { 672 // check if file exists 673 if (!file_exists($backgroundImage)) { 674 $backgroundImageExploded = explode('/', $backgroundImage); 675 $imageFileName = count($backgroundImageExploded) > 1? $backgroundImageExploded[count($backgroundImageExploded)-1] : $backgroundImage; 676 677 throw new Exception('Invalid background image: ' . $imageFileName); 678 } 679 680 // check image type 681 $finfo = finfo_open(FILEINFO_MIME_TYPE); // return mime type ala mimetype extension 682 $imageType = finfo_file($finfo, $backgroundImage); 683 finfo_close($finfo); 684 685 if (!in_array ($imageType, $this->allowedBackgroundImageTypes)) { 686 throw new Exception('Invalid background image type! Allowed types are: ' . join(', ', $this->allowedBackgroundImageTypes)); 687 } 688 689 return $imageType; 690 } 691 692 /** 693 * Create background image from type 694 * 695 * @param string $backgroundImage 696 * @param string $imageType 697 * @return resource 698 * @throws Exception 699 */ 700 protected function createBackgroundImageFromType($backgroundImage, $imageType) 701 { 702 switch ($imageType) { 703 case 'image/jpeg': 704 $image = imagecreatefromjpeg($backgroundImage); 705 break; 706 case 'image/png': 707 $image = imagecreatefrompng($backgroundImage); 708 break; 709 case 'image/gif': 710 $image = imagecreatefromgif($backgroundImage); 711 break; 712 713 default: 714 throw new Exception('Not supported file type for background image!'); 715 break; 716 } 717 718 return $image; 719 } 720 }