]> Raphaël G. Git Repositories - packbundle/blobdiff - Util/ImageUtil.php
Import contact form
[packbundle] / Util / ImageUtil.php
index c43c96d8d5998ccf845e1b8c30c8ec10635f8c4d..44afe28bb7234221c685061b8c4c54d44d287024 100644 (file)
 
 namespace Rapsys\PackBundle\Util;
 
+use Psr\Container\ContainerInterface;
+
+use Rapsys\PackBundle\RapsysPackBundle;
+
 use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
 use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
 use Symfony\Component\Routing\RouterInterface;
 
 /**
- * Helps manage map
+ * Manages image
  */
 class ImageUtil {
        /**
-        * The captcha width
+        * Alias string
         */
-       const captchaWidth = 192;
+       protected string $alias;
 
        /**
-        * The captcha height
+        * Config array
         */
-       const captchaHeight = 52;
+       protected array $config;
 
        /**
-        * The captcha background color
+        * Creates a new image util
+        *
+        * @param ContainerInterface $container The container instance
+        * @param RouterInterface $router The RouterInterface instance
+        * @param SluggerUtil $slugger The SluggerUtil instance
         */
-       const captchaBackground = 'white';
+       public function __construct(protected ContainerInterface $container, protected RouterInterface $router, protected SluggerUtil $slugger) {
+               //Retrieve config
+               $this->config = $container->getParameter($this->alias = RapsysPackBundle::getAlias());
+       }
 
        /**
-        * The captcha fill color
+        * Get captcha data
+        *
+        * @param ?int $height The height
+        * @param ?int $width The width
+        * @return array The captcha data
         */
-       const captchaFill = '#cff';
+       public function getCaptcha(?int $height = null, ?int $width = null): array {
+               //Without height
+               if ($height === null) {
+                       //Set height from config
+                       $height = $this->config['captcha']['height'];
+               }
 
-       /**
-        * The captcha font size
-        */
-       const captchaFontSize = 45;
+               //Without width
+               if ($width === null) {
+                       //Set width from config
+                       $width = $this->config['captcha']['width'];
+               }
 
-       /**
-        * The captcha stroke color
-        */
-       const captchaStroke = '#00c3f9';
+               //Get random
+               $random = rand(0, 999);
 
-       /**
-        * The captcha stroke width
-        */
-       const captchaStrokeWidth = 2;
+               //Set a
+               $a = $random % 10;
 
-       /**
-        * The thumb width
-        */
-       const thumbWidth = 640;
+               //Set b
+               $b = $random / 10 % 10;
 
-       /**
-        * The thumb height
-        */
-       const thumbHeight = 640;
+               //Set c
+               $c = $random / 100 % 10;
 
-       /**
-        * The cache path
-        */
-       protected string $cache;
+               //Set equation
+               $equation = $a.' * '.$b.' + '.$c;
 
-       /**
-        * The path
-        */
-       protected string $path;
+               //Short path
+               $short = $this->slugger->short($equation);
 
-       /**
-        * The RouterInterface instance
-        */
-       protected RouterInterface $router;
+               //Set hash
+               $hash = $this->slugger->serialize([$short, $height, $width]);
+
+               //Return array
+               return [
+                       'token' => $this->slugger->hash(strval($a * $b + $c)),
+                       'value' => strval($a * $b + $c),
+                       'equation' => str_replace([' ', '*', '+'], ['-', 'mul', 'add'], $equation),
+                       'src' => $this->router->generate('rapsyspack_captcha', ['hash' => $hash, 'equation' => $short, 'height' => $height, 'width' => $width, '_format' => $this->config['captcha']['format']]),
+                       'width' => $width,
+                       'height' => $height
+               ];
+       }
 
        /**
-        * The SluggerUtil instance
+        * Return the facebook image
+        *
+        * Generate simple image in jpeg format or load it from cache
+        *
+        * @TODO: move to a svg merging system ?
+        *
+        * @param string $path The request path info
+        * @param array $texts The image texts
+        * @param int $updated The updated timestamp
+        * @param ?string $source The image source
+        * @param ?int $height The height
+        * @param ?int $width The width
+        * @return array The image array
         */
-       protected SluggerUtil $slugger;
+       public function getFacebook(string $path, array $texts, int $updated, ?string $source = null, ?int $height = null, ?int $width = null): array {
+               //Without source
+               if ($source === null && $this->config['facebook']['source'] === null) {
+                       //Return empty image data
+                       return [];
+               //Without local source
+               } elseif ($source === null) {
+                       //Set local source
+                       $source = $this->config['facebook']['source'];
+               }
 
-       //Captcha background
-       protected string $captchaBackground;
+               //Without height
+               if ($height === null) {
+                       //Set height from config
+                       $height = $this->config['facebook']['height'];
+               }
 
-       //Captcha fill
-       protected string $captchaFill;
+               //Without width
+               if ($width === null) {
+                       //Set width from config
+                       $width = $this->config['facebook']['width'];
+               }
 
-       //Captcha font size
-       protected int $captchaFontSize;
+               //Set path file
+               $facebook = $this->config['cache'].'/'.$this->config['prefixes']['facebook'].$path.'.jpeg';
+
+               //Without existing path
+               if (!is_dir($dir = dirname($facebook))) {
+                       //Create filesystem object
+                       $filesystem = new Filesystem();
+
+                       try {
+                               //Create path
+                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                               $filesystem->mkdir($dir, 0775);
+                       } catch (IOExceptionInterface $e) {
+                               //Throw error
+                               throw new \Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e);
+                       }
+               }
 
-       //Captcha stroke
-       protected string $captchaStroke;
+               //With path file
+               if (is_file($facebook) && ($mtime = stat($facebook)['mtime']) && $mtime >= $updated) {
+                       #XXX: we used to drop texts with $data['canonical'] === true !!!
 
-       //Captcha stroke width
-       protected int $captchaStrokeWidth;
+                       //Set short path
+                       $short = $this->slugger->short($path);
 
-       /**
-        * The captcha background
-        */
-       public string $captchaBackground;
+                       //Set hash
+                       $hash = $this->slugger->serialize([$short, $height, $width]);
 
-       /**
-        * The captcha fill
-        */
-       public string $captchaFill;
+                       //Return image data
+                       return [
+                               '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),
+                               'og:image:alt' => str_replace("\n", ' ', implode(' - ', array_keys($texts))),
+                               'og:image:height' => $height,
+                               'og:image:width' => $width
+                       ];
+               }
 
-       /**
-        * The captcha font size
-        */
-       public int $captchaFontSize;
+               //Set cache path
+               $cache = $this->config['cache'].'/'.$this->config['prefixes']['facebook'].$path.'.png';
+
+               //Without cache path
+               if (!is_dir($dir = dirname($cache))) {
+                       //Create filesystem object
+                       $filesystem = new Filesystem();
+
+                       try {
+                               //Create path
+                               //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                               $filesystem->mkdir($dir, 0775);
+                       } catch (IOExceptionInterface $e) {
+                               //Throw error
+                               throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
+                       }
+               }
 
-       /**
-        * The captcha stroke
-        */
-       public string $captchaStroke;
+               //Create image object
+               $image = new \Imagick();
+
+               //Without cache image
+               if (!is_file($cache) || stat($cache)['mtime'] < stat($source)['mtime']) {
+                       //Check target directory
+                       if (!is_dir($dir = dirname($cache))) {
+                               //Create filesystem object
+                               $filesystem = new Filesystem();
+
+                               try {
+                                       //Create dir
+                                       //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+                                       $filesystem->mkdir($dir, 0775);
+                               } catch (IOExceptionInterface $e) {
+                                       //Throw error
+                                       throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
+                               }
+                       }
 
-       /**
-        * The captcha stroke width
-        */
-       public int $captchaStrokeWidth;
+                       //Without source
+                       if (!is_file($source)) {
+                               //Throw error
+                               throw new \Exception(sprintf('Source file "%s" do not exists', $source));
+                       }
 
-       /**
-        * Creates a new image util
-        *
-        * @param RouterInterface $router The RouterInterface instance
-        * @param SluggerUtil $slugger The SluggerUtil instance
-        * @param string $cache The cache directory
-        * @param string $path The public path
-        * @param string $prefix The prefix
-        */
-       function __construct(RouterInterface $router, SluggerUtil $slugger, string $cache = '../var/cache', string $path = './bundles/rapsyspack', string $prefix = 'image', string $captchaBackground = self::captchaBackground, string $captchaFill = self::captchaFill, int $captchaFontSize = self::captchaFontSize, string $captchaStroke = self::captchaStroke, int $captchaStrokeWidth = self::captchaStrokeWidth) {
-               //Set cache
-               $this->cache = $cache.'/'.$prefix;
+                       //Convert to absolute path
+                       $source = realpath($source);
+
+                       //Read image
+                       //XXX: Imagick::readImage only supports absolute path
+                       $image->readImage($source);
+
+                       //Crop using aspect ratio
+                       //XXX: for better result upload image directly in aspect ratio :)
+                       $image->cropThumbnailImage($width, $height);
+
+                       //Strip image exif data and properties
+                       $image->stripImage();
+
+                       //Save cache image
+                       if (!$image->writeImage($cache)) {
+                               //Throw error
+                               throw new \Exception(sprintf('Unable to write image "%s"', $cache));
+                       }
+               //With cache
+               } else {
+                       //Read image
+                       $image->readImage($cache);
+               }
+
+               //Create draw
+               $draw = new \ImagickDraw();
+
+               //Set stroke antialias
+               $draw->setStrokeAntialias(true);
+
+               //Set text antialias
+               $draw->setTextAntialias(true);
+
+               //Set align aliases
+               $aligns = [
+                       'left' => \Imagick::ALIGN_LEFT,
+                       'center' => \Imagick::ALIGN_CENTER,
+                       'right' => \Imagick::ALIGN_RIGHT
+               ];
+
+               //Init counter
+               $i = 1;
+
+               //Set text count
+               $count = count($texts);
+
+               //Draw each text stroke
+               foreach($texts as $text => $data) {
+                       //Set font
+                       $draw->setFont($this->config['fonts'][$data['font']??$this->config['facebook']['font']]);
+
+                       //Set font size
+                       $draw->setFontSize($data['size']??$this->config['facebook']['size']);
+
+                       //Set stroke width
+                       $draw->setStrokeWidth($data['thickness']??$this->config['facebook']['thickness']);
 
-               //Set captcha background
-               $this->captchaBackground = $captchaBackground;
+                       //Set text alignment
+                       $draw->setTextAlignment($align = ($aligns[$data['align']??$this->config['facebook']['align']]));
 
-               //Set captcha fill
-               $this->captchaFill = $captchaFill;
+                       //Get font metrics
+                       $metrics = $image->queryFontMetrics($draw, $text);
 
-               //Set captcha font size
-               $this->captchaFontSize = $captchaFontSize;
+                       //Without y
+                       if (empty($data['y'])) {
+                               //Position verticaly each text evenly
+                               $texts[$text]['y'] = $data['y'] = (($height + 100) / (count($texts) + 1) * $i) - 50;
+                       }
+
+                       //Without x
+                       if (empty($data['x'])) {
+                               if ($align == \Imagick::ALIGN_CENTER) {
+                                       $texts[$text]['x'] = $data['x'] = $width/2;
+                               } elseif ($align == \Imagick::ALIGN_LEFT) {
+                                       $texts[$text]['x'] = $data['x'] = 50;
+                               } elseif ($align == \Imagick::ALIGN_RIGHT) {
+                                       $texts[$text]['x'] = $data['x'] = $width - 50;
+                               }
+                       }
+
+                       //Center verticaly
+                       //XXX: add ascender part then center it back by half of textHeight
+                       //TODO: maybe add a boundingbox ???
+                       $texts[$text]['y'] = $data['y'] += $metrics['ascender'] - $metrics['textHeight']/2;
+
+                       //Set stroke color
+                       $draw->setStrokeColor(new \ImagickPixel($data['border']??$this->config['facebook']['border']));
+
+                       //Set fill color
+                       $draw->setFillColor(new \ImagickPixel($data['fill']??$this->config['facebook']['fill']));
+
+                       //Add annotation
+                       $draw->annotation($data['x'], $data['y'], $text);
+
+                       //Increase counter
+                       $i++;
+               }
+
+               //Create stroke object
+               $stroke = new \Imagick();
+
+               //Add new image
+               $stroke->newImage($width, $height, new \ImagickPixel('transparent'));
+
+               //Draw on image
+               $stroke->drawImage($draw);
+
+               //Blur image
+               //XXX: blur the stroke canvas only
+               $stroke->blurImage(5,3);
+
+               //Set opacity to 0.5
+               //XXX: see https://www.php.net/manual/en/image.evaluateimage.php
+               $stroke->evaluateImage(\Imagick::EVALUATE_DIVIDE, 1.5, \Imagick::CHANNEL_ALPHA);
+
+               //Compose image
+               $image->compositeImage($stroke, \Imagick::COMPOSITE_OVER, 0, 0);
 
-               //Set captcha stroke
-               $this->captchaStroke = $captchaStroke;
+               //Clear stroke
+               $stroke->clear();
 
-               //Set captcha stroke width
-               $this->captchaStrokeWidth = $captchaStrokeWidth;
+               //Destroy stroke
+               unset($stroke);
 
-               //Set path
-               $this->path = $path.'/'.$prefix;
+               //Clear draw
+               $draw->clear();
 
-               //Set router
-               $this->router = $router;
+               //Set text antialias
+               $draw->setTextAntialias(true);
+
+               //Draw each text
+               foreach($texts as $text => $data) {
+                       //Set font
+                       $draw->setFont($this->config['fonts'][$data['font']??$this->config['facebook']['font']]);
+
+                       //Set font size
+                       $draw->setFontSize($data['size']??$this->config['facebook']['size']);
+
+                       //Set text alignment
+                       $draw->setTextAlignment($aligns[$data['align']??$this->config['facebook']['align']]);
+
+                       //Set fill color
+                       $draw->setFillColor(new \ImagickPixel($data['fill']??$this->config['facebook']['fill']));
+
+                       //Add annotation
+                       $draw->annotation($data['x'], $data['y'], $text);
+
+                       //With canonical text
+                       if (!empty($data['canonical'])) {
+                               //Prevent canonical to finish in alt
+                               unset($texts[$text]);
+                       }
+               }
+
+               //Draw on image
+               $image->drawImage($draw);
+
+               //Strip image exif data and properties
+               $image->stripImage();
+
+               //Set image format
+               $image->setImageFormat('jpeg');
+
+               //Set progressive jpeg
+               $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
+
+               //Save image
+               if (!$image->writeImage($facebook)) {
+                       //Throw error
+                       throw new \Exception(sprintf('Unable to write image "%s"', $facebook));
+               }
 
-               //Set slugger
-               $this->slugger = $slugger;
+               //Set short path
+               $short = $this->slugger->short($path);
+
+               //Set hash
+               $hash = $this->slugger->serialize([$short, $height, $width]);
+
+               //Return image data
+               return [
+                       '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),
+                       'og:image:alt' => str_replace("\n", ' ', implode(' - ', array_keys($texts))),
+                       'og:image:height' => $height,
+                       'og:image:width' => $width
+               ];
        }
 
        /**
-        * Get captcha data
+        * Get map data
         *
-        * @param int $updated The updated timestamp
-        * @param int $width The width
-        * @param int $height The height
-        * @return array The captcha data
+        * @param float $latitude The latitude
+        * @param float $longitude The longitude
+        * @param ?int $height The height
+        * @param ?int $width The width
+        * @param ?int $zoom The zoom
+        * @return array The map data
         */
-       public function getCaptcha(int $updated, int $width = self::captchaWidth, int $height = self::captchaHeight): array {
-               //Set a
-               $a = rand(0, 9);
+       public function getMap(float $latitude, float $longitude, ?int $height = null, ?int $width = null, ?int $zoom = null): array {
+               //Without height
+               if ($height === null) {
+                       //Set height from config
+                       $height = $this->config['map']['height'];
+               }
 
-               //Set b
-               $b = rand(0, 5);
+               //Without width
+               if ($width === null) {
+                       //Set width from config
+                       $width = $this->config['map']['width'];
+               }
 
-               //Set c
-               $c = rand(0, 9);
+               //Without zoom
+               if ($zoom === null) {
+                       //Set zoom from config
+                       $zoom = $this->config['map']['zoom'];
+               }
 
-               //Set equation
-               $equation = $a.' * '.$b.' + '.$c;
+               //Set hash
+               $hash = $this->slugger->hash([$height, $width, $zoom, $latitude, $longitude]);
 
-               //Short path
-               $short = $this->slugger->short($equation);
+               //Return array
+               return [
+                       'latitude' => $latitude,
+                       'longitude' => $longitude,
+                       'height' => $height,
+                       'src' => $this->router->generate('rapsyspack_map', ['hash' => $hash, 'height' => $height, 'width' => $width, 'zoom' => $zoom, 'latitude' => $latitude, 'longitude' => $longitude, '_format' => $this->config['map']['format']]),
+                       'width' => $width,
+                       'zoom' => $zoom
+               ];
+       }
+
+       /**
+        * Get multi map data
+        *
+        * @param array $coordinates The coordinates array
+        * @param ?int $height The height
+        * @param ?int $width The width
+        * @param ?int $zoom The zoom
+        * @return array The multi map data
+        */
+       public function getMulti(array $coordinates, ?int $height = null, ?int $width = null, ?int $zoom = null): array {
+               //Without coordinates
+               if ($coordinates === []) {
+                       //Throw error
+                       throw new \Exception('Missing coordinates');
+               }
+
+               //Without height
+               if ($height === null) {
+                       //Set height from config
+                       $height = $this->config['multi']['height'];
+               }
+
+               //Without width
+               if ($width === null) {
+                       //Set width from config
+                       $width = $this->config['multi']['width'];
+               }
+
+               //Without zoom
+               if ($zoom === null) {
+                       //Set zoom from config
+                       $zoom = $this->config['multi']['zoom'];
+               }
+
+               //Initialize latitudes and longitudes arrays
+               $latitudes = $longitudes = [];
+
+               //Set coordinate
+               $coordinate = implode(
+                       '-',
+                       array_map(
+                               function ($v) use (&$latitudes, &$longitudes) {
+                                       //Get latitude and longitude
+                                       list($latitude, $longitude) = $v;
+
+                                       //Append latitude
+                                       $latitudes[] = $latitude;
+
+                                       //Append longitude
+                                       $longitudes[] = $longitude;
+
+                                       //Append coordinate
+                                       return $latitude.','.$longitude;
+                               },
+                               $coordinates
+                       )
+               );
+
+               //Set latitude
+               $latitude = round((min($latitudes)+max($latitudes))/2, 6);
+
+               //Set longitude
+               $longitude = round((min($longitudes)+max($longitudes))/2, 6);
+
+               //Set zoom
+               $zoom = $this->getZoom($latitude, $longitude, $coordinates, $height, $width, $zoom);
 
                //Set hash
-               $hash = $this->slugger->serialize([$updated, $short, $width, $height]);
+               $hash = $this->slugger->hash([$height, $width, $zoom, $coordinate]);
 
                //Return array
                return [
-                       'token' => $this->slugger->hash(strval($a * $b + $c)),
-                       'value' => strval($a * $b + $c),
-                       'equation' => str_replace([' ', '*', '+'], ['-', 'mul', 'add'], $equation),
-                       'src' => $this->router->generate('rapsys_pack_captcha', ['hash' => $hash, 'updated' => $updated, 'equation' => $short, 'width' => $width, 'height' => $height]),
+                       'coordinate' => $coordinate,
+                       'height' => $height,
+                       'src' => $this->router->generate('rapsyspack_multi', ['hash' => $hash, 'height' => $height, 'width' => $width, 'zoom' => $zoom, 'coordinate' => $coordinate, '_format' => $this->config['multi']['format']]),
                        'width' => $width,
-                       'height' => $height
+                       'zoom' => $zoom
                ];
        }
 
+       /**
+        * Get multi zoom
+        *
+        * Compute a zoom to have all coordinates on multi map
+        * Multi map visible only from -($width / 2) until ($width / 2) and from -($height / 2) until ($height / 2)
+        *
+        * @TODO Wether we need to take in consideration circle radius in coordinates comparisons, likely +/-(radius / $this->config['multi']['tz'])
+        *
+        * @param float $latitude The latitude
+        * @param float $longitude The longitude
+        * @param array $coordinates The coordinates array
+        * @param int $height The height
+        * @param int $width The width
+        * @param int $zoom The zoom
+        * @return int The zoom
+        */
+       public function getZoom(float $latitude, float $longitude, array $coordinates, int $height, int $width, int $zoom): int {
+               //Iterate on each zoom
+               for ($i = $zoom; $i >= 1; $i--) {
+                       //Get tile xy
+                       $centerX = $this->longitudeToX($longitude, $i);
+                       $centerY = $this->latitudeToY($latitude, $i);
+
+                       //Calculate start xy
+                       $startX = floor($centerX - $width / 2 / $this->config['multi']['tz']);
+                       $startY = floor($centerY - $height / 2 / $this->config['multi']['tz']);
+
+                       //Calculate end xy
+                       $endX = ceil($centerX + $width / 2 / $this->config['multi']['tz']);
+                       $endY = ceil($centerY + $height / 2 / $this->config['multi']['tz']);
+
+                       //Iterate on each coordinates
+                       foreach($coordinates as $k => $coordinate) {
+                               //Get coordinates
+                               list($clatitude, $clongitude) = $coordinate;
+
+                               //Set dest x
+                               $destX = $this->longitudeToX($clongitude, $i);
+
+                               //With outside point
+                               if ($startX >= $destX || $endX <= $destX) {
+                                       //Skip zoom
+                                       continue(2);
+                               }
+
+                               //Set dest y
+                               $destY = $this->latitudeToY($clatitude, $i);
+
+                               //With outside point
+                               if ($startY >= $destY || $endY <= $destY) {
+                                       //Skip zoom
+                                       continue(2);
+                               }
+                       }
+
+                       //Found zoom
+                       break;
+               }
+
+               //Return zoom
+               return $i;
+       }
+
        /**
         * Get thumb data
         *
-        * @param string $caption The caption
-        * @param int $updated The updated timestamp
         * @param string $path The path
-        * @param int $width The width
-        * @param int $height The height
+        * @param ?int $height The height
+        * @param ?int $width The width
         * @return array The thumb data
         */
-       public function getThumb(string $caption, int $updated, string $path, int $width = self::thumbWidth, int $height = self::thumbHeight): array {
-               //Get image width and height
-               list($imageWidth, $imageHeight) = getimagesize($path);
+       public function getThumb(string $path, ?int $height = null, ?int $width = null): array {
+               //Without height
+               if ($height === null) {
+                       //Set height from config
+                       $height = $this->config['thumb']['height'];
+               }
+
+               //Without width
+               if ($width === null) {
+                       //Set width from config
+                       $width = $this->config['thumb']['width'];
+               }
 
                //Short path
                $short = $this->slugger->short($path);
 
-               //Set link hash
-               $link = $this->slugger->serialize([$updated, $short, $imageWidth, $imageHeight]);
+               //Set hash
+               $hash = $this->slugger->serialize([$short, $height, $width]);
 
-               //Set src hash
-               $src = $this->slugger->serialize([$updated, $short, $width, $height]);
+               #TODO: compute thumb from file type ?
+               #TODO: see if setting format there is smart ? we do not yet know if we want a image or movie thumb ?
+               #TODO: do we add to route '_format' => $this->config['thumb']['format']
 
                //Return array
                return [
-                       'caption' => $caption,
-                       'link' => $this->router->generate('rapsys_pack_thumb', ['hash' => $link, 'updated' => $updated, 'path' => $short, 'width' => $imageWidth, 'height' => $imageHeight]),
-                       'src' => $this->router->generate('rapsys_pack_thumb', ['hash' => $src, 'updated' => $updated, 'path' => $short, 'width' => $width, 'height' => $height]),
+                       'src' => $this->router->generate('rapsyspack_thumb', ['hash' => $hash, 'path' => $short, 'height' => $height, 'width' => $width]),
                        'width' => $width,
                        'height' => $height
                ];
        }
 
+       /**
+        * Convert longitude to tile x number
+        *
+        * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
+        *
+        * @param float $longitude The longitude
+        * @param int $zoom The zoom
+        *
+        * @return float The tile x
+        */
+       public function longitudeToX(float $longitude, int $zoom): float {
+               return (($longitude + 180) / 360) * pow(2, $zoom);
+       }
+
+       /**
+        * Convert latitude to tile y number
+        *
+        * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
+        *
+        * @param $latitude The latitude
+        * @param $zoom The zoom
+        *
+        * @return float The tile y
+        */
+       public function latitudeToY(float $latitude, int $zoom): float {
+               return (1 - log(tan(deg2rad($latitude)) + 1 / cos(deg2rad($latitude))) / pi()) / 2 * pow(2, $zoom);
+       }
+
+       /**
+        * Convert tile x to longitude
+        *
+        * @param float $x The tile x
+        * @param int $zoom The zoom
+        *
+        * @return float The longitude
+        */
+       public function xToLongitude(float $x, int $zoom): float {
+               return $x / pow(2, $zoom) * 360.0 - 180.0;
+       }
+
+       /**
+        * Convert tile y to latitude
+        *
+        * @param float $y The tile y
+        * @param int $zoom The zoom
+        *
+        * @return float The latitude
+        */
+       public function yToLatitude(float $y, int $zoom): float {
+               return rad2deg(atan(sinh(pi() * (1 - 2 * $y / pow(2, $zoom)))));
+       }
+
+       /**
+        * Convert decimal latitude to sexagesimal
+        *
+        * @param float $latitude The decimal latitude
+        *
+        * @return string The sexagesimal longitude
+        */
+       public function latitudeToSexagesimal(float $latitude): string {
+               //Set degree
+               //TODO: see if round or intval is better suited to fix the Deprecated: Implicit conversion from float to int loses precision
+               $degree = round($latitude) % 60;
+
+               //Set minute
+               $minute = round(($latitude - $degree) * 60) % 60;
+
+               //Set second
+               $second = round(($latitude - $degree - $minute / 60) * 3600) % 3600;
+
+               //Return sexagesimal longitude
+               return $degree.'°'.$minute.'\''.$second.'"'.($latitude >= 0 ? 'N' : 'S');
+       }
+
+       /**
+        * Convert decimal longitude to sexagesimal
+        *
+        * @param float $longitude The decimal longitude
+        *
+        * @return string The sexagesimal longitude
+        */
+       public function longitudeToSexagesimal(float $longitude): string {
+               //Set degree
+               //TODO: see if round or intval is better suited to fix the Deprecated: Implicit conversion from float to int loses precision
+               $degree = round($longitude) % 60;
+
+               //Set minute
+               $minute = round(($longitude - $degree) * 60) % 60;
+
+               //Set second
+               $second = round(($longitude - $degree - $minute / 60) * 3600) % 3600;
+
+               //Return sexagesimal longitude
+               return $degree.'°'.$minute.'\''.$second.'"'.($longitude >= 0 ? 'E' : 'W');
+       }
+
        /**
         * Remove image
         *
         * @param int $updated The updated timestamp
+        * @param string $prefix The prefix
         * @param string $path The path
         * @return array The thumb clear success
         */
-       public function remove(int $updated, string $path): bool {
+       public function remove(int $updated, string $prefix, string $path): bool {
+               die('TODO: see how to make it work');
+
+               //Without valid prefix
+               if (!isset($this->config['prefixes'][$prefix])) {
+                       //Throw error
+                       throw new \Exception(sprintf('Invalid prefix "%s"', $prefix));
+               }
+
                //Set hash tree
                $hash = array_reverse(str_split(strval($updated)));
 
                //Set dir
-               $dir = $this->path.'/'.$hash[0].'/'.$hash[1].'/'.$hash[2].'/'.$updated.'/'.$this->slugger->short($path);
+               $dir = $this->config['public'].'/'.$this->config['prefixes'][$prefix].'/'.$hash[0].'/'.$hash[1].'/'.$hash[2].'/'.$this->slugger->short($path);
 
                //Set removes
                $removes = [];