1 <?php 
declare(strict_types
=1); 
   4  * This file is part of the Rapsys PackBundle package. 
   6  * (c) Raphaël Gertz <symfony@rapsys.eu> 
   8  * For the full copyright and license information, please view the LICENSE 
   9  * file that was distributed with this source code. 
  12 namespace Rapsys\PackBundle\Util
; 
  14 use Psr\Container\ContainerInterface
; 
  16 use Rapsys\PackBundle\RapsysPackBundle
; 
  18 use Symfony\Component\Filesystem\Exception\IOExceptionInterface
; 
  19 use Symfony\Component\Filesystem\Filesystem
; 
  20 use Symfony\Component\Routing\Generator\UrlGeneratorInterface
; 
  21 use Symfony\Component\Routing\RouterInterface
; 
  30         protected string $alias; 
  35         protected array $config; 
  38          * Creates a new image util 
  40          * @param ContainerInterface $container The container instance 
  41          * @param RouterInterface $router The RouterInterface instance 
  42          * @param SluggerUtil $slugger The SluggerUtil instance 
  44         public function __construct(protected ContainerInterface 
$container, protected RouterInterface 
$router, protected SluggerUtil 
$slugger) { 
  46                 $this->config 
= $container->getParameter($this->alias 
= RapsysPackBundle
::getAlias()); 
  52          * @param ?int $height The height 
  53          * @param ?int $width The width 
  54          * @return array The captcha data 
  56         public function getCaptcha(?int $height = null, ?int $width = null): array { 
  58                 if ($height === null) { 
  59                         //Set height from config 
  60                         $height = $this->config
['captcha']['height']; 
  64                 if ($width === null) { 
  65                         //Set width from config 
  66                         $width = $this->config
['captcha']['width']; 
  70                 $random = rand(0, 999); 
  76                 $b = $random / 10 % 
10; 
  79                 $c = $random / 100 % 
10; 
  82                 $equation = $a.' * '.$b.' + '.$c; 
  85                 $short = $this->slugger
->short($equation); 
  88                 $hash = $this->slugger
->serialize([$short, $height, $width]); 
  92                         'token' => $this->slugger
->hash(strval($a * $b + 
$c)), 
  93                         'value' => strval($a * $b + 
$c), 
  94                         'equation' => str_replace([' ', '*', '+'], ['-', 'mul', 'add'], $equation), 
  95                         'src' => $this->router
->generate('rapsyspack_captcha', ['hash' => $hash, 'equation' => $short, 'height' => $height, 'width' => $width, '_format' => $this->config
['captcha']['format']]), 
 102          * Return the facebook image 
 104          * Generate simple image in jpeg format or load it from cache 
 106          * @TODO: move to a svg merging system ? 
 108          * @param string $path The request path info 
 109          * @param array $texts The image texts 
 110          * @param int $updated The updated timestamp 
 111          * @param ?string $source The image source 
 112          * @param ?int $height The height 
 113          * @param ?int $width The width 
 114          * @return array The image array 
 116         public function getFacebook(string $path, array $texts, int $updated, ?string $source = null, ?int $height = null, ?int $width = null): array { 
 118                 if ($source === null && $this->config
['facebook']['source'] === null) { 
 119                         //Return empty image data 
 121                 //Without local source 
 122                 } elseif ($source === null) { 
 124                         $source = $this->config
['facebook']['source']; 
 128                 if ($height === null) { 
 129                         //Set height from config 
 130                         $height = $this->config
['facebook']['height']; 
 134                 if ($width === null) { 
 135                         //Set width from config 
 136                         $width = $this->config
['facebook']['width']; 
 140                 $facebook = $this->config
['cache'].'/'.$this->config
['prefixes']['facebook'].$path.'.jpeg'; 
 142                 //Without existing path 
 143                 if (!is_dir($dir = dirname($facebook))) { 
 144                         //Create filesystem object 
 145                         $filesystem = new Filesystem(); 
 149                                 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) 
 150                                 $filesystem->mkdir($dir, 0775); 
 151                         } catch (IOExceptionInterface 
$e) { 
 153                                 throw new \
Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e); 
 158                 if (is_file($facebook) && ($mtime = stat($facebook)['mtime']) && $mtime >= $updated) { 
 159                         #XXX: we used to drop texts with $data['canonical'] === true !!! 
 162                         $short = $this->slugger
->short($path); 
 165                         $hash = $this->slugger
->serialize([$short, $height, $width]); 
 169                                 'og:image' => $this->router
->generate('rapsyspack_facebook', ['hash' => $hash, 'path' => $short, 'height' => $height, 'width' => $width, 'u' => $mtime, '_format' => $this->config
['facebook']['format']], UrlGeneratorInterface
::ABSOLUTE_URL
), 
 170                                 'og:image:alt' => str_replace("\n", ' ', implode(' - ', array_keys($texts))), 
 171                                 'og:image:height' => $height, 
 172                                 'og:image:width' => $width 
 177                 $cache = $this->config
['cache'].'/'.$this->config
['prefixes']['facebook'].$path.'.png'; 
 180                 if (!is_dir($dir = dirname($cache))) { 
 181                         //Create filesystem object 
 182                         $filesystem = new Filesystem(); 
 186                                 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) 
 187                                 $filesystem->mkdir($dir, 0775); 
 188                         } catch (IOExceptionInterface 
$e) { 
 190                                 throw new \
Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e); 
 194                 //Create image object 
 195                 $image = new \
Imagick(); 
 197                 //Without cache image 
 198                 if (!is_file($cache) || stat($cache)['mtime'] < stat($source)['mtime']) { 
 199                         //Check target directory 
 200                         if (!is_dir($dir = dirname($cache))) { 
 201                                 //Create filesystem object 
 202                                 $filesystem = new Filesystem(); 
 206                                         //XXX: set as 0775, symfony umask (0022) will reduce rights (0755) 
 207                                         $filesystem->mkdir($dir, 0775); 
 208                                 } catch (IOExceptionInterface 
$e) { 
 210                                         throw new \
Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e); 
 215                         if (!is_file($source)) { 
 217                                 throw new \
Exception(sprintf('Source file "%s" do not exists', $source)); 
 220                         //Convert to absolute path 
 221                         $source = realpath($source); 
 224                         //XXX: Imagick::readImage only supports absolute path 
 225                         $image->readImage($source); 
 227                         //Crop using aspect ratio 
 228                         //XXX: for better result upload image directly in aspect ratio :) 
 229                         $image->cropThumbnailImage($width, $height); 
 231                         //Strip image exif data and properties 
 232                         $image->stripImage(); 
 235                         if (!$image->writeImage($cache)) { 
 237                                 throw new \
Exception(sprintf('Unable to write image "%s"', $cache)); 
 242                         $image->readImage($cache); 
 246                 $draw = new \
ImagickDraw(); 
 248                 //Set stroke antialias 
 249                 $draw->setStrokeAntialias(true); 
 252                 $draw->setTextAntialias(true); 
 256                         'left' => \Imagick
::ALIGN_LEFT
, 
 257                         'center' => \Imagick
::ALIGN_CENTER
, 
 258                         'right' => \Imagick
::ALIGN_RIGHT
 
 265                 $count = count($texts); 
 267                 //Draw each text stroke 
 268                 foreach($texts as $text => $data) { 
 270                         $draw->setFont($this->config
['fonts'][$data['font']??$this->config
['facebook']['font']]); 
 273                         $draw->setFontSize($data['size']??$this->config
['facebook']['size']); 
 276                         $draw->setStrokeWidth($data['thickness']??$this->config
['facebook']['thickness']); 
 279                         $draw->setTextAlignment($align = ($aligns[$data['align']??$this->config
['facebook']['align']])); 
 282                         $metrics = $image->queryFontMetrics($draw, $text); 
 285                         if (empty($data['y'])) { 
 286                                 //Position verticaly each text evenly 
 287                                 $texts[$text]['y'] = $data['y'] = (($height + 
100) / (count($texts) + 
1) * $i) - 50; 
 291                         if (empty($data['x'])) { 
 292                                 if ($align == \Imagick
::ALIGN_CENTER
) { 
 293                                         $texts[$text]['x'] = $data['x'] = $width/2; 
 294                                 } elseif ($align == \Imagick
::ALIGN_LEFT
) { 
 295                                         $texts[$text]['x'] = $data['x'] = 50; 
 296                                 } elseif ($align == \Imagick
::ALIGN_RIGHT
) { 
 297                                         $texts[$text]['x'] = $data['x'] = $width - 50; 
 302                         //XXX: add ascender part then center it back by half of textHeight 
 303                         //TODO: maybe add a boundingbox ??? 
 304                         $texts[$text]['y'] = $data['y'] +
= $metrics['ascender'] - $metrics['textHeight']/2; 
 307                         $draw->setStrokeColor(new \
ImagickPixel($data['border']??$this->config
['facebook']['border'])); 
 310                         $draw->setFillColor(new \
ImagickPixel($data['fill']??$this->config
['facebook']['fill'])); 
 313                         $draw->annotation($data['x'], $data['y'], $text); 
 319                 //Create stroke object 
 320                 $stroke = new \
Imagick(); 
 323                 $stroke->newImage($width, $height, new \
ImagickPixel('transparent')); 
 326                 $stroke->drawImage($draw); 
 329                 //XXX: blur the stroke canvas only 
 330                 $stroke->blurImage(5,3); 
 333                 //XXX: see https://www.php.net/manual/en/image.evaluateimage.php 
 334                 $stroke->evaluateImage(\Imagick
::EVALUATE_DIVIDE
, 1.5, \Imagick
::CHANNEL_ALPHA
); 
 337                 $image->compositeImage($stroke, \Imagick
::COMPOSITE_OVER
, 0, 0); 
 349                 $draw->setTextAntialias(true); 
 352                 foreach($texts as $text => $data) { 
 354                         $draw->setFont($this->config
['fonts'][$data['font']??$this->config
['facebook']['font']]); 
 357                         $draw->setFontSize($data['size']??$this->config
['facebook']['size']); 
 360                         $draw->setTextAlignment($aligns[$data['align']??$this->config
['facebook']['align']]); 
 363                         $draw->setFillColor(new \
ImagickPixel($data['fill']??$this->config
['facebook']['fill'])); 
 366                         $draw->annotation($data['x'], $data['y'], $text); 
 368                         //With canonical text 
 369                         if (!empty($data['canonical'])) { 
 370                                 //Prevent canonical to finish in alt 
 371                                 unset($texts[$text]); 
 376                 $image->drawImage($draw); 
 378                 //Strip image exif data and properties 
 379                 $image->stripImage(); 
 382                 $image->setImageFormat('jpeg'); 
 384                 //Set progressive jpeg 
 385                 $image->setInterlaceScheme(\Imagick
::INTERLACE_PLANE
); 
 388                 if (!$image->writeImage($facebook)) { 
 390                         throw new \
Exception(sprintf('Unable to write image "%s"', $facebook)); 
 394                 $short = $this->slugger
->short($path); 
 397                 $hash = $this->slugger
->serialize([$short, $height, $width]); 
 401                         'og:image' => $this->router
->generate('rapsyspack_facebook', ['hash' => $hash, 'path' => $short, 'height' => $height, 'width' => $width, 'u' => stat($facebook)['mtime'], '_format' => $this->config
['facebook']['format']], UrlGeneratorInterface
::ABSOLUTE_URL
), 
 402                         'og:image:alt' => str_replace("\n", ' ', implode(' - ', array_keys($texts))), 
 403                         'og:image:height' => $height, 
 404                         'og:image:width' => $width 
 411          * @param float $latitude The latitude 
 412          * @param float $longitude The longitude 
 413          * @param ?int $height The height 
 414          * @param ?int $width The width 
 415          * @param ?int $zoom The zoom 
 416          * @return array The map data 
 418         public function getMap(float $latitude, float $longitude, ?int $height = null, ?int $width = null, ?int $zoom = null): array { 
 420                 if ($height === null) { 
 421                         //Set height from config 
 422                         $height = $this->config
['map']['height']; 
 426                 if ($width === null) { 
 427                         //Set width from config 
 428                         $width = $this->config
['map']['width']; 
 432                 if ($zoom === null) { 
 433                         //Set zoom from config 
 434                         $zoom = $this->config
['map']['zoom']; 
 438                 $hash = $this->slugger
->hash([$height, $width, $zoom, $latitude, $longitude]); 
 442                         'latitude' => $latitude, 
 443                         'longitude' => $longitude, 
 445                         'src' => $this->router
->generate('rapsyspack_map', ['hash' => $hash, 'height' => $height, 'width' => $width, 'zoom' => $zoom, 'latitude' => $latitude, 'longitude' => $longitude, '_format' => $this->config
['map']['format']]), 
 454          * @param array $coordinates The coordinates array 
 455          * @param ?int $height The height 
 456          * @param ?int $width The width 
 457          * @param ?int $zoom The zoom 
 458          * @return array The multi map data 
 460         public function getMulti(array $coordinates, ?int $height = null, ?int $width = null, ?int $zoom = null): array { 
 461                 //Without coordinates 
 462                 if ($coordinates === []) { 
 464                         throw new \
Exception('Missing coordinates'); 
 468                 if ($height === null) { 
 469                         //Set height from config 
 470                         $height = $this->config
['multi']['height']; 
 474                 if ($width === null) { 
 475                         //Set width from config 
 476                         $width = $this->config
['multi']['width']; 
 480                 if ($zoom === null) { 
 481                         //Set zoom from config 
 482                         $zoom = $this->config
['multi']['zoom']; 
 485                 //Initialize latitudes and longitudes arrays 
 486                 $latitudes = $longitudes = []; 
 489                 $coordinate = implode( 
 492                                 function ($v) use (&$latitudes, &$longitudes) { 
 493                                         //Get latitude and longitude 
 494                                         list($latitude, $longitude) = $v; 
 497                                         $latitudes[] = $latitude; 
 500                                         $longitudes[] = $longitude; 
 503                                         return $latitude.','.$longitude; 
 510                 $latitude = round((min($latitudes)+
max($latitudes))/2, 6); 
 513                 $longitude = round((min($longitudes)+
max($longitudes))/2, 6); 
 516                 $zoom = $this->getZoom($latitude, $longitude, $coordinates, $height, $width, $zoom); 
 519                 $hash = $this->slugger
->hash([$height, $width, $zoom, $coordinate]); 
 523                         'coordinate' => $coordinate, 
 525                         'src' => $this->router
->generate('rapsyspack_multi', ['hash' => $hash, 'height' => $height, 'width' => $width, 'zoom' => $zoom, 'coordinate' => $coordinate, '_format' => $this->config
['multi']['format']]), 
 534          * Compute a zoom to have all coordinates on multi map 
 535          * Multi map visible only from -($width / 2) until ($width / 2) and from -($height / 2) until ($height / 2) 
 537          * @TODO Wether we need to take in consideration circle radius in coordinates comparisons, likely +/-(radius / $this->config['multi']['tz']) 
 539          * @param float $latitude The latitude 
 540          * @param float $longitude The longitude 
 541          * @param array $coordinates The coordinates array 
 542          * @param int $height The height 
 543          * @param int $width The width 
 544          * @param int $zoom The zoom 
 545          * @return int The zoom 
 547         public function getZoom(float $latitude, float $longitude, array $coordinates, int $height, int $width, int $zoom): int { 
 548                 //Iterate on each zoom 
 549                 for ($i = $zoom; $i >= 1; $i--) { 
 551                         $centerX = $this->longitudeToX($longitude, $i); 
 552                         $centerY = $this->latitudeToY($latitude, $i); 
 555                         $startX = floor($centerX - $width / 2 / $this->config
['multi']['tz']); 
 556                         $startY = floor($centerY - $height / 2 / $this->config
['multi']['tz']); 
 559                         $endX = ceil($centerX + 
$width / 2 / $this->config
['multi']['tz']); 
 560                         $endY = ceil($centerY + 
$height / 2 / $this->config
['multi']['tz']); 
 562                         //Iterate on each coordinates 
 563                         foreach($coordinates as $k => $coordinate) { 
 565                                 list($clatitude, $clongitude) = $coordinate; 
 568                                 $destX = $this->longitudeToX($clongitude, $i); 
 571                                 if ($startX >= $destX || $endX <= $destX) { 
 577                                 $destY = $this->latitudeToY($clatitude, $i); 
 580                                 if ($startY >= $destY || $endY <= $destY) { 
 597          * @param string $path The path 
 598          * @param ?int $height The height 
 599          * @param ?int $width The width 
 600          * @param ?string $format The format 
 601          * @return array The thumb data 
 603         public function getThumb(string $path, ?int $height = null, ?int $width = null, ?string $format = null): array { 
 605                 if ($format === null || !in_array($format, $this->config
['formats'])) { 
 606                         //Set format from config 
 607                         $format = $this->config
['thumb']['format']; 
 611                 if ($height === null) { 
 612                         //Set height from config 
 613                         $height = $this->config
['thumb']['height']; 
 617                 if ($width === null) { 
 618                         //Set width from config 
 619                         $width = $this->config
['thumb']['width']; 
 623                 $short = $this->slugger
->short($path); 
 626                 $hash = $this->slugger
->serialize([$short, $height, $width]); 
 628                 #TODO: compute thumb from file type ? 
 629                 #TODO: see if setting format there is smart ? we do not yet know if we want a image or movie thumb ? 
 630                 #TODO: do we add to route '_format' => $this->config['thumb']['format'] 
 634                         'src' => $this->router
->generate('rapsyspack_thumb', ['hash' => $hash, 'path' => $short, 'height' => $height, 'width' => $width, '_format' => $format]), 
 641          * Convert longitude to tile x number 
 643          * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5 
 645          * @param float $longitude The longitude 
 646          * @param int $zoom The zoom 
 648          * @return float The tile x 
 650         public function longitudeToX(float $longitude, int $zoom): float { 
 651                 return (($longitude + 
180) / 360) * pow(2, $zoom); 
 655          * Convert latitude to tile y number 
 657          * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5 
 659          * @param $latitude The latitude 
 660          * @param $zoom The zoom 
 662          * @return float The tile y 
 664         public function latitudeToY(float $latitude, int $zoom): float { 
 665                 return (1 - log(tan(deg2rad($latitude)) + 
1 / cos(deg2rad($latitude))) / pi()) / 2 * pow(2, $zoom); 
 669          * Convert tile x to longitude 
 671          * @param float $x The tile x 
 672          * @param int $zoom The zoom 
 674          * @return float The longitude 
 676         public function xToLongitude(float $x, int $zoom): float { 
 677                 return $x / pow(2, $zoom) * 360.0 - 180.0; 
 681          * Convert tile y to latitude 
 683          * @param float $y The tile y 
 684          * @param int $zoom The zoom 
 686          * @return float The latitude 
 688         public function yToLatitude(float $y, int $zoom): float { 
 689                 return rad2deg(atan(sinh(pi() * (1 - 2 * $y / pow(2, $zoom))))); 
 693          * Convert decimal latitude to sexagesimal 
 695          * @param float $latitude The decimal latitude 
 697          * @return string The sexagesimal longitude 
 699         public function latitudeToSexagesimal(float $latitude): string { 
 701                 //TODO: see if round or intval is better suited to fix the Deprecated: Implicit conversion from float to int loses precision 
 702                 $degree = round($latitude) % 
60; 
 705                 $minute = round(($latitude - $degree) * 60) % 
60; 
 708                 $second = round(($latitude - $degree - $minute / 60) * 3600) % 
3600; 
 710                 //Return sexagesimal longitude 
 711                 return $degree.'°'.$minute.'\''.$second.'"'.($latitude >= 0 ? 'N' : 'S'); 
 715          * Convert decimal longitude to sexagesimal 
 717          * @param float $longitude The decimal longitude 
 719          * @return string The sexagesimal longitude 
 721         public function longitudeToSexagesimal(float $longitude): string { 
 723                 //TODO: see if round or intval is better suited to fix the Deprecated: Implicit conversion from float to int loses precision 
 724                 $degree = round($longitude) % 
60; 
 727                 $minute = round(($longitude - $degree) * 60) % 
60; 
 730                 $second = round(($longitude - $degree - $minute / 60) * 3600) % 
3600; 
 732                 //Return sexagesimal longitude 
 733                 return $degree.'°'.$minute.'\''.$second.'"'.($longitude >= 0 ? 'E' : 'W'); 
 739          * @param int $updated The updated timestamp 
 740          * @param string $prefix The prefix 
 741          * @param string $path The path 
 742          * @return array The thumb clear success 
 744         public function remove(int $updated, string $prefix, string $path): bool { 
 745                 die('TODO: see how to make it work'); 
 747                 //Without valid prefix 
 748                 if (!isset($this->config
['prefixes'][$prefix])) { 
 750                         throw new \
Exception(sprintf('Invalid prefix "%s"', $prefix)); 
 754                 $hash = array_reverse(str_split(strval($updated))); 
 757                 $dir = $this->config
['public'].'/'.$this->config
['prefixes'][$prefix].'/'.$hash[0].'/'.$hash[1].'/'.$hash[2].'/'.$this->slugger
->short($path); 
 767                         //Iterate on each file 
 768                         foreach(array_merge($removes, array_diff(scandir($dir), ['..', '.'])) as $file) { 
 770                                 if (is_file($dir.'/'.$file)) { 
 772                                         $removes[] = $dir.'/'.$file; 
 777                 //Create filesystem object 
 778                 $filesystem = new Filesystem(); 
 782                         $filesystem->remove($removes); 
 783                 } catch (IOExceptionInterface 
$e) { 
 785                         throw new \
Exception(sprintf('Unable to delete thumb directory "%s"', $dir), 0, $e);