]> Raphaël G. Git Repositories - packbundle/blob - Util/ImageUtil.php
Version 0.5.4
[packbundle] / Util / ImageUtil.php
1 <?php declare(strict_types=1);
2
3 /*
4 * This file is part of the Rapsys PackBundle package.
5 *
6 * (c) Raphaël Gertz <symfony@rapsys.eu>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace Rapsys\PackBundle\Util;
13
14 use Psr\Container\ContainerInterface;
15
16 use Rapsys\PackBundle\RapsysPackBundle;
17
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;
22
23 /**
24 * Manages image
25 */
26 class ImageUtil {
27 /**
28 * Alias string
29 */
30 protected string $alias;
31
32 /**
33 * Config array
34 */
35 protected array $config;
36
37 /**
38 * Creates a new image util
39 *
40 * @param ContainerInterface $container The container instance
41 * @param RouterInterface $router The RouterInterface instance
42 * @param SluggerUtil $slugger The SluggerUtil instance
43 */
44 public function __construct(protected ContainerInterface $container, protected RouterInterface $router, protected SluggerUtil $slugger) {
45 //Retrieve config
46 $this->config = $container->getParameter($this->alias = RapsysPackBundle::getAlias());
47 }
48
49 /**
50 * Get captcha data
51 *
52 * @param ?int $height The height
53 * @param ?int $width The width
54 * @return array The captcha data
55 */
56 public function getCaptcha(?int $height = null, ?int $width = null): array {
57 //Without height
58 if ($height === null) {
59 //Set height from config
60 $height = $this->config['captcha']['height'];
61 }
62
63 //Without width
64 if ($width === null) {
65 //Set width from config
66 $width = $this->config['captcha']['width'];
67 }
68
69 //Get random
70 $random = rand(0, 999);
71
72 //Set a
73 $a = $random % 10;
74
75 //Set b
76 $b = $random / 10 % 10;
77
78 //Set c
79 $c = $random / 100 % 10;
80
81 //Set equation
82 $equation = $a.' * '.$b.' + '.$c;
83
84 //Short path
85 $short = $this->slugger->short($equation);
86
87 //Set hash
88 $hash = $this->slugger->serialize([$short, $height, $width]);
89
90 //Return array
91 return [
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']]),
96 'width' => $width,
97 'height' => $height
98 ];
99 }
100
101 /**
102 * Return the facebook image
103 *
104 * Generate simple image in jpeg format or load it from cache
105 *
106 * @TODO: move to a svg merging system ?
107 *
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
115 */
116 public function getFacebook(string $path, array $texts, int $updated, ?string $source = null, ?int $height = null, ?int $width = null): array {
117 //Without source
118 if ($source === null && $this->config['facebook']['source'] === null) {
119 //Return empty image data
120 return [];
121 //Without local source
122 } elseif ($source === null) {
123 //Set local source
124 $source = $this->config['facebook']['source'];
125 }
126
127 //Without height
128 if ($height === null) {
129 //Set height from config
130 $height = $this->config['facebook']['height'];
131 }
132
133 //Without width
134 if ($width === null) {
135 //Set width from config
136 $width = $this->config['facebook']['width'];
137 }
138
139 //Set path file
140 $facebook = $this->config['cache'].'/'.$this->config['prefixes']['facebook'].$path.'.jpeg';
141
142 //Without existing path
143 if (!is_dir($dir = dirname($facebook))) {
144 //Create filesystem object
145 $filesystem = new Filesystem();
146
147 try {
148 //Create path
149 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
150 $filesystem->mkdir($dir, 0775);
151 } catch (IOExceptionInterface $e) {
152 //Throw error
153 throw new \Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e);
154 }
155 }
156
157 //With path file
158 if (is_file($facebook) && ($mtime = stat($facebook)['mtime']) && $mtime >= $updated) {
159 #XXX: we used to drop texts with $data['canonical'] === true !!!
160
161 //Set short path
162 $short = $this->slugger->short($path);
163
164 //Set hash
165 $hash = $this->slugger->serialize([$short, $height, $width]);
166
167 //Return image data
168 return [
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
173 ];
174 }
175
176 //Set cache path
177 $cache = $this->config['cache'].'/'.$this->config['prefixes']['facebook'].$path.'.png';
178
179 //Without cache path
180 if (!is_dir($dir = dirname($cache))) {
181 //Create filesystem object
182 $filesystem = new Filesystem();
183
184 try {
185 //Create path
186 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
187 $filesystem->mkdir($dir, 0775);
188 } catch (IOExceptionInterface $e) {
189 //Throw error
190 throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
191 }
192 }
193
194 //Create image object
195 $image = new \Imagick();
196
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();
203
204 try {
205 //Create dir
206 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
207 $filesystem->mkdir($dir, 0775);
208 } catch (IOExceptionInterface $e) {
209 //Throw error
210 throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
211 }
212 }
213
214 //Without source
215 if (!is_file($source)) {
216 //Throw error
217 throw new \Exception(sprintf('Source file "%s" do not exists', $source));
218 }
219
220 //Convert to absolute path
221 $source = realpath($source);
222
223 //Read image
224 //XXX: Imagick::readImage only supports absolute path
225 $image->readImage($source);
226
227 //Crop using aspect ratio
228 //XXX: for better result upload image directly in aspect ratio :)
229 $image->cropThumbnailImage($width, $height);
230
231 //Strip image exif data and properties
232 $image->stripImage();
233
234 //Save cache image
235 if (!$image->writeImage($cache)) {
236 //Throw error
237 throw new \Exception(sprintf('Unable to write image "%s"', $cache));
238 }
239 //With cache
240 } else {
241 //Read image
242 $image->readImage($cache);
243 }
244
245 //Create draw
246 $draw = new \ImagickDraw();
247
248 //Set stroke antialias
249 $draw->setStrokeAntialias(true);
250
251 //Set text antialias
252 $draw->setTextAntialias(true);
253
254 //Set align aliases
255 $aligns = [
256 'left' => \Imagick::ALIGN_LEFT,
257 'center' => \Imagick::ALIGN_CENTER,
258 'right' => \Imagick::ALIGN_RIGHT
259 ];
260
261 //Init counter
262 $i = 1;
263
264 //Set text count
265 $count = count($texts);
266
267 //Draw each text stroke
268 foreach($texts as $text => $data) {
269 //Set font
270 $draw->setFont($this->config['fonts'][$data['font']??$this->config['facebook']['font']]);
271
272 //Set font size
273 $draw->setFontSize($data['size']??$this->config['facebook']['size']);
274
275 //Set stroke width
276 $draw->setStrokeWidth($data['thickness']??$this->config['facebook']['thickness']);
277
278 //Set text alignment
279 $draw->setTextAlignment($align = ($aligns[$data['align']??$this->config['facebook']['align']]));
280
281 //Get font metrics
282 $metrics = $image->queryFontMetrics($draw, $text);
283
284 //Without y
285 if (empty($data['y'])) {
286 //Position verticaly each text evenly
287 $texts[$text]['y'] = $data['y'] = (($height + 100) / (count($texts) + 1) * $i) - 50;
288 }
289
290 //Without x
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;
298 }
299 }
300
301 //Center verticaly
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;
305
306 //Set stroke color
307 $draw->setStrokeColor(new \ImagickPixel($data['border']??$this->config['facebook']['border']));
308
309 //Set fill color
310 $draw->setFillColor(new \ImagickPixel($data['fill']??$this->config['facebook']['fill']));
311
312 //Add annotation
313 $draw->annotation($data['x'], $data['y'], $text);
314
315 //Increase counter
316 $i++;
317 }
318
319 //Create stroke object
320 $stroke = new \Imagick();
321
322 //Add new image
323 $stroke->newImage($width, $height, new \ImagickPixel('transparent'));
324
325 //Draw on image
326 $stroke->drawImage($draw);
327
328 //Blur image
329 //XXX: blur the stroke canvas only
330 $stroke->blurImage(5,3);
331
332 //Set opacity to 0.5
333 //XXX: see https://www.php.net/manual/en/image.evaluateimage.php
334 $stroke->evaluateImage(\Imagick::EVALUATE_DIVIDE, 1.5, \Imagick::CHANNEL_ALPHA);
335
336 //Compose image
337 $image->compositeImage($stroke, \Imagick::COMPOSITE_OVER, 0, 0);
338
339 //Clear stroke
340 $stroke->clear();
341
342 //Destroy stroke
343 unset($stroke);
344
345 //Clear draw
346 $draw->clear();
347
348 //Set text antialias
349 $draw->setTextAntialias(true);
350
351 //Draw each text
352 foreach($texts as $text => $data) {
353 //Set font
354 $draw->setFont($this->config['fonts'][$data['font']??$this->config['facebook']['font']]);
355
356 //Set font size
357 $draw->setFontSize($data['size']??$this->config['facebook']['size']);
358
359 //Set text alignment
360 $draw->setTextAlignment($aligns[$data['align']??$this->config['facebook']['align']]);
361
362 //Set fill color
363 $draw->setFillColor(new \ImagickPixel($data['fill']??$this->config['facebook']['fill']));
364
365 //Add annotation
366 $draw->annotation($data['x'], $data['y'], $text);
367
368 //With canonical text
369 if (!empty($data['canonical'])) {
370 //Prevent canonical to finish in alt
371 unset($texts[$text]);
372 }
373 }
374
375 //Draw on image
376 $image->drawImage($draw);
377
378 //Strip image exif data and properties
379 $image->stripImage();
380
381 //Set image format
382 $image->setImageFormat('jpeg');
383
384 //Set progressive jpeg
385 $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
386
387 //Save image
388 if (!$image->writeImage($facebook)) {
389 //Throw error
390 throw new \Exception(sprintf('Unable to write image "%s"', $facebook));
391 }
392
393 //Set short path
394 $short = $this->slugger->short($path);
395
396 //Set hash
397 $hash = $this->slugger->serialize([$short, $height, $width]);
398
399 //Return image data
400 return [
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
405 ];
406 }
407
408 /**
409 * Get map data
410 *
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
417 */
418 public function getMap(float $latitude, float $longitude, ?int $height = null, ?int $width = null, ?int $zoom = null): array {
419 //Without height
420 if ($height === null) {
421 //Set height from config
422 $height = $this->config['map']['height'];
423 }
424
425 //Without width
426 if ($width === null) {
427 //Set width from config
428 $width = $this->config['map']['width'];
429 }
430
431 //Without zoom
432 if ($zoom === null) {
433 //Set zoom from config
434 $zoom = $this->config['map']['zoom'];
435 }
436
437 //Set hash
438 $hash = $this->slugger->hash([$height, $width, $zoom, $latitude, $longitude]);
439
440 //Return array
441 return [
442 'latitude' => $latitude,
443 'longitude' => $longitude,
444 'height' => $height,
445 'src' => $this->router->generate('rapsyspack_map', ['hash' => $hash, 'height' => $height, 'width' => $width, 'zoom' => $zoom, 'latitude' => $latitude, 'longitude' => $longitude, '_format' => $this->config['map']['format']]),
446 'width' => $width,
447 'zoom' => $zoom
448 ];
449 }
450
451 /**
452 * Get multi map data
453 *
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
459 */
460 public function getMulti(array $coordinates, ?int $height = null, ?int $width = null, ?int $zoom = null): array {
461 //Without coordinates
462 if ($coordinates === []) {
463 //Throw error
464 throw new \Exception('Missing coordinates');
465 }
466
467 //Without height
468 if ($height === null) {
469 //Set height from config
470 $height = $this->config['multi']['height'];
471 }
472
473 //Without width
474 if ($width === null) {
475 //Set width from config
476 $width = $this->config['multi']['width'];
477 }
478
479 //Without zoom
480 if ($zoom === null) {
481 //Set zoom from config
482 $zoom = $this->config['multi']['zoom'];
483 }
484
485 //Initialize latitudes and longitudes arrays
486 $latitudes = $longitudes = [];
487
488 //Set coordinate
489 $coordinate = implode(
490 '-',
491 array_map(
492 function ($v) use (&$latitudes, &$longitudes) {
493 //Get latitude and longitude
494 list($latitude, $longitude) = $v;
495
496 //Append latitude
497 $latitudes[] = $latitude;
498
499 //Append longitude
500 $longitudes[] = $longitude;
501
502 //Append coordinate
503 return $latitude.','.$longitude;
504 },
505 $coordinates
506 )
507 );
508
509 //Set latitude
510 $latitude = round((min($latitudes)+max($latitudes))/2, 6);
511
512 //Set longitude
513 $longitude = round((min($longitudes)+max($longitudes))/2, 6);
514
515 //Set zoom
516 $zoom = $this->getZoom($latitude, $longitude, $coordinates, $height, $width, $zoom);
517
518 //Set hash
519 $hash = $this->slugger->hash([$height, $width, $zoom, $coordinate]);
520
521 //Return array
522 return [
523 'coordinate' => $coordinate,
524 'height' => $height,
525 'src' => $this->router->generate('rapsyspack_multi', ['hash' => $hash, 'height' => $height, 'width' => $width, 'zoom' => $zoom, 'coordinate' => $coordinate, '_format' => $this->config['multi']['format']]),
526 'width' => $width,
527 'zoom' => $zoom
528 ];
529 }
530
531 /**
532 * Get multi zoom
533 *
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)
536 *
537 * @TODO Wether we need to take in consideration circle radius in coordinates comparisons, likely +/-(radius / $this->config['multi']['tz'])
538 *
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
546 */
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--) {
550 //Get tile xy
551 $centerX = $this->longitudeToX($longitude, $i);
552 $centerY = $this->latitudeToY($latitude, $i);
553
554 //Calculate start xy
555 $startX = floor($centerX - $width / 2 / $this->config['multi']['tz']);
556 $startY = floor($centerY - $height / 2 / $this->config['multi']['tz']);
557
558 //Calculate end xy
559 $endX = ceil($centerX + $width / 2 / $this->config['multi']['tz']);
560 $endY = ceil($centerY + $height / 2 / $this->config['multi']['tz']);
561
562 //Iterate on each coordinates
563 foreach($coordinates as $k => $coordinate) {
564 //Get coordinates
565 list($clatitude, $clongitude) = $coordinate;
566
567 //Set dest x
568 $destX = $this->longitudeToX($clongitude, $i);
569
570 //With outside point
571 if ($startX >= $destX || $endX <= $destX) {
572 //Skip zoom
573 continue(2);
574 }
575
576 //Set dest y
577 $destY = $this->latitudeToY($clatitude, $i);
578
579 //With outside point
580 if ($startY >= $destY || $endY <= $destY) {
581 //Skip zoom
582 continue(2);
583 }
584 }
585
586 //Found zoom
587 break;
588 }
589
590 //Return zoom
591 return $i;
592 }
593
594 /**
595 * Get thumb data
596 *
597 * @param string $path The path
598 * @param ?int $height The height
599 * @param ?int $width The width
600 * @return array The thumb data
601 */
602 public function getThumb(string $path, ?int $height = null, ?int $width = null): array {
603 //Without height
604 if ($height === null) {
605 //Set height from config
606 $height = $this->config['thumb']['height'];
607 }
608
609 //Without width
610 if ($width === null) {
611 //Set width from config
612 $width = $this->config['thumb']['width'];
613 }
614
615 //Short path
616 $short = $this->slugger->short($path);
617
618 //Set hash
619 $hash = $this->slugger->serialize([$short, $height, $width]);
620
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']
624
625 //Return array
626 return [
627 'src' => $this->router->generate('rapsyspack_thumb', ['hash' => $hash, 'path' => $short, 'height' => $height, 'width' => $width]),
628 'width' => $width,
629 'height' => $height
630 ];
631 }
632
633 /**
634 * Convert longitude to tile x number
635 *
636 * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
637 *
638 * @param float $longitude The longitude
639 * @param int $zoom The zoom
640 *
641 * @return float The tile x
642 */
643 public function longitudeToX(float $longitude, int $zoom): float {
644 return (($longitude + 180) / 360) * pow(2, $zoom);
645 }
646
647 /**
648 * Convert latitude to tile y number
649 *
650 * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Lon..2Flat._to_tile_numbers_5
651 *
652 * @param $latitude The latitude
653 * @param $zoom The zoom
654 *
655 * @return float The tile y
656 */
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);
659 }
660
661 /**
662 * Convert tile x to longitude
663 *
664 * @param float $x The tile x
665 * @param int $zoom The zoom
666 *
667 * @return float The longitude
668 */
669 public function xToLongitude(float $x, int $zoom): float {
670 return $x / pow(2, $zoom) * 360.0 - 180.0;
671 }
672
673 /**
674 * Convert tile y to latitude
675 *
676 * @param float $y The tile y
677 * @param int $zoom The zoom
678 *
679 * @return float The latitude
680 */
681 public function yToLatitude(float $y, int $zoom): float {
682 return rad2deg(atan(sinh(pi() * (1 - 2 * $y / pow(2, $zoom)))));
683 }
684
685 /**
686 * Convert decimal latitude to sexagesimal
687 *
688 * @param float $latitude The decimal latitude
689 *
690 * @return string The sexagesimal longitude
691 */
692 public function latitudeToSexagesimal(float $latitude): string {
693 //Set degree
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;
696
697 //Set minute
698 $minute = round(($latitude - $degree) * 60) % 60;
699
700 //Set second
701 $second = round(($latitude - $degree - $minute / 60) * 3600) % 3600;
702
703 //Return sexagesimal longitude
704 return $degree.'°'.$minute.'\''.$second.'"'.($latitude >= 0 ? 'N' : 'S');
705 }
706
707 /**
708 * Convert decimal longitude to sexagesimal
709 *
710 * @param float $longitude The decimal longitude
711 *
712 * @return string The sexagesimal longitude
713 */
714 public function longitudeToSexagesimal(float $longitude): string {
715 //Set degree
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;
718
719 //Set minute
720 $minute = round(($longitude - $degree) * 60) % 60;
721
722 //Set second
723 $second = round(($longitude - $degree - $minute / 60) * 3600) % 3600;
724
725 //Return sexagesimal longitude
726 return $degree.'°'.$minute.'\''.$second.'"'.($longitude >= 0 ? 'E' : 'W');
727 }
728
729 /**
730 * Remove image
731 *
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
736 */
737 public function remove(int $updated, string $prefix, string $path): bool {
738 die('TODO: see how to make it work');
739
740 //Without valid prefix
741 if (!isset($this->config['prefixes'][$prefix])) {
742 //Throw error
743 throw new \Exception(sprintf('Invalid prefix "%s"', $prefix));
744 }
745
746 //Set hash tree
747 $hash = array_reverse(str_split(strval($updated)));
748
749 //Set dir
750 $dir = $this->config['public'].'/'.$this->config['prefixes'][$prefix].'/'.$hash[0].'/'.$hash[1].'/'.$hash[2].'/'.$this->slugger->short($path);
751
752 //Set removes
753 $removes = [];
754
755 //With dir
756 if (is_dir($dir)) {
757 //Add tree to remove
758 $removes[] = $dir;
759
760 //Iterate on each file
761 foreach(array_merge($removes, array_diff(scandir($dir), ['..', '.'])) as $file) {
762 //With file
763 if (is_file($dir.'/'.$file)) {
764 //Add file to remove
765 $removes[] = $dir.'/'.$file;
766 }
767 }
768 }
769
770 //Create filesystem object
771 $filesystem = new Filesystem();
772
773 try {
774 //Remove list
775 $filesystem->remove($removes);
776 } catch (IOExceptionInterface $e) {
777 //Throw error
778 throw new \Exception(sprintf('Unable to delete thumb directory "%s"', $dir), 0, $e);
779 }
780
781 //Return success
782 return true;
783 }
784 }