X-Git-Url: https://git.rapsys.eu/packbundle/blobdiff_plain/f94eaaa325da4edb18e4f1bbdc49173cdeb882b7..79be0fb28e90272e87fe910d09a808211ab7804a:/Util/ImageUtil.php

diff --git a/Util/ImageUtil.php b/Util/ImageUtil.php
index 03b4616..44afe28 100644
--- a/Util/ImageUtil.php
+++ b/Util/ImageUtil.php
@@ -11,226 +11,743 @@
 
 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]);
 
-	/**
-	 * The SluggerUtil instance
-	 */
-	protected SluggerUtil $slugger;
+		//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 captcha background
+	 * 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
 	 */
-	public string $captchaBackground;
+	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'];
+		}
 
-	/**
-	 * The captcha fill
-	 */
-	public string $captchaFill;
+		//Without height
+		if ($height === null) {
+			//Set height from config
+			$height = $this->config['facebook']['height'];
+		}
 
-	/**
-	 * The captcha font size
-	 */
-	public int $captchaFontSize;
+		//Without width
+		if ($width === null) {
+			//Set width from config
+			$width = $this->config['facebook']['width'];
+		}
 
-	/**
-	 * The captcha stroke
-	 */
-	public string $captchaStroke;
+		//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);
+			}
+		}
 
-	/**
-	 * The captcha stroke width
-	 */
-	public int $captchaStrokeWidth;
+		//With path file
+		if (is_file($facebook) && ($mtime = stat($facebook)['mtime']) && $mtime >= $updated) {
+			#XXX: we used to drop texts with $data['canonical'] === true !!!
 
-	/**
-	 * 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;
+			//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' => $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
+			];
+		}
+
+		//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);
+			}
+		}
+
+		//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);
+				}
+			}
+
+			//Without source
+			if (!is_file($source)) {
+				//Throw error
+				throw new \Exception(sprintf('Source file "%s" do not exists', $source));
+			}
+
+			//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 text alignment
+			$draw->setTextAlignment($align = ($aligns[$data['align']??$this->config['facebook']['align']]));
+
+			//Get font metrics
+			$metrics = $image->queryFontMetrics($draw, $text);
+
+			//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);
+
+		//Clear stroke
+		$stroke->clear();
+
+		//Destroy stroke
+		unset($stroke);
+
+		//Clear draw
+		$draw->clear();
+
+		//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);
 
-		//Set captcha background
-		$this->captchaBackground = $captchaBackground;
+			//With canonical text
+			if (!empty($data['canonical'])) {
+				//Prevent canonical to finish in alt
+				unset($texts[$text]);
+			}
+		}
 
-		//Set captcha fill
-		$this->captchaFill = $captchaFill;
+		//Draw on image
+		$image->drawImage($draw);
 
-		//Set captcha font size
-		$this->captchaFontSize = $captchaFontSize;
+		//Strip image exif data and properties
+		$image->stripImage();
 
-		//Set captcha stroke
-		$this->captchaStroke = $captchaStroke;
+		//Set image format
+		$image->setImageFormat('jpeg');
 
-		//Set captcha stroke width
-		$this->captchaStrokeWidth = $captchaStrokeWidth;
+		//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 path
-		$this->path = $path.'/'.$prefix;
+		//Set short path
+		$short = $this->slugger->short($path);
 
-		//Set router
-		$this->router = $router;
+		//Set hash
+		$hash = $this->slugger->serialize([$short, $height, $width]);
 
-		//Set slugger
-		$this->slugger = $slugger;
+		//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 = [];