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 * @return array The thumb data
602 public function getThumb(string $path, ?int $height = null, ?int $width = null): array {
604 if ($height === null) {
605 //Set height from config
606 $height = $this->config
['thumb']['height'];
610 if ($width === null) {
611 //Set width from config
612 $width = $this->config
['thumb']['width'];
616 $short = $this->slugger
->short($path);
619 $hash = $this->slugger
->serialize([$short, $height, $width]);
621 #TODO: compute thumb from file type ?
622 #TODO: see if setting format there is smart ? we do not yet know if we want a image or movie thumb ?
623 #TODO: do we add to route '_format' => $this->config['thumb']['format']
627 'src' => $this->router
->generate('rapsyspack_thumb', ['hash' => $hash, 'path' => $short, 'height' => $height, 'width' => $width]),
634 * Convert longitude to tile x number
636 * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
638 * @param float $longitude The longitude
639 * @param int $zoom The zoom
641 * @return float The tile x
643 public function longitudeToX(float $longitude, int $zoom): float {
644 return (($longitude +
180) / 360) * pow(2, $zoom);
648 * Convert latitude to tile y number
650 * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
652 * @param $latitude The latitude
653 * @param $zoom The zoom
655 * @return float The tile y
657 public function latitudeToY(float $latitude, int $zoom): float {
658 return (1 - log(tan(deg2rad($latitude)) +
1 / cos(deg2rad($latitude))) / pi()) / 2 * pow(2, $zoom);
662 * Convert tile x to longitude
664 * @param float $x The tile x
665 * @param int $zoom The zoom
667 * @return float The longitude
669 public function xToLongitude(float $x, int $zoom): float {
670 return $x / pow(2, $zoom) * 360.0 - 180.0;
674 * Convert tile y to latitude
676 * @param float $y The tile y
677 * @param int $zoom The zoom
679 * @return float The latitude
681 public function yToLatitude(float $y, int $zoom): float {
682 return rad2deg(atan(sinh(pi() * (1 - 2 * $y / pow(2, $zoom)))));
686 * Convert decimal latitude to sexagesimal
688 * @param float $latitude The decimal latitude
690 * @return string The sexagesimal longitude
692 public function latitudeToSexagesimal(float $latitude): string {
694 //TODO: see if round or intval is better suited to fix the Deprecated: Implicit conversion from float to int loses precision
695 $degree = round($latitude) %
60;
698 $minute = round(($latitude - $degree) * 60) %
60;
701 $second = round(($latitude - $degree - $minute / 60) * 3600) %
3600;
703 //Return sexagesimal longitude
704 return $degree.'°'.$minute.'\''.$second.'"'.($latitude >= 0 ? 'N' : 'S');
708 * Convert decimal longitude to sexagesimal
710 * @param float $longitude The decimal longitude
712 * @return string The sexagesimal longitude
714 public function longitudeToSexagesimal(float $longitude): string {
716 //TODO: see if round or intval is better suited to fix the Deprecated: Implicit conversion from float to int loses precision
717 $degree = round($longitude) %
60;
720 $minute = round(($longitude - $degree) * 60) %
60;
723 $second = round(($longitude - $degree - $minute / 60) * 3600) %
3600;
725 //Return sexagesimal longitude
726 return $degree.'°'.$minute.'\''.$second.'"'.($longitude >= 0 ? 'E' : 'W');
732 * @param int $updated The updated timestamp
733 * @param string $prefix The prefix
734 * @param string $path The path
735 * @return array The thumb clear success
737 public function remove(int $updated, string $prefix, string $path): bool {
738 die('TODO: see how to make it work');
740 //Without valid prefix
741 if (!isset($this->config
['prefixes'][$prefix])) {
743 throw new \
Exception(sprintf('Invalid prefix "%s"', $prefix));
747 $hash = array_reverse(str_split(strval($updated)));
750 $dir = $this->config
['public'].'/'.$this->config
['prefixes'][$prefix].'/'.$hash[0].'/'.$hash[1].'/'.$hash[2].'/'.$this->slugger
->short($path);
760 //Iterate on each file
761 foreach(array_merge($removes, array_diff(scandir($dir), ['..', '.'])) as $file) {
763 if (is_file($dir.'/'.$file)) {
765 $removes[] = $dir.'/'.$file;
770 //Create filesystem object
771 $filesystem = new Filesystem();
775 $filesystem->remove($removes);
776 } catch (IOExceptionInterface
$e) {
778 throw new \
Exception(sprintf('Unable to delete thumb directory "%s"', $dir), 0, $e);