CaptchaBuilder.php 20 KB

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