X-Git-Url: https://git.rapsys.eu/packbundle/blobdiff_plain/232d0fe60cb6990fd231a2645233e3ef8c00cbea..7958c95fb124a29b4970ef8ec72f78ee4f5a71ea:/Util/ImageUtil.php?ds=inline diff --git a/Util/ImageUtil.php b/Util/ImageUtil.php index de1db0a..44afe28 100644 --- a/Util/ImageUtil.php +++ b/Util/ImageUtil.php @@ -11,201 +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 + ]; + } /** - * Creates a new image util + * Return the facebook image * - * @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 + * 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 */ - function __construct(RouterInterface $router, SluggerUtil $slugger, string $cache = '../var/cache', string $path = './bundles/rapsyspack', string $prefix = 'image', $captchaBackground = self::captchaBackground, $captchaFill = self::captchaFill, $captchaFontSize = self::captchaFontSize, $captchaStroke = self::captchaStroke, $captchaStrokeWidth = self::captchaStrokeWidth) { - //Set cache - $this->cache = $cache.'/'.$prefix; + 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']; + } + + //Without height + if ($height === null) { + //Set height from config + $height = $this->config['facebook']['height']; + } + + //Without width + if ($width === null) { + //Set width from config + $width = $this->config['facebook']['width']; + } + + //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); + } + } - //set captcha background - $this->captchaBackground = $captchaBackground; + //With path file + if (is_file($facebook) && ($mtime = stat($facebook)['mtime']) && $mtime >= $updated) { + #XXX: we used to drop texts with $data['canonical'] === true !!! - //set captcha fill - $this->captchaFill = $captchaFill; + //Set short path + $short = $this->slugger->short($path); - //set captcha font size - $this->captchaFontSize = $captchaFontSize; + //Set hash + $hash = $this->slugger->serialize([$short, $height, $width]); - //set captcha stroke - $this->captchaStroke = $captchaStroke; + //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); - //set captcha stroke width - $this->captchaStrokeWidth = $captchaStrokeWidth; + //Clear draw + $draw->clear(); - //Set path - $this->path = $path.'/'.$prefix; + //Set text antialias + $draw->setTextAntialias(true); - //Set router - $this->router = $router; + //Draw each text + foreach($texts as $text => $data) { + //Set font + $draw->setFont($this->config['fonts'][$data['font']??$this->config['facebook']['font']]); - //Set slugger - $this->slugger = $slugger; + //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 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 = [];