+++ /dev/null
-<?php
-
-namespace Rapsys\PackBundle\Asset;
-
-use Symfony\Component\Asset\Context\ContextInterface;
-use Symfony\Component\Asset\Package;
-use Symfony\Component\Asset\VersionStrategy\VersionStrategyInterface;
-
-/**
- * (@inheritdoc)
- */
-class PathPackage extends Package {
- //The base path
- protected $basePath;
-
- /**
- * {@inheritdoc}
- */
- public function __construct(string $basePath, VersionStrategyInterface $versionStrategy, ContextInterface $context = null) {
- parent::__construct($versionStrategy, $context);
-
- if (!$basePath) {
- $this->basePath = '/';
- } else {
- if ('/' != $basePath[0]) {
- $basePath = '/'.$basePath;
- }
-
- $this->basePath = rtrim($basePath, '/').'/';
- }
- }
-
- /**
- * @todo Try retrive public dir from the member function BundleNameBundle::getPublicDir() return value ?
- * @xxx see https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure
- * {@inheritdoc}
- */
- public function getUrl($path) {
- //Match url starting with a bundle name
- if (preg_match('%^@([A-Z][a-zA-Z]*?)(?:Bundle/Resources/public)?/(.*)$%', $path, $matches)) {
- //Handle empty or without replacement pattern basePath
- if (empty($this->basePath) || strpos($this->basePath, '%s') === false) {
- //Set path from hardcoded format
- $path = '/bundles/'.strtolower($matches[1]).'/'.$matches[2];
- //Proceed with basePath pattern replacement
- } else {
- //Set path from basePath pattern
- //XXX: basePath has a trailing / added by constructor
- $path = sprintf($this->basePath, strtolower($matches[1])).$matches[2];
- }
- }
-
- //Return parent getUrl result
- return parent::getUrl($path);
- }
-}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle;
+
+use Rapsys\PackBundle\RapsysPackBundle;
+
+use Symfony\Component\Console\Command\Command as BaseCommand;
+use Symfony\Component\DependencyInjection\Container;
+
+/**
+ * {@inheritdoc}
+ */
+class Command extends BaseCommand {
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(protected ?string $name = null) {
+ //Fix name
+ $this->name = $this->name ?? static::getName();
+
+ //Call parent constructor
+ parent::__construct($this->name);
+
+ //With description
+ if (!empty($this->description)) {
+ //Set description
+ $this->setDescription($this->description);
+ }
+
+ //With help
+ if (!empty($this->help)) {
+ //Set help
+ $this->setHelp($this->help);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Return the command name
+ */
+ public function getName(): string {
+ //With namespace
+ if ($npos = strrpos(static::class, '\\')) {
+ //Set name pos
+ $npos++;
+ //Without namespace
+ } else {
+ $npos = 0;
+ }
+
+ //With trailing command
+ if (substr(static::class, -strlen('Command'), strlen('Command')) === 'Command') {
+ //Set bundle pos
+ $bpos = strlen(static::class) - $npos - strlen('Command');
+ //Without bundle
+ } else {
+ //Set bundle pos
+ $bpos = strlen(static::class) - $npos;
+ }
+
+ //Return command alias
+ return RapsysPackBundle::getAlias().':'.strtolower(substr(static::class, $npos, $bpos));
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Command;
+
+use Rapsys\PackBundle\Command;
+
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * {@inheritdoc}
+ *
+ * Shuffle printable character range
+ */
+class RangeCommand extends Command {
+ /**
+ * Set description
+ *
+ * Shown with bin/console list
+ */
+ protected string $description = 'Outputs a shuffled printable characters range';
+
+ /**
+ * Set help
+ *
+ * Shown with bin/console --help rapsyspack:range
+ */
+ protected string $help = 'This command outputs a shuffled printable characters range';
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(protected string $file = '.env.local', protected ?string $name = null) {
+ //Call parent constructor
+ parent::__construct($this->name);
+
+ //Add argument
+ $this->addArgument('file', InputArgument::OPTIONAL, 'Environment file', $this->file);
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Output a shuffled printable characters range
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ //Printable character range
+ $ranges = range(' ', '~');
+
+ //Range shuffled
+ $shuffles = [];
+
+ //Shuffle range array
+ do {
+ //Set start offset
+ $offset = rand(0, ($count = count($ranges)) - 1);
+ //Set length
+ $length = rand(1, $count - $offset < ($ceil = (int)ceil(($count+count($shuffles))/rand(5,10))) ? $count - $offset : rand(2, $ceil));
+ //Splice array
+ $slices = array_splice($ranges, $offset, $length);
+ //When reverse
+ if (rand(0, 1)) {
+ //Reverse sliced array
+ $slices = array_reverse($slices);
+ }
+ //Append sliced array
+ $shuffles = array_merge($shuffles, $slices);
+ } while (!empty($ranges));
+
+ //With writeable file
+ if (is_file($file = $input->getArgument('file')) && is_writeable($file)) {
+ //Get file content
+ if (($content = file_get_contents($file, false)) === false) {
+ //Display error
+ error_log(sprintf('Unable to get %s content', $file), 0);
+
+ //Return failure
+ return self::FAILURE;
+ }
+
+ //Set string
+ $string = 'RAPSYSPACK_RANGE="'.strtr(implode($shuffles), ['\\' => '\\\\', '"' => '\\"', '$' => '\\$']).'"';
+
+ //With match
+ if (preg_match('/^RAPSYSPACK_RANGE=.*$/m', $content, $matches, PREG_OFFSET_CAPTURE)) {
+ //Replace matches
+ $content = preg_replace('/^(RAPSYSPACK_RANGE=.*)$/m', '#$1'."\n".strtr($string, ['\\' => '\\\\', '\\$' => '\\\\$']), $content);
+ //Without match
+ } else {
+ $content .= "\n".$string;
+ }
+
+ //Write file content
+ if (file_put_contents($file, $content) === false) {
+ //Display error
+ error_log(sprintf('Unable to put %s content', $file), 0);
+
+ //Return failure
+ return self::FAILURE;
+ }
+
+ //Print content
+ echo $content;
+ //Without writeable file
+ } else {
+ //Print instruction
+ echo '# Set in '.$file."\n";
+
+ //Print rapsys pack range variable
+ echo 'RAPSYSPACK_RANGE=';
+
+ //Print shuffled range
+ var_export(implode($shuffles));
+ }
+
+ //Return success
+ return self::SUCCESS;
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Context;
+
+use Symfony\Component\Asset\Context\NullContext as BaseNullContext;
+
+/**
+ * {@inheritdoc}
+ */
+class NullContext extends BaseNullContext {
+ /**
+ * Returns the base url
+ *
+ * @return string The base url
+ */
+ public function getBaseUrl(): string {
+ return '';
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Context;
+
+use Symfony\Component\Asset\Context\RequestStackContext as BaseRequestStackContext;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * {@inheritdoc}
+ */
+class RequestStackContext extends BaseRequestStackContext {
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(protected RequestStack $requestStack, protected string $basePath = '', protected bool $secure = false) {
+ //Call parent constructor
+ parent::__construct($requestStack, $basePath, $secure);
+ }
+
+ /**
+ * Returns the base url
+ *
+ * @return string The base url
+ */
+ public function getBaseUrl(): string {
+ //Without request
+ if (!$request = $this->requestStack->getMainRequest()) {
+ //Return base path
+ return $this->basePath;
+ }
+
+ //Return base uri
+ return $request->getSchemeAndHttpHost().$request->getBaseUrl();
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Controller;
+
+use Rapsys\PackBundle\Util\ImageUtil;
+use Rapsys\PackBundle\Util\SluggerUtil;
+
+use Psr\Container\ContainerInterface;
+
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\HeaderUtils;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\Routing\RequestContext;
+use Symfony\Contracts\Service\ServiceSubscriberInterface;
+
+/**
+ * {@inheritdoc}
+ */
+class ImageController extends AbstractController implements ServiceSubscriberInterface {
+ /**
+ * Creates a new image controller
+ *
+ * @param ContainerInterface $container The ContainerInterface instance
+ * @param ImageUtil $image The MapUtil instance
+ * @param SluggerUtil $slugger The SluggerUtil instance
+ * @param string $cache The cache path
+ * @param string $path The public path
+ * @param string $prefix The prefix
+ */
+ function __construct(protected ContainerInterface $container, protected ImageUtil $image, protected SluggerUtil $slugger, protected string $cache = '../var/cache', protected string $path = './bundles/rapsyspack', protected string $prefix = 'image') {
+ }
+
+ /**
+ * Return captcha image
+ *
+ * @param Request $request The Request instance
+ * @param string $hash The hash
+ * @param int $updated The updated timestamp
+ * @param string $equation The shorted equation
+ * @param int $width The width
+ * @param int $height The height
+ * @return Response The rendered image
+ */
+ public function captcha(Request $request, string $hash, int $updated, string $equation, int $width, int $height): Response {
+ //Without matching hash
+ if ($hash !== $this->slugger->serialize([$updated, $equation, $width, $height])) {
+ //Throw new exception
+ throw new NotFoundHttpException(sprintf('Unable to match captcha hash: %s', $hash));
+ }
+
+ //Set hashed tree
+ $hashed = array_reverse(str_split(strval($updated)));
+
+ //Set captcha
+ $captcha = $this->path.'/'.$this->prefix.'/'.$hashed[0].'/'.$hashed[1].'/'.$hashed[2].'/'.$updated.'/'.$equation.'/'.$width.'x'.$height.'.jpeg';
+
+ //Unshort equation
+ $equation = $this->slugger->unshort($equation);
+
+ //Without captcha up to date file
+ if (!is_file($captcha) || !($mtime = stat($captcha)['mtime']) || $mtime < $updated) {
+ //Without existing captcha path
+ if (!is_dir($dir = dirname($captcha))) {
+ //Create filesystem object
+ $filesystem = new Filesystem();
+
+ try {
+ //Create path
+ //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+ //XXX: on CoW filesystems execute a chattr +C before filling
+ $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);
+ }
+ }
+
+ //Create image instance
+ $image = new \Imagick();
+
+ //Add imagick draw instance
+ //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916
+ $draw = new \ImagickDraw();
+
+ //Set text antialias
+ $draw->setTextAntialias(true);
+
+ //Set stroke antialias
+ $draw->setStrokeAntialias(true);
+
+ //Set text alignment
+ $draw->setTextAlignment(\Imagick::ALIGN_CENTER);
+
+ //Set gravity
+ $draw->setGravity(\Imagick::GRAVITY_CENTER);
+
+ //Set fill color
+ $draw->setFillColor($this->image->getFill());
+
+ //Set stroke color
+ $draw->setStrokeColor($this->image->getStroke());
+
+ //Set font size
+ $draw->setFontSize($this->image->getFontSize() / 1.5);
+
+ //Set stroke width
+ $draw->setStrokeWidth($this->image->getStrokeWidth() / 3);
+
+ //Set rotation
+ $draw->rotate($rotate = (rand(25, 75)*(rand(0,1)?-.1:.1)));
+
+ //Get font metrics
+ $metrics2 = $image->queryFontMetrics($draw, strval('stop spam'));
+
+ //Add annotation
+ $draw->annotation($width / 2 - ceil(rand(intval(-$metrics2['textWidth']), intval($metrics2['textWidth'])) / 2) - abs($rotate), ceil($metrics2['textHeight'] + $metrics2['descender'] + $metrics2['ascender']) / 2 - $this->image->getStrokeWidth() - $rotate, strval('stop spam'));
+
+ //Set rotation
+ $draw->rotate(-$rotate);
+
+ //Set font size
+ $draw->setFontSize($this->image->getFontSize());
+
+ //Set stroke width
+ $draw->setStrokeWidth($this->image->getStrokeWidth());
+
+ //Set rotation
+ $draw->rotate($rotate = (rand(25, 50)*(rand(0,1)?-.1:.1)));
+
+ //Get font metrics
+ $metrics = $image->queryFontMetrics($draw, strval($equation));
+
+ //Add annotation
+ $draw->annotation($width / 2, ceil($metrics['textHeight'] + $metrics['descender'] + $metrics['ascender']) / 2 - $this->image->getStrokeWidth(), strval($equation));
+
+ //Set rotation
+ $draw->rotate(-$rotate);
+
+ //Add new image
+ #$image->newImage(intval(ceil($metrics['textWidth'])), intval(ceil($metrics['textHeight'] + $metrics['descender'])), new \ImagickPixel($this->image->getBackground()), 'jpeg');
+ $image->newImage($width, $height, new \ImagickPixel($this->image->getBackground()), 'jpeg');
+
+ //Draw on image
+ $image->drawImage($draw);
+
+ //Strip image exif data and properties
+ $image->stripImage();
+
+ //Set compression quality
+ $image->setImageCompressionQuality(70);
+
+ //Save captcha
+ if (!$image->writeImage($captcha)) {
+ //Throw error
+ throw new \Exception(sprintf('Unable to write image "%s"', $captcha));
+ }
+
+ //Set mtime
+ $mtime = stat($captcha)['mtime'];
+ }
+
+ //Read captcha from cache
+ $response = new BinaryFileResponse($captcha);
+
+ //Set file name
+ $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'captcha-stop-spam-'.str_replace([' ', '*', '+'], ['-', 'mul', 'add'], $equation).'-'.$width.'x'.$height.'.jpeg');
+
+ //Set etag
+ $response->setEtag(md5($hash));
+
+ //Set last modified
+ $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime)));
+
+ //Set as public
+ $response->setPublic();
+
+ //Return 304 response if not modified
+ $response->isNotModified($request);
+
+ //Return response
+ return $response;
+ }
+
+ /**
+ * Return thumb image
+ *
+ * @param Request $request The Request instance
+ * @param string $hash The hash
+ * @param int $updated The updated timestamp
+ * @param string $path The image path
+ * @param int $width The width
+ * @param int $height The height
+ * @return Response The rendered image
+ */
+ public function thumb(Request $request, string $hash, int $updated, string $path, int $width, int $height): Response {
+ //Without matching hash
+ if ($hash !== $this->slugger->serialize([$updated, $path, $width, $height])) {
+ //Throw new exception
+ throw new NotFoundHttpException(sprintf('Unable to match thumb hash: %s', $hash));
+ }
+
+ //Set hashed tree
+ $hashed = array_reverse(str_split(strval($updated)));
+
+ //Set thumb
+ $thumb = $this->path.'/'.$this->prefix.'/'.$hashed[0].'/'.$hashed[1].'/'.$hashed[2].'/'.$updated.'/'.$path.'/'.$width.'x'.$height.'.jpeg';
+
+ //Unshort path
+ $path = $this->slugger->unshort($path);
+
+ //Without thumb up to date file
+ if (!is_file($thumb) || !($mtime = stat($thumb)['mtime']) || $mtime < $updated) {
+ //Without existing thumb path
+ if (!is_dir($dir = dirname($thumb))) {
+ //Create filesystem object
+ $filesystem = new Filesystem();
+
+ try {
+ //Create path
+ //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+ //XXX: on CoW filesystems execute a chattr +C before filling
+ $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);
+ }
+ }
+
+ //Create image instance
+ $image = new \Imagick();
+
+ //Read image
+ $image->readImage(realpath($path));
+
+ //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();
+
+ //Set compression quality
+ //TODO: ajust that
+ $image->setImageCompressionQuality(70);
+
+ //Save thumb
+ if (!$image->writeImage($thumb)) {
+ //Throw error
+ throw new \Exception(sprintf('Unable to write image "%s"', $thumb));
+ }
+
+ //Set mtime
+ $mtime = stat($thumb)['mtime'];
+ }
+
+ //Read thumb from cache
+ $response = new BinaryFileResponse($thumb);
+
+ //Set file name
+ $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'thumb-'.str_replace('/', '_', $path).'-'.$width.'x'.$height.'.jpeg');
+
+ //Set etag
+ $response->setEtag(md5($hash));
+
+ //Set last modified
+ $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime)));
+
+ //Set as public
+ $response->setPublic();
+
+ //Return 304 response if not modified
+ $response->isNotModified($request);
+
+ //Return response
+ return $response;
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Controller;
+
+use Rapsys\PackBundle\Util\MapUtil;
+use Rapsys\PackBundle\Util\SluggerUtil;
+
+use Psr\Container\ContainerInterface;
+
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\HeaderUtils;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\Routing\RequestContext;
+use Symfony\Contracts\Service\ServiceSubscriberInterface;
+
+/**
+ * {@inheritdoc}
+ */
+class MapController extends AbstractController implements ServiceSubscriberInterface {
+ /**
+ * The stream context instance
+ */
+ protected mixed $ctx;
+
+ /**
+ * Creates a new osm controller
+ *
+ * @param ContainerInterface $container The ContainerInterface instance
+ * @param MapUtil $map The MapUtil instance
+ * @param SluggerUtil $slugger The SluggerUtil instance
+ * @param string $cache The cache path
+ * @param string $path The public path
+ * @param string $prefix The prefix
+ * @param string $url The tile server url
+ */
+ function __construct(protected ContainerInterface $container, protected MapUtil $map, protected SluggerUtil $slugger, protected string $cache = '../var/cache', protected string $path = './bundles/rapsyspack', protected string $prefix = 'map', protected string $url = MapUtil::osm) {
+ //Set ctx
+ $this->ctx = stream_context_create(
+ [
+ 'http' => [
+ #'header' => ['Referer: https://www.openstreetmap.org/'],
+ 'max_redirects' => $_ENV['RAPSYSPACK_REDIRECT'] ?? 20,
+ 'timeout' => $_ENV['RAPSYSPACK_TIMEOUT'] ?? (($timeout = ini_get('default_socket_timeout')) !== false && $timeout !== "" ? (float)$timeout : 60),
+ 'user_agent' => $_ENV['RAPSYSPACK_AGENT'] ?? (($agent = ini_get('user_agent')) !== false && $agent !== "" ? (string)$agent : RapsysPackBundle::getAlias().'/'.RapsysPackBundle::getVersion())
+ ]
+ ]
+ );
+ }
+
+ /**
+ * Return map image
+ *
+ * @param Request $request The Request instance
+ * @param string $hash The hash
+ * @param int $updated The updated timestamp
+ * @param float $latitude The latitude
+ * @param float $longitude The longitude
+ * @param int $zoom The zoom
+ * @param int $width The width
+ * @param int $height The height
+ * @return Response The rendered image
+ */
+ public function map(Request $request, string $hash, int $updated, float $latitude, float $longitude, int $zoom, int $width, int $height): Response {
+ //Without matching hash
+ if ($hash !== $this->slugger->hash([$updated, $latitude, $longitude, $zoom, $width, $height])) {
+ //Throw new exception
+ throw new NotFoundHttpException(sprintf('Unable to match map hash: %s', $hash));
+ }
+
+ //Set map
+ $map = $this->path.'/'.$this->prefix.'/'.$zoom.'/'.$latitude.'/'.$longitude.'/'.$width.'x'.$height.'.jpeg';
+
+ //Without multi up to date file
+ if (!is_file($map) || !($mtime = stat($map)['mtime']) || $mtime < $updated) {
+ //Without existing map path
+ if (!is_dir($dir = dirname($map))) {
+ //Create filesystem object
+ $filesystem = new Filesystem();
+
+ try {
+ //Create path
+ //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+ //XXX: on CoW filesystems execute a chattr +C before filling
+ $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);
+ }
+ }
+
+ //Create image instance
+ $image = new \Imagick();
+
+ //Add new image
+ $image->newImage($width, $height, new \ImagickPixel('transparent'), 'jpeg');
+
+ //Create tile instance
+ $tile = new \Imagick();
+
+ //Get tile xy
+ $centerX = $this->map->longitudeToX($longitude, $zoom);
+ $centerY = $this->map->latitudeToY($latitude, $zoom);
+
+ //Calculate start xy
+ $startX = floor(floor($centerX) - $width / MapUtil::tz);
+ $startY = floor(floor($centerY) - $height / MapUtil::tz);
+
+ //Calculate end xy
+ $endX = ceil(ceil($centerX) + $width / MapUtil::tz);
+ $endY = ceil(ceil($centerY) + $height / MapUtil::tz);
+
+ for($x = $startX; $x <= $endX; $x++) {
+ for($y = $startY; $y <= $endY; $y++) {
+ //Set cache path
+ $cache = $this->cache.'/'.$this->prefix.'/'.$zoom.'/'.$x.'/'.$y.'.png';
+
+ //Without cache image
+ if (!is_file($cache)) {
+ //Set tile url
+ $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->url);
+
+ //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);
+ }
+ }
+
+ //Store tile in cache
+ file_put_contents($cache, file_get_contents($tileUri, false, $this->ctx));
+ }
+
+ //Set dest x
+ $destX = intval(floor($width / 2 - MapUtil::tz * ($centerX - $x)));
+
+ //Set dest y
+ $destY = intval(floor($height / 2 - MapUtil::tz * ($centerY - $y)));
+
+ //Read tile from cache
+ $tile->readImage($cache);
+
+ //Compose image
+ $image->compositeImage($tile, \Imagick::COMPOSITE_OVER, $destX, $destY);
+
+ //Clear tile
+ $tile->clear();
+ }
+ }
+
+ //Add imagick draw instance
+ //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916
+ $draw = new \ImagickDraw();
+
+ //Set text antialias
+ $draw->setTextAntialias(true);
+
+ //Set stroke antialias
+ $draw->setStrokeAntialias(true);
+
+ //Set text alignment
+ $draw->setTextAlignment(\Imagick::ALIGN_CENTER);
+
+ //Set gravity
+ $draw->setGravity(\Imagick::GRAVITY_CENTER);
+
+ //Set fill color
+ $draw->setFillColor('#cff');
+
+ //Set stroke color
+ $draw->setStrokeColor('#00c3f9');
+
+ //Set stroke width
+ $draw->setStrokeWidth(2);
+
+ //Draw circle
+ $draw->circle($width/2 - 5, $height/2 - 5, $width/2 + 5, $height/2 + 5);
+
+ //Draw on image
+ $image->drawImage($draw);
+
+ //Strip image exif data and properties
+ $image->stripImage();
+
+ //Add latitude
+ //XXX: not supported by imagick :'(
+ $image->setImageProperty('exif:GPSLatitude', $this->map->latitudeToSexagesimal($latitude));
+
+ //Add longitude
+ //XXX: not supported by imagick :'(
+ $image->setImageProperty('exif:GPSLongitude', $this->map->longitudeToSexagesimal($longitude));
+
+ //Add description
+ //XXX: not supported by imagick :'(
+ #$image->setImageProperty('exif:Description', $caption);
+
+ //Set progressive jpeg
+ $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
+
+ //Set compression quality
+ //TODO: ajust that
+ $image->setImageCompressionQuality(70);
+
+ //Save image
+ if (!$image->writeImage($map)) {
+ //Throw error
+ throw new \Exception(sprintf('Unable to write image "%s"', $path));
+ }
+
+ //Set mtime
+ $mtime = stat($map)['mtime'];
+ }
+
+ //Read map from cache
+ $response = new BinaryFileResponse($map);
+
+ //Set file name
+ $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'map-'.$latitude.','.$longitude.'-'.$zoom.'-'.$width.'x'.$height.'.jpeg');
+
+ //Set etag
+ $response->setEtag(md5(serialize([$updated, $latitude, $longitude, $zoom, $width, $height])));
+
+ //Set last modified
+ $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime)));
+
+ //Disable robot index
+ $response->headers->set('X-Robots-Tag', 'noindex');
+
+ //Set as public
+ $response->setPublic();
+
+ //Return 304 response if not modified
+ $response->isNotModified($request);
+
+ //Return response
+ return $response;
+ }
+
+ /**
+ * Return multi map image
+ *
+ * @param Request $request The Request instance
+ * @param string $hash The hash
+ * @param int $updated The updated timestamp
+ * @param float $latitude The latitude
+ * @param float $longitude The longitude
+ * @param string $coordinates The coordinates
+ * @param int $zoom The zoom
+ * @param int $width The width
+ * @param int $height The height
+ * @return Response The rendered image
+ */
+ public function multiMap(Request $request, string $hash, int $updated, float $latitude, float $longitude, string $coordinates, int $zoom, int $width, int $height): Response {
+ //Without matching hash
+ if ($hash !== $this->slugger->hash([$updated, $latitude, $longitude, $coordinate = $this->slugger->hash($coordinates), $zoom, $width, $height])) {
+ //Throw new exception
+ throw new NotFoundHttpException(sprintf('Unable to match multi map hash: %s', $hash));
+ }
+
+ //Set multi
+ $map = $this->path.'/'.$this->prefix.'/'.$zoom.'/'.$latitude.'/'.$longitude.'/'.$coordinate.'/'.$width.'x'.$height.'.jpeg';
+
+ //Without multi up to date file
+ if (!is_file($map) || !($mtime = stat($map)['mtime']) || $mtime < $updated) {
+ //Without existing multi path
+ if (!is_dir($dir = dirname($map))) {
+ //Create filesystem object
+ $filesystem = new Filesystem();
+
+ try {
+ //Create path
+ //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+ //XXX: on CoW filesystems execute a chattr +C before filling
+ $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);
+ }
+ }
+
+ //Create image instance
+ $image = new \Imagick();
+
+ //Add new image
+ $image->newImage($width, $height, new \ImagickPixel('transparent'), 'jpeg');
+
+ //Create tile instance
+ $tile = new \Imagick();
+
+ //Get tile xy
+ $centerX = $this->map->longitudeToX($longitude, $zoom);
+ $centerY = $this->map->latitudeToY($latitude, $zoom);
+
+ //Calculate start xy
+ $startX = floor(floor($centerX) - $width / MapUtil::tz);
+ $startY = floor(floor($centerY) - $height / MapUtil::tz);
+
+ //Calculate end xy
+ $endX = ceil(ceil($centerX) + $width / MapUtil::tz);
+ $endY = ceil(ceil($centerY) + $height / MapUtil::tz);
+
+ for($x = $startX; $x <= $endX; $x++) {
+ for($y = $startY; $y <= $endY; $y++) {
+ //Set cache path
+ $cache = $this->cache.'/'.$this->prefix.'/'.$zoom.'/'.$x.'/'.$y.'.png';
+
+ //Without cache image
+ if (!is_file($cache)) {
+ //Set tile url
+ $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->url);
+
+ //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);
+ }
+ }
+
+ //Store tile in cache
+ file_put_contents($cache, file_get_contents($tileUri, false, $this->ctx));
+ }
+
+ //Set dest x
+ $destX = intval(floor($width / 2 - MapUtil::tz * ($centerX - $x)));
+
+ //Set dest y
+ $destY = intval(floor($height / 2 - MapUtil::tz * ($centerY - $y)));
+
+ //Read tile from cache
+ $tile->readImage($cache);
+
+ //Compose image
+ $image->compositeImage($tile, \Imagick::COMPOSITE_OVER, $destX, $destY);
+
+ //Clear tile
+ $tile->clear();
+ }
+ }
+
+ //Add imagick draw instance
+ //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916
+ $draw = new \ImagickDraw();
+
+ //Set text antialias
+ $draw->setTextAntialias(true);
+
+ //Set stroke antialias
+ $draw->setStrokeAntialias(true);
+
+ //Set text alignment
+ $draw->setTextAlignment(\Imagick::ALIGN_CENTER);
+
+ //Set gravity
+ $draw->setGravity(\Imagick::GRAVITY_CENTER);
+
+ //Convert to array
+ $coordinates = array_reverse(array_map(function ($v) { $p = strpos($v, ','); return ['latitude' => floatval(substr($v, 0, $p)), 'longitude' => floatval(substr($v, $p + 1))]; }, explode('-', $coordinates)), true);
+
+ //Iterate on locations
+ foreach($coordinates as $id => $coordinate) {
+ //Set dest x
+ $destX = intval(floor($width / 2 - MapUtil::tz * ($centerX - $this->map->longitudeToX(floatval($coordinate['longitude']), $zoom))));
+
+ //Set dest y
+ $destY = intval(floor($height / 2 - MapUtil::tz * ($centerY - $this->map->latitudeToY(floatval($coordinate['latitude']), $zoom))));
+
+ //Set fill color
+ $draw->setFillColor($this->map->getFill());
+
+ //Set font size
+ $draw->setFontSize($this->map->getFontSize());
+
+ //Set stroke color
+ $draw->setStrokeColor($this->map->getStroke());
+
+ //Set circle radius
+ $radius = $this->map->getRadius();
+
+ //Set stroke width
+ $stroke = $this->map->getStrokeWidth();
+
+ //With matching position
+ if ($coordinate['latitude'] === $latitude && $coordinate['longitude'] == $longitude) {
+ //Set fill color
+ $draw->setFillColor($this->map->getHighFill());
+
+ //Set font size
+ $draw->setFontSize($this->map->getHighFontSize());
+
+ //Set stroke color
+ $draw->setStrokeColor($this->map->getHighStroke());
+
+ //Set circle radius
+ $radius = $this->map->getHighRadius();
+
+ //Set stroke width
+ $stroke = $this->map->getHighStrokeWidth();
+ }
+
+ //Set stroke width
+ $draw->setStrokeWidth($stroke);
+
+ //Draw circle
+ $draw->circle($destX - $radius, $destY - $radius, $destX + $radius, $destY + $radius);
+
+ //Set fill color
+ $draw->setFillColor($draw->getStrokeColor());
+
+ //Set stroke width
+ $draw->setStrokeWidth($stroke / 4);
+
+ //Get font metrics
+ #$metrics = $image->queryFontMetrics($draw, strval($id));
+
+ //Add annotation
+ $draw->annotation($destX - $radius, $destY + $stroke, strval($id));
+ }
+
+ //Draw on image
+ $image->drawImage($draw);
+
+ //Strip image exif data and properties
+ $image->stripImage();
+
+ //Add latitude
+ //XXX: not supported by imagick :'(
+ $image->setImageProperty('exif:GPSLatitude', $this->map->latitudeToSexagesimal($latitude));
+
+ //Add longitude
+ //XXX: not supported by imagick :'(
+ $image->setImageProperty('exif:GPSLongitude', $this->map->longitudeToSexagesimal($longitude));
+
+ //Add description
+ //XXX: not supported by imagick :'(
+ #$image->setImageProperty('exif:Description', $caption);
+
+ //Set progressive jpeg
+ $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
+
+ //Set compression quality
+ //TODO: ajust that
+ $image->setImageCompressionQuality(70);
+
+ //Save image
+ if (!$image->writeImage($map)) {
+ //Throw error
+ throw new \Exception(sprintf('Unable to write image "%s"', $path));
+ }
+
+ //Set mtime
+ $mtime = stat($map)['mtime'];
+ }
+
+ //Read map from cache
+ $response = new BinaryFileResponse($map);
+
+ //Set file name
+ $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'multimap-'.$latitude.','.$longitude.'-'.$zoom.'-'.$width.'x'.$height.'.jpeg');
+
+ //Set etag
+ $response->setEtag(md5(serialize([$updated, $latitude, $longitude, $zoom, $width, $height])));
+
+ //Set last modified
+ $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime)));
+
+ //Disable robot index
+ $response->headers->set('X-Robots-Tag', 'noindex');
+
+ //Set as public
+ $response->setPublic();
+
+ //Return 304 response if not modified
+ $response->isNotModified($request);
+
+ //Return response
+ return $response;
+ }
+}
-<?php
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
namespace Rapsys\PackBundle\DependencyInjection;
+use Rapsys\PackBundle\RapsysPackBundle;
+
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
+use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\Process\ExecutableFinder;
/**
+ * {@inheritdoc}
+ *
* This is the class that validates and merges configuration from your app/config files.
*
- * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/configuration.html}
+ * @link http://symfony.com/doc/current/cookbook/bundles/configuration.html
*/
class Configuration implements ConfigurationInterface {
/**
* {@inheritdoc}
*/
- public function getConfigTreeBuilder() {
+ public function getConfigTreeBuilder(): TreeBuilder {
//Get TreeBuilder object
- $treeBuilder = new TreeBuilder('rapsys_pack');
+ $treeBuilder = new TreeBuilder($alias = RapsysPackBundle::getAlias());
//Get ExecutableFinder object
$finder = new ExecutableFinder();
- /**
- * XXX: Note about the output schemes
- *
- * The output files are written based on the output.<ext> scheme with the * replaced by the hashed path of packed files
- *
- * The following service configuration make twig render the output file path with the right '/' basePath prefix:
- * services:
- * assets.pack_package:
- * class: Rapsys\PackBundle\Asset\PathPackage
- * arguments: [ '/', '@assets.empty_version_strategy', '@assets.context' ]
- * rapsys_pack.twig.pack_extension:
- * class: Rapsys\PackBundle\Twig\PackExtension
- * arguments: [ '@file_locator', '@service_container', '@assets.pack_package' ]
- * tags: [ twig.extension ]
- */
-
//The bundle default values
$defaults = [
- 'config' => [
- 'name' => 'asset_url',
- 'scheme' => 'https://',
- 'timeout' => (int)ini_get('default_socket_timeout'),
- 'agent' => (string)ini_get('user_agent')?:'rapsys_pack/0.1.3',
- 'redirect' => 5
- ],
- 'output' => [
- 'css' => '@RapsysPack/css/*.pack.css',
- 'js' => '@RapsysPack/js/*.pack.js',
- 'img' => '@RapsysPack/img/*.pack.jpg'
- ],
'filters' => [
'css' => [
- 'class' => 'Rapsys\PackBundle\Twig\Filter\CPackFilter',
- 'args' => [
- $finder->find('cpack', '/usr/local/bin/cpack'),
- 'minify'
- ]
- ],
- 'js' => [
- 'class' => 'Rapsys\PackBundle\Twig\Filter\JPackFilter',
- 'args' => [
- $finder->find('jpack', '/usr/local/bin/jpack'),
- 'best'
+ 0 => [
+ 'class' => 'Rapsys\PackBundle\Filter\CPackFilter',
+ 'args' => [
+ $finder->find('cpack', '/usr/local/bin/cpack'),
+ 'minify'
+ ]
]
],
'img' => [
- 'class' => 'Rapsys\PackBundle\Twig\Filter\IPackFilter',
- 'args' => []
+ 0 => [
+ 'class' => 'Rapsys\PackBundle\Filter\IPackFilter',
+ 'args' => []
+ ]
],
- ]
+ 'js' => [
+ 0 => [
+ 'class' => 'Rapsys\PackBundle\Filter\JPackFilter',
+ 'args' => [
+ $finder->find('jpack', '/usr/local/bin/jpack'),
+ 'best'
+ ]
+ ]
+ ]
+ ],
+ #TODO: migrate to public.path, public.url and router->generateUrl ?
+ #XXX: that would means dropping the PathPackage stuff and use static route like rapsyspack_facebook
+ 'output' => [
+ 'css' => '@RapsysPack/css/*.pack.css',
+ 'img' => '@RapsysPack/img/*.pack.jpg',
+ 'js' => '@RapsysPack/js/*.pack.js'
+ ],
+ 'path' => dirname(__DIR__).'/Resources/public',
+ 'token' => 'asset_url'
];
- //Here we define the parameters that are allowed to configure the bundle.
- //XXX: see https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php for default value and description
- //XXX: see http://symfony.com/doc/current/components/config/definition.html
- //XXX: see https://github.com/symfony/assetic-bundle/blob/master/DependencyInjection/Configuration.php#L63
- //XXX: see php bin/console config:dump-reference rapsys_pack to dump default config
- //XXX: see php bin/console debug:config rapsys_pack to dump config
+ /**
+ * Defines parameters allowed to configure the bundle
+ *
+ * @link https://github.com/symfony/symfony/blob/master/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
+ * @link http://symfony.com/doc/current/components/config/definition.html
+ * @link https://github.com/symfony/assetic-bundle/blob/master/DependencyInjection/Configuration.php#L63
+ *
+ * @see bin/console config:dump-reference rapsyspack to dump default config
+ * @see bin/console debug:config rapsyspack to dump config
+ */
$treeBuilder
//Parameters
->getRootNode()
->addDefaultsIfNotSet()
->children()
- ->arrayNode('config')
- ->addDefaultsIfNotSet()
- ->children()
- ->scalarNode('name')->cannotBeEmpty()->defaultValue($defaults['config']['name'])->end()
- ->scalarNode('scheme')->cannotBeEmpty()->defaultValue($defaults['config']['scheme'])->end()
- ->integerNode('timeout')->min(0)->max(300)->defaultValue($defaults['config']['timeout'])->end()
- ->scalarNode('agent')->cannotBeEmpty()->defaultValue($defaults['config']['agent'])->end()
- ->integerNode('redirect')->min(1)->max(30)->defaultValue($defaults['config']['redirect'])->end()
- ->end()
- ->end()
- ->arrayNode('output')
- ->addDefaultsIfNotSet()
- ->children()
- ->scalarNode('css')->cannotBeEmpty()->defaultValue($defaults['output']['css'])->end()
- ->scalarNode('js')->cannotBeEmpty()->defaultValue($defaults['output']['js'])->end()
- ->scalarNode('img')->cannotBeEmpty()->defaultValue($defaults['output']['img'])->end()
- ->end()
- ->end()
->arrayNode('filters')
->addDefaultsIfNotSet()
->children()
->arrayNode('css')
- #XXX: undocumented, see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+ /**
+ * Undocumented
+ *
+ * @see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+ */
->addDefaultChildrenIfNoneSet()
->arrayPrototype()
->children()
->scalarNode('class')
->isRequired()
->cannotBeEmpty()
- ->defaultValue($defaults['filters']['css']['class'])
+ ->defaultValue($defaults['filters']['css'][0]['class'])
->end()
->arrayNode('args')
- /*->isRequired()*/
- ->treatNullLike(array())
- ->defaultValue($defaults['filters']['css']['args'])
+ //->isRequired()
+ ->treatNullLike([])
+ ->defaultValue($defaults['filters']['css'][0]['args'])
->scalarPrototype()->end()
->end()
->end()
->end()
->end()
- ->arrayNode('js')
- #XXX: undocumented, see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+ ->arrayNode('img')
+ /**
+ * Undocumented
+ *
+ * @see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+ */
->addDefaultChildrenIfNoneSet()
->arrayPrototype()
->children()
->scalarNode('class')
->isRequired()
->cannotBeEmpty()
- ->defaultValue($defaults['filters']['js']['class'])
+ ->defaultValue($defaults['filters']['img'][0]['class'])
->end()
->arrayNode('args')
- ->treatNullLike(array())
- ->defaultValue($defaults['filters']['js']['args'])
+ ->treatNullLike([])
+ ->defaultValue($defaults['filters']['img'][0]['args'])
->scalarPrototype()->end()
->end()
->end()
->end()
->end()
- ->arrayNode('img')
- #XXX: undocumented, see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+ ->arrayNode('js')
+ /**
+ * Undocumented
+ *
+ * @see Symfony/Component/Config/Definition/Builder/ArrayNodeDefinition.php +513
+ */
->addDefaultChildrenIfNoneSet()
->arrayPrototype()
->children()
->scalarNode('class')
->isRequired()
->cannotBeEmpty()
- ->defaultValue($defaults['filters']['img']['class'])
+ ->defaultValue($defaults['filters']['js'][0]['class'])
->end()
->arrayNode('args')
- ->treatNullLike(array())
- ->defaultValue($defaults['filters']['img']['args'])
+ ->treatNullLike([])
+ ->defaultValue($defaults['filters']['js'][0]['args'])
->scalarPrototype()->end()
->end()
->end()
->end()
->end()
->end()
+ ->arrayNode('output')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->scalarNode('css')->cannotBeEmpty()->defaultValue($defaults['output']['css'])->end()
+ ->scalarNode('img')->cannotBeEmpty()->defaultValue($defaults['output']['img'])->end()
+ ->scalarNode('js')->cannotBeEmpty()->defaultValue($defaults['output']['js'])->end()
+ ->end()
+ ->end()
+ ->scalarNode('path')->cannotBeEmpty()->defaultValue($defaults['path'])->end()
+ ->scalarNode('token')->cannotBeEmpty()->defaultValue($defaults['token'])->end()
->end()
->end();
-<?php
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
namespace Rapsys\PackBundle\DependencyInjection;
+use Rapsys\PackBundle\RapsysPackBundle;
+
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
* This is the class that loads and manages your bundle configuration.
*
* @link http://symfony.com/doc/current/cookbook/bundles/extension.html
+ *
+ * {@inheritdoc}
*/
class RapsysPackExtension extends Extension {
/**
* {@inheritdoc}
*/
- public function load(array $configs, ContainerBuilder $container) {
+ public function load(array $configs, ContainerBuilder $container): void {
//Load configuration
$configuration = $this->getConfiguration($configs, $container);
//Process the configuration to get merged config
$config = $this->processConfiguration($configuration, $configs);
+ //Set bundle alias
+ $alias = RapsysPackBundle::getAlias();
+
//Detect when no user configuration is provided
if ($configs === [[]]) {
//Prepend default config
- $container->prependExtensionConfig($this->getAlias(), $config);
+ $container->prependExtensionConfig($alias, $config);
}
//Save configuration in parameters
- $container->setParameter($this->getAlias(), $config);
+ $container->setParameter($alias, $config);
+
+ //Set rapsyspack.alias key
+ $container->setParameter($alias.'.alias', $alias);
+
+ //Set rapsyspack.path key
+ $container->setParameter($alias.'.path', $config['path']);
+
+ //Set rapsyspack.version key
+ $container->setParameter($alias.'.version', RapsysPackBundle::getVersion());
}
/**
* {@inheritdoc}
+ *
+ * @xxx Required by kernel to load renamed alias configuration
*/
- public function getAlias() {
- return 'rapsys_pack';
+ public function getAlias(): string {
+ return RapsysPackBundle::getAlias();
}
}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Extension;
+
+use Rapsys\PackBundle\Parser\TokenParser;
+use Rapsys\PackBundle\RapsysPackBundle;
+use Rapsys\PackBundle\Util\IntlUtil;
+use Rapsys\PackBundle\Util\SluggerUtil;
+
+use Symfony\Component\Asset\PackageInterface;
+use Symfony\Component\HttpKernel\Config\FileLocator;
+
+use Twig\Extension\AbstractExtension;
+
+/**
+ * {@inheritdoc}
+ */
+class PackExtension extends AbstractExtension {
+ /**
+ * {@inheritdoc}
+ *
+ * @link https://twig.symfony.com/doc/2.x/advanced.html
+ */
+ public function __construct(protected IntlUtil $intl, protected FileLocator $locator, protected PackageInterface $package, protected SluggerUtil $slugger, protected array $parameters) {
+ }
+
+ /**
+ * Returns a filter array to add to the existing list.
+ *
+ * @return \Twig\TwigFilter[]
+ */
+ public function getTokenParsers(): array {
+ return [
+ new TokenParser($this->locator, $this->package, $this->parameters['token'], 'stylesheet', $this->parameters['output']['css'], $this->parameters['filters']['css']),
+ new TokenParser($this->locator, $this->package, $this->parameters['token'], 'javascript', $this->parameters['output']['js'], $this->parameters['filters']['js']),
+ new TokenParser($this->locator, $this->package, $this->parameters['token'], 'image', $this->parameters['output']['img'], $this->parameters['filters']['img'])
+ ];
+ }
+
+ /**
+ * Returns a filter array to add to the existing list.
+ *
+ * @return \Twig\TwigFilter[]
+ */
+ public function getFilters(): array {
+ return [
+ new \Twig\TwigFilter('lcfirst', 'lcfirst'),
+ new \Twig\TwigFilter('ucfirst', 'ucfirst'),
+ new \Twig\TwigFilter('hash', [$this->slugger, 'hash']),
+ new \Twig\TwigFilter('unshort', [$this->slugger, 'unshort']),
+ new \Twig\TwigFilter('short', [$this->slugger, 'short']),
+ new \Twig\TwigFilter('slug', [$this->slugger, 'slug']),
+ new \Twig\TwigFilter('intldate', [$this->intl, 'date'], ['needs_environment' => true]),
+ new \Twig\TwigFilter('intlnumber', [$this->intl, 'number']),
+ new \Twig\TwigFilter('intlcurrency', [$this->intl, 'currency']),
+ new \Twig\TwigFilter('download', 'file_get_contents', [false, null]),
+ new \Twig\TwigFilter('base64_encode', 'base64_encode'),
+ new \Twig\TwigFilter('base64_decode', 'base64_decode')
+ ];
+ }
+}
-<?php
+<?php declare(strict_types=1);
-namespace Rapsys\PackBundle\Twig\Filter;
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Filter;
-use Rapsys\PackBundle\Twig\Filter\FilterInterface;
use Twig\Error\Error;
+use Twig\Source;
+/**
+ * {@inheritdoc}
+ */
class CPackFilter implements FilterInterface {
- //Default bin
- private $bin;
-
- //Default compress type
- private $compress;
-
- //Twig template filename
- private $fileName;
-
- //Twig template line
- private $line;
-
- //Configure the object
- //XXX: compress can be minify or pretty
- public function __construct($fileName, $line, $bin = 'cpack', $compress = 'minify') {
- //Set fileName
- $this->fileName = $fileName;
-
- //Set line
- $this->line = $line;
-
- //Set bin
- $this->bin = $bin;
-
- //Set compress
- $this->compress = $compress;
-
+ /**
+ * Setup cpack filter
+ *
+ * @xxx compress can be minify or pretty
+ */
+ public function __construct(protected Source $fileName, protected int $line, protected string $bin = 'cpack', protected string $compress = 'minify') {
//Deal with compress
if (!empty($this->compress)) {
//Append minify parameter
if ($this->compress == 'minify') {
+ //TODO: protect binary call by converting bin to array ?
$this->bin .= ' --minify';
//Unknown compress type
//XXX: default compression is pretty
}
}
- public function process($content) {
+ /**
+ * {@inheritdoc}
+ */
+ public function process(string $content): string {
//Create descriptors
$descriptorSpec = array(
0 => array('pipe', 'r'),
//Open process
if (is_resource($proc = proc_open($this->bin, $descriptorSpec, $pipes))) {
//Set stderr as non blocking
- stream_set_blocking($pipes[2], 0);
+ stream_set_blocking($pipes[2], false);
//Send content to stdin
fwrite($pipes[0], $content);
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Filter;
+
+/**
+ * Filter interface definition
+ *
+ * @todo do we need something else ? (like a constructor that read parameters or else)
+ */
+interface FilterInterface {
+ /**
+ * Process function
+ *
+ * @param string $content The content to process
+ * @return string The processed content
+ */
+ public function process(string $content): string;
+}
-<?php
+<?php declare(strict_types=1);
-namespace Rapsys\PackBundle\Twig\Filter;
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Filter;
-use Rapsys\PackBundle\Twig\Filter\FilterInterface;
use Twig\Error\Error;
+use Twig\Source;
+/**
+ * {@inheritdoc}
+ */
class JPackFilter implements FilterInterface {
- //Default bin
- private $bin;
-
- //Default compress type
- private $compress;
-
- //Twig template filename
- private $fileName;
-
- //Twig template line
- private $line;
-
- //Configure the object
- //XXX: can be clean, shrink, obfuscate or best
- public function __construct($fileName, $line, $bin = 'jpack', $compress = 'best') {
- //Set fileName
- $this->fileName = $fileName;
-
- //Set line
- $this->line = $line;
-
- //Set bin
- $this->bin = $bin;
-
- //Set compress
- $this->compress = $compress;
-
+ /**
+ * Setup jpack filter
+ *
+ * @xxx compress can be clean, shrink, obfuscate or best
+ */
+ public function __construct(protected Source $fileName, protected int $line, protected string $bin = 'jpack', protected string $compress = 'best') {
//Deal with compress
if (!empty($this->compress)) {
//Append clean parameter
if ($this->compress == 'clean') {
+ //TODO: protect binary call by converting bin to array ?
$this->bin .= ' --clean';
//Append shrink parameter
} elseif ($this->compress == 'shrink') {
+ //TODO: protect binary call by converting bin to array ?
$this->bin .= ' --shrink';
//Append obfuscate parameter
} elseif ($this->compress == 'obfuscate') {
+ //TODO: protect binary call by converting bin to array ?
$this->bin .= ' --obfuscate';
//Unknown compress type
//XXX: default compression is best
}
}
- public function process($content) {
+ /**
+ * {@inheritdoc}
+ */
+ public function process(string $content): string {
//Create descriptors
$descriptorSpec = array(
0 => array('pipe', 'r'),
//Open process
if (is_resource($proc = proc_open($this->bin, $descriptorSpec, $pipes))) {
//Set stderr as non blocking
- stream_set_blocking($pipes[2], 0);
+ stream_set_blocking($pipes[2], false);
//Send content to stdin
fwrite($pipes[0], $content);
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Form;
+
+use Rapsys\PackBundle\Util\ImageUtil;
+use Rapsys\PackBundle\Util\SluggerUtil;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\HiddenType;
+use Symfony\Component\Form\Extension\Core\Type\IntegerType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormError;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+/**
+ * Captcha Type class definition
+ *
+ * @see https://symfony.com/doc/current/form/create_custom_field_type.html
+ */
+class CaptchaType extends AbstractType {
+ /**
+ * Constructor
+ *
+ * @param ?ImageUtil $image The image instance
+ * @param ?SluggerUtil $slugger The slugger instance
+ * @param ?TranslatorInterface $translator The translator instance
+ */
+ public function __construct(protected ?ImageUtil $image = null, protected ?SluggerUtil $slugger = null, protected ?TranslatorInterface $translator = null) {
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Build form
+ */
+ public function buildForm(FormBuilderInterface $builder, array $options): void {
+ //With image, slugger and translator
+ if (!empty($options['captcha']) && $this->image !== null && $this->slugger !== null && $this->translator !== null) {
+ //Set captcha
+ $captcha = $this->image->getCaptcha((new \DateTime('-1 year'))->getTimestamp());
+
+ //Add captcha token
+ $builder->add('_captcha_token', HiddenType::class, ['data' => $captcha['token'], 'empty_data' => $captcha['token'], 'mapped' => false]);
+
+ //Add captcha
+ $builder->add('captcha', IntegerType::class, ['label_attr' => ['class' => 'captcha'], 'label' => '<img src="'.htmlentities($captcha['src']).'" alt="'.htmlentities($captcha['equation']).'" />', 'label_html' => true, 'mapped' => false, 'translation_domain' => false]);
+
+ //Add event listener on captcha
+ $builder->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'validateCaptcha']);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function configureOptions(OptionsResolver $resolver): void {
+ //Call parent configure options
+ parent::configureOptions($resolver);
+
+ //Set defaults
+ $resolver->setDefaults(['captcha' => false]);
+
+ //Add extra captcha option
+ $resolver->setAllowedTypes('captcha', 'boolean');
+ }
+
+ /**
+ * Validate captcha
+ *
+ * @param FormEvent $event The form event
+ */
+ public function validateCaptcha(FormEvent $event): void {
+ //Get form
+ $form = $event->getForm();
+
+ //Get event data
+ $data = $event->getData();
+
+ //Set token
+ $token = $form->get('_captcha_token')->getConfig()->getData();
+
+ //Without captcha
+ if (empty($data['captcha'])) {
+ //Add error on captcha
+ $form->addError(new FormError($this->translator->trans('Captcha is empty')));
+
+ //Reset captcha token
+ $data['_captcha_token'] = $token;
+
+ //Set event data
+ $event->setData($data);
+ //With invalid captcha
+ } elseif ($this->slugger->hash($data['captcha']) !== $data['_captcha_token']) {
+ //Add error on captcha
+ $form->addError(new FormError($this->translator->trans('Captcha is invalid')));
+
+ //Reset captcha token
+ $data['_captcha_token'] = $token;
+
+ //Set event data
+ $event->setData($data);
+ }
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Package;
+
+use Rapsys\PackBundle\Context\NullContext;
+
+use Symfony\Component\Asset\Context\ContextInterface;
+use Symfony\Component\Asset\Package;
+use Symfony\Component\Asset\VersionStrategy\VersionStrategyInterface;
+
+/**
+ * {@inheritdoc}
+ */
+class PathPackage extends Package {
+ /**
+ * The base url
+ */
+ protected string $baseUrl;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __construct(protected string $basePath, protected VersionStrategyInterface $versionStrategy, protected ?ContextInterface $context = null) {
+ //Without context use a null context
+ $this->context = $this->context ?? new NullContext();
+
+ //Call parent constructor
+ parent::__construct($this->versionStrategy, $this->context);
+
+ //Without base path
+ if (empty($basePath)) {
+ //Set base path
+ $this->basePath = '/';
+ //With base path
+ } else {
+ //With relative base path
+ if ('/' != $basePath[0]) {
+ //Set base path as absolute
+ $basePath = '/'.$basePath;
+ }
+
+ //Set base path
+ $this->basePath = rtrim($basePath, '/').'/';
+ }
+
+ //Set base url
+ $this->baseUrl = $this->context->getBaseUrl();
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * Returns an absolute or root-relative public path
+ *
+ * Transform @BundleBundle to bundle and remove /Resources/public fragment from path
+ * This bundle name conversion and bundle prefix are the same as in asset:install command
+ *
+ * @link https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure
+ * @see vendor/symfony/framework-bundle/Command/AssetsInstallCommand.php +113
+ * @see vendor/symfony/framework-bundle/Command/AssetsInstallCommand.php +141
+ */
+ public function getUrl(string $path): string {
+ //Match url starting with a bundle name
+ if (preg_match('%^@([A-Z][a-zA-Z]*?)(?:Bundle/Resources/public)?/(.*)$%', $path, $matches)) {
+ //Handle empty or without replacement pattern basePath
+ if (empty($this->basePath) || strpos($this->basePath, '%s') === false) {
+ //Set path from hardcoded format
+ $path = '/bundles/'.strtolower($matches[1]).'/'.$matches[2];
+ //Proceed with basePath pattern replacement
+ } else {
+ //Set path from basePath pattern
+ //XXX: basePath has a trailing / added by constructor
+ $path = sprintf($this->basePath, strtolower($matches[1])).$matches[2];
+ }
+ }
+
+ //Return parent getUrl result
+ return parent::getUrl($path);
+ }
+
+ /**
+ * Returns an absolute public path.
+ *
+ * @param string $path A path
+ * @return string The absolute public path
+ */
+ public function getAbsoluteUrl(string $path): string {
+ //Return concated base url and url from path
+ return $this->baseUrl.self::getUrl($path);
+ }
+}
-<?php
+<?php declare(strict_types=1);
-namespace Rapsys\PackBundle\Twig;
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Parser;
+
+use Rapsys\PackBundle\RapsysPackBundle;
-use Symfony\Component\HttpKernel\Config\FileLocator;
use Symfony\Component\Asset\PackageInterface;
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\HttpKernel\Config\FileLocator;
+
use Twig\Error\Error;
use Twig\Node\Expression\AssignNameExpression;
use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;
-class PackTokenParser extends AbstractTokenParser {
- ///The tag name
- protected $tag;
+/**
+ * {@inheritdoc}
+ */
+class TokenParser extends AbstractTokenParser {
+ /**
+ * The stream context instance
+ */
+ protected mixed $ctx;
/**
* Constructor
*
- * @param FileLocator locator The FileLocator instance
- * @param PackageInterface package The Assets Package instance
- * @param array config The config path
- * @param string tag The tag name
- * @param string output The default output string
- * @param array filters The default filters array
+ * @param FileLocator $locator The FileLocator instance
+ * @param PackageInterface $package The Assets Package instance
+ * @param string $token The token name
+ * @param string $tag The tag name
+ * @param string $output The default output string
+ * @param array $filters The default filter array
*/
- public function __construct(FileLocator $locator, PackageInterface $package, $config, $tag, $output, $filters) {
- //Save locator
- $this->locator = $locator;
-
- //Save assets package
- $this->package = $package;
-
- //Set name
- $this->name = $config['name'];
-
- //Set scheme
- $this->scheme = $config['scheme'];
-
- //Set timeout
- $this->timeout = $config['timeout'];
-
- //Set agent
- $this->agent = $config['agent'];
-
- //Set redirect
- $this->redirect = $config['redirect'];
-
- //Set tag
- $this->tag = $tag;
-
- //Set output
- $this->output = $output;
-
- //Set filters
- $this->filters = $filters;
+ public function __construct(protected FileLocator $locator, protected PackageInterface $package, protected string $token, protected string $tag, protected string $output, protected array $filters) {
+ //Set ctx
+ $this->ctx = stream_context_create(
+ [
+ 'http' => [
+ #'header' => ['Referer: https://www.openstreetmap.org/'],
+ 'max_redirects' => $_ENV['RAPSYSPACK_REDIRECT'] ?? 20,
+ 'timeout' => $_ENV['RAPSYSPACK_TIMEOUT'] ?? (($timeout = ini_get('default_socket_timeout')) !== false && $timeout !== "" ? (float)$timeout : 60),
+ 'user_agent' => $_ENV['RAPSYSPACK_AGENT'] ?? (($agent = ini_get('user_agent')) !== false && $agent !== "" ? (string)$agent : RapsysPackBundle::getAlias().'/'.RapsysPackBundle::getVersion())
+ ]
+ ]
+ );
}
/**
*
* @return string This tag name
*/
- public function getTag() {
+ public function getTag(): string {
return $this->tag;
}
/**
* Parse the token
*
- * @param Token token The \Twig\Token instance
+ * @xxx Skip filter when debug mode is enabled is not possible
+ * @xxx This code is only run once when twig cache is enabled
+ * @xxx Twig cache value is not avaible in container parameters, maybe in twig env ?
*
+ * @param Token $token The \Twig\Token instance
* @return Node The PackNode
- *
- * @todo see if we can't add a debug mode behaviour
- *
- * If twig.debug or env=dev (or rapsys_pack.config.debug?) is set, it should be possible to loop on each input
- * and process the captured body without applying requested filter.
- *
- * @todo list:
- * - detect debug mode
- * - retrieve fixe link from input s%@(Name)Bundle/Resources/public(/somewhere/file.ext)%/bundles/\L\1\E\2%
- * - for each inputs:
- * - generate a set asset_url=x
- * - generate a body
*/
- public function parse(Token $token) {
+ public function parse(Token $token): Node {
+ //Get parser
$parser = $this->parser;
+
+ //Get parser stream
$stream = $this->parser->getStream();
+ //Set inputs array
$inputs = [];
- $name = $this->name;
- $output = $this->output;
- $filters = $this->filters;
+ //Set content
$content = '';
//Process the token block until end
//filter='yui_js'
$stream->next();
$stream->expect(Token::OPERATOR_TYPE, '=');
- $filters = array_merge($filters, array_filter(array_map('trim', explode(',', $stream->expect(Token::STRING_TYPE)->getValue()))));
+ $this->filters = array_merge($this->filters, array_filter(array_map('trim', explode(',', $stream->expect(Token::STRING_TYPE)->getValue()))));
//The output token
} elseif ($stream->test(Token::NAME_TYPE, 'output')) {
//output='js/packed/*.js' OR output='js/core.js'
$stream->next();
$stream->expect(Token::OPERATOR_TYPE, '=');
- $output = $stream->expect(Token::STRING_TYPE)->getValue();
- //The name token
- } elseif ($stream->test(Token::NAME_TYPE, 'name')) {
+ $this->output = $stream->expect(Token::STRING_TYPE)->getValue();
+ //The token name
+ } elseif ($stream->test(Token::NAME_TYPE, 'token')) {
//name='core_js'
$stream->next();
$stream->expect(Token::OPERATOR_TYPE, '=');
- $name = $stream->expect(Token::STRING_TYPE)->getValue();
+ $this->token = $stream->expect(Token::STRING_TYPE)->getValue();
//Unexpected token
} else {
$token = $stream->getCurrent();
//Process end block
$stream->expect(Token::BLOCK_END_TYPE);
- //TODO: debug mode should be inserted here before the output variable is rewritten
-
//Replace star with sha1
- if (($pos = strpos($output, '*')) !== false) {
- //XXX: assetic use substr(sha1(serialize($inputs).serialize($filters).serialize($options)), 0, 7)
- $output = substr($output, 0, $pos).sha1(serialize($inputs).serialize($filters)).substr($output, $pos + 1);
+ if (($pos = strpos($this->output, '*')) !== false) {
+ //XXX: assetic use substr(sha1(serialize($inputs).serialize($this->filters).serialize($this->output)), 0, 7)
+ $this->output = substr($this->output, 0, $pos).sha1(serialize($inputs).serialize($this->filters)).substr($this->output, $pos + 1);
}
//Process inputs
//Deal with generic url
if (strpos($inputs[$k], '//') === 0) {
//Fix url
- $inputs[$k] = $this->scheme.substr($inputs[$k], 2);
+ $inputs[$k] = ($_ENV['RAPSYSPACK_SCHEME'] ?? 'https').'://'.substr($inputs[$k], 2);
//Deal with non url path
} elseif (strpos($inputs[$k], '://') === false) {
//Check if we have a bundle path
if (strpos($inputs[$k], '*') !== false || (($a = strpos($inputs[$k], '{')) !== false && ($b = strpos($inputs[$k], ',', $a)) !== false && strpos($inputs[$k], '}', $b) !== false)) {
//Get replacement
$replacement = glob($inputs[$k], GLOB_NOSORT|GLOB_BRACE);
+
//Check that these are working files
foreach($replacement as $input) {
//Check that it's a file
throw new Error(sprintf('Input path "%s" from "%s" is not a file', $input, $inputs[$k]), $token->getLine(), $stream->getSourceContext());
}
}
+
//Replace with glob path
array_splice($inputs, $k, 1, $replacement);
+
//Fix current key
$k += count($replacement) - 1;
//Check that it's a file
}
}
- //Init context
- $ctx = stream_context_create(
- [
- 'http' => [
- 'timeout' => $this->timeout,
- 'user_agent' => $this->agent,
- 'redirect' => $this->redirect,
- ]
- ]
- );
-
//Check inputs
if (!empty($inputs)) {
//Retrieve files content
foreach($inputs as $input) {
//Try to retrieve content
- if (($data = file_get_contents($input, false, $ctx)) === false) {
+ if (($data = file_get_contents($input, false, $this->ctx)) === false) {
throw new Error(sprintf('Unable to retrieve input path "%s"', $input), $token->getLine(), $stream->getSourceContext());
}
+
//Append content
$content .= $data;
}
}
//Check filters
- if (!empty($filters)) {
+ if (!empty($this->filters)) {
//Apply all filters
- foreach($filters as $filter) {
+ foreach($this->filters as $filter) {
//Init args
$args = [$stream->getSourceContext(), $token->getLine()];
+
//Check if args is available
if (!empty($filter['args'])) {
//Append args if provided
$args += $filter['args'];
}
+
//Init reflection
$reflection = new \ReflectionClass($filter['class']);
+
//Set instance args
$tool = $reflection->newInstanceArgs($args);
+
//Process content
$content = $tool->process($content);
+
//Remove object
unset($tool, $reflection);
}
}
//Retrieve asset uri
- //XXX: this path is the merge of services.assets.path_package.arguments[0] and rapsys_pack.output.(css,img,js)
- if (($outputUrl = $this->package->getUrl($output)) === false) {
- throw new Error(sprintf('Unable to get url for asset: %s', $output), $token->getLine(), $stream->getSourceContext());
+ //XXX: this path is the merge of services.assets.path_package.arguments[0] and rapsyspack.output.(css,img,js)
+ if (($outputUrl = $this->package->getUrl($this->output)) === false) {
+ throw new Error(sprintf('Unable to get url for asset: %s', $this->output), $token->getLine(), $stream->getSourceContext());
}
//Check if we have a bundle path
- if ($output[0] == '@') {
+ if ($this->output[0] == '@') {
//Resolve it
- $output = $this->getLocated($output, $token->getLine(), $stream->getSourceContext());
+ $this->output = $this->getLocated($this->output, $token->getLine(), $stream->getSourceContext());
}
+ //Get filesystem
+ $filesystem = new Filesystem();
+
//Create output dir if not present
- if (!is_dir($dir = dirname($output))) {
+ if (!is_dir($dir = dirname($this->output))) {
try {
- //XXX: set as 0777, symfony umask (0022) will reduce rights (0755)
- if (mkdir($dir, 0777, true) === false) {
- throw new \Exception();
- }
- } catch (\Exception $e) {
+ //Create dir
+ //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
+ $filesystem->mkdir($dir, 0775);
+ } catch (IOExceptionInterface $e) {
+ //Throw error
throw new Error(sprintf('Output directory "%s" do not exists and unable to create it', $dir), $token->getLine(), $stream->getSourceContext(), $e);
}
}
//Send file content
- //XXX: to avoid partial content in reverse cache we use atomic rotation write, unlink and move
try {
- if (file_put_contents($output.'.new', $content) === false) {
- throw new \Exception();
- }
- } catch(\Exception $e) {
- throw new Error(sprintf('Unable to write to: %s', $output.'.new'), $token->getLine(), $stream->getSourceContext(), $e);
- }
-
- //Remove old file
- if (is_file($output)) {
- try {
- if (unlink($output) === false) {
- throw new \Exception();
- }
- } catch (\Exception $e) {
- throw new Error(sprintf('Unable to unlink: %s', $output), $token->getLine(), $stream->getSourceContext(), $e);
- }
- }
-
- //Rename it
- try {
- if (rename($output.'.new', $output) === false) {
- throw new \Exception();
- }
- } catch (\Exception $e) {
- throw new Error(sprintf('Unable to rename: %s to %s', $output.'.new', $output), $token->getLine(), $stream->getSourceContext(), $e);
+ //Write content to file
+ //XXX: this call is (maybe) atomic
+ //XXX: see https://symfony.com/doc/current/components/filesystem.html#dumpfile
+ $filesystem->dumpFile($this->output, $content);
+ } catch (IOExceptionInterface $e) {
+ //Throw error
+ throw new Error(sprintf('Unable to write to: %s', $this->output), $token->getLine(), $stream->getSourceContext(), $e);
}
//Set name in context key
- $ref = new AssignNameExpression($name, $token->getLine());
+ $ref = new AssignNameExpression($this->token, $token->getLine());
//Set output in context value
$value = new TextNode($outputUrl, $token->getLine());
/**
* Test for tag end
*
- * @param Token token The \Twig\Token instance
- *
- * @return bool
+ * @param Token $token The \Twig\Token instance
+ * @return bool The token end test result
*/
- public function testEndTag(Token $token) {
+ public function testEndTag(Token $token): bool {
return $token->test(['end'.$this->getTag()]);
}
/**
* Get path from bundled file
*
- * @param string file The bundled file path
- * @param int lineno The template line where the error occurred
- * @param Source source The source context where the error occurred
- * @param \Exception prev The previous exception
+ * @see https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure
*
+ * @param string $file The bundled file path
+ * @param int $lineno The template line where the error occurred
+ * @param Source $source The source context where the error occurred
+ * @param Exception $prev The previous exception
* @return string The resolved file path
- *
- * @todo Try retrive public dir from the member function BundleNameBundle::getPublicDir() return value ?
- * @xxx see https://symfony.com/doc/current/bundles.html#overridding-the-bundle-directory-structure
*/
- public function getLocated($file, int $lineno = 0, Source $source = null, \Exception $prev = null) {
+ public function getLocated(string $file, int $lineno = 0, ?Source $source = null, ?\Exception $prev = null): string {
/*TODO: add a @jquery magic feature ?
if ($file == '@jquery') {
#header('Content-Type: text/plain');
//Check that we have a / separator between bundle name and path
if (($pos = strpos($file, '/')) === false) {
- throw new Error(sprintf('Invalid path "%s"', $file), $token->getLine(), $stream->getSourceContext());
+ throw new Error(sprintf('Invalid path "%s"', $file), $lineno, $source);
}
//Set bundle
//Resolve bundle prefix
try {
$prefix = $this->locator->locate($bundle);
- //Catch bundle does not exist or is not enabled exception
+ //Catch bundle does not exist or is not enabled exception
} catch(\InvalidArgumentException $e) {
//Fix lowercase first bundle character
if ($bundle[1] > 'Z' || $bundle[1] < 'A') {
//Catch bundle does not exist or is not enabled exception again
} catch(\InvalidArgumentException $e) {
//Bail out as bundle or path is invalid and we have no way to know what was meant
- throw new Error(sprintf('Invalid bundle name "%s" in path "%s". Maybe you meant "%s"', substr($file, 1, $pos - 1), $file, $bundle.'/'.$path), $token->getLine(), $stream->getSourceContext(), $e);
+ throw new Error(sprintf('Invalid bundle name "%s" in path "%s". Maybe you meant "%s"', substr($file, 1, $pos - 1), $file, $bundle.'/'.$path), $lineno, $source, $e);
}
}
+Contribute
+==========
+
+You may buy me a Beer, a Tea or help with Server fees with a paypal donation to
+the address <paypal@rapsys.eu>.
+
+Don't forget to show your love for this project, feel free to report bugs to
+the author, issues which are security relevant should be disclosed privately
+first.
+
+Patches are welcomed and grant credit when requested.
+
Installation
============
```json
{
- ...,
- "repositories": [
- {
- "type": "package",
- "package": {
- "name": "rapsys/packbundle",
- "version": "dev-master",
- "source": {
- "type": "git",
- "url": "https://git.rapsys.eu/packbundle",
- "reference": "master"
- },
- "autoload": {
- "psr-4": {
- "Rapsys\\PackBundle\\": ""
- }
- }
- }
- }
- ],
- ...
+ ...,
+ "repositories": [
+ {
+ "type": "package",
+ "package": {
+ "name": "rapsys/packbundle",
+ "version": "dev-master",
+ "source": {
+ "type": "git",
+ "url": "https://git.rapsys.eu/packbundle",
+ "reference": "master"
+ },
+ "autoload": {
+ "psr-4": {
+ "Rapsys\\PackBundle\\": ""
+ }
+ },
+ "require": {
+ "symfony/asset": "^4.0|^5.0|^6.0|^7.0",
+ "symfony/flex": "^1.0|^2.0",
+ "symfony/framework-bundle": "^4.0|^5.0|^6.0|^7.0",
+ "symfony/process": "^4.0|^5.0|^6.0|^7.0",
+ "symfony/twig-bundle": "^4.0|^5.0|^6.0|^7.0"
+ }
+ }
+ }
+ ],
+ ...
}
```
// ...
class AppKernel extends Kernel
{
- public function registerBundles()
- {
- $bundles = array(
- // ...
- new Rapsys\PackBundle\RapsysPackBundle(),
- );
+ public function registerBundles()
+ {
+ $bundles = array(
+ // ...
+ new Rapsys\PackBundle\RapsysPackBundle(),
+ );
- // ...
- }
+ // ...
+ }
- // ...
+ // ...
}
```
### Step 3: Configure the Bundle
-Verify that you have the configuration file `config/packages/rapsys_pack.yaml`
-with the following content:
+Setup configuration file `config/packages/rapsys_pack.yaml` with the following
+content available in `Resources/config/packages/rapsys_pack.yaml`:
```yaml
#Services configuration
services:
+ #Replace assets.packages definition
+ assets.packages:
+ class: 'Symfony\Component\Asset\Packages'
+ arguments: [ '@rapsys_pack.path_package' ]
+ #Replace assets.context definition
+ assets.context:
+ class: 'Rapsys\PackBundle\Context\RequestStackContext'
+ arguments: [ '@request_stack', '%asset.request_context.base_path%', '%asset.request_context.secure%' ]
#Register assets pack package
- assets.pack_package:
- class: Rapsys\PackBundle\Asset\PathPackage
+ rapsys_pack.path_package:
+ class: 'Rapsys\PackBundle\Package\PathPackage'
arguments: [ '/', '@assets.empty_version_strategy', '@assets.context' ]
+ public: true
#Register twig pack extension
- rapsys_pack.twig.pack_extension:
- class: Rapsys\PackBundle\Twig\PackExtension
- arguments: [ '@file_locator', '@service_container', '@assets.pack_package' ]
- tags: [ twig.extension ]
+ rapsys_pack.pack_extension:
+ class: 'Rapsys\PackBundle\Extension\PackExtension'
+ arguments: [ '@service_container', '@rapsys_pack.intl_util', '@file_locator', '@rapsys_pack.path_package', '@rapsys_pack.slugger_util' ]
+ tags: [ 'twig.extension' ]
+ #Register intl util service
+ rapsys_pack.intl_util:
+ class: 'Rapsys\PackBundle\Util\IntlUtil'
+ public: true
+ #Register facebook event subscriber
+ Rapsys\PackBundle\Subscriber\FacebookSubscriber:
+ arguments: [ '@router', [] ]
+ tags: [ 'kernel.event_subscriber' ]
+ #Register intl util class alias
+ Rapsys\PackBundle\Util\IntlUtil:
+ alias: 'rapsys_pack.intl_util'
+ #Register facebook util service
+ rapsys_pack.facebook_util:
+ class: 'Rapsys\PackBundle\Util\FacebookUtil'
+ arguments: [ '@router', '%kernel.project_dir%/var/cache', '%rapsys_pack.path%' ]
+ public: true
+ #Register facebook util class alias
+ Rapsys\PackBundle\Util\FacebookUtil:
+ alias: 'rapsys_pack.facebook_util'
+ #Register image util service
+ rapsys_pack.image_util:
+ class: 'Rapsys\PackBundle\Util\ImageUtil'
+ arguments: [ '@router', '@rapsys_pack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsys_pack.path%' ]
+ public: true
+ #Register image util class alias
+ Rapsys\PackBundle\Util\ImageUtil:
+ alias: 'rapsys_pack.image_util'
+ #Register map util service
+ rapsys_pack.map_util:
+ class: 'Rapsys\PackBundle\Util\MapUtil'
+ arguments: [ '@router', '@rapsys_pack.slugger_util' ]
+ public: true
+ #Register map util class alias
+ Rapsys\PackBundle\Util\MapUtil:
+ alias: 'rapsys_pack.map_util'
+ #Register slugger util service
+ rapsys_pack.slugger_util:
+ class: 'Rapsys\PackBundle\Util\SluggerUtil'
+ arguments: [ '%kernel.secret%' ]
+ public: true
+ #Register slugger util class alias
+ Rapsys\PackBundle\Util\SluggerUtil:
+ alias: 'rapsys_pack.slugger_util'
+ #Register image controller
+ Rapsys\PackBundle\Controller\ImageController:
+ arguments: [ '@service_container', '@rapsys_pack.image_util', '@rapsys_pack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsys_pack.path%' ]
+ tags: [ 'controller.service_arguments' ]
+ #Register map controller
+ Rapsys\PackBundle\Controller\MapController:
+ arguments: [ '@service_container', '@rapsys_pack.map_util', '@rapsys_pack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsys_pack.path%' ]
+ tags: [ 'controller.service_arguments' ]
+ Rapsys\PackBundle\Form\CaptchaType:
+ arguments: [ '@rapsys_pack.image_util', '@rapsys_pack.slugger_util', '@translator' ]
+ tags: [ 'form.type' ]
```
-Open a command console, enter your project directory and execute the
-following command to see default bundle configuration:
+Setup configuration file `config/packages/myproject.yaml` with the following
+content available in `Resources/config/packages/rapsys_pack.yaml`:
+
+```yaml
+#Services configuration
+services:
+ #Register facebook event subscriber
+ Rapsys\PackBundle\Subscriber\FacebookSubscriber:
+ arguments: [ '@router', [ 'en', 'en_gb', 'en_us', 'fr', 'fr_fr' ] ]
+ tags: [ 'kernel.event_subscriber' ]
+ #Register facebook util service
+ rapsys_blog.facebook_util:
+ class: 'Rapsys\PackBundle\Util\FacebookUtil'
+ arguments: [ '@router', '%kernel.project_dir%/var/cache', '%rapsys_pack.path%', 'facebook', '%kernel.project_dir%/public/png/facebook.png' ]
+ public: true
+```
+
+Open a command console, enter your project directory and execute the following
+command to see default bundle configuration:
```console
-$ php bin/console config:dump-reference RapsysPackBundle
+$ bin/console config:dump-reference RapsysPackBundle
```
-Open a command console, enter your project directory and execute the
-following command to see current bundle configuration:
+Open a command console, enter your project directory and execute the following
+command to see current bundle configuration:
```console
-$ php bin/console debug:config RapsysPackBundle
+$ bin/console debug:config RapsysPackBundle
```
### Step 4: Use the twig extension in your Template
-You can use a template like this to generate your first `rapsys_pack` enabled template:
+You can use a template like this to generate your first `rapsys_pack` enabled
+template:
```twig
<!DOCTYPE html>
```php
<?php
-namespace Rapsys\PackBundle\Twig\Filter;
+namespace Rapsys\PackBundle\Filter;
-use Rapsys\PackBundle\Twig\Filter\FilterInterface;
use Twig\Error\Error;
//This class will be defined in the parameter rapsys_pack.filters.(css|img|js).[x].class string
class MyPackFilter implements FilterInterface {
- //The constructor arguments ... will be replaced defined in the parameter rapsys_pack.filters.(css|img|js).[x].args array
- public function __construct($fileName, $line, $bin = 'mypack', ...) {
+ //The constructor arguments ... will be replaced with values defined in the parameter rapsys_pack.filters.(css|img|js).[x].args array
+ public function __construct(string $fileName, int $line, string $bin = 'mypack', ...) {
//Set fileName
$this->fileName = $fileName;
}
//Pass merge of all inputs in content
- public function process($content) {
+ public function process(string $content): string {
//Create descriptors
$descriptorSpec = array(
0 => array('pipe', 'r'),
//Open process
if (is_resource($proc = proc_open($this->bin, $descriptorSpec, $pipes))) {
//Set stderr as non blocking
- stream_set_blocking($pipes[2], 0);
+ stream_set_blocking($pipes[2], false);
//Send content to stdin
fwrite($pipes[0], $content);
}
```
-The class is required to get it's arguments through constructor and have a process method.
+The class must implements FilterInterface and get it's arguments through constructor.
-<?php
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
namespace Rapsys\PackBundle;
+use Rapsys\PackBundle\DependencyInjection\RapsysPackExtension;
+
+use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\HttpKernel\Bundle\Bundle;
-class RapsysPackBundle extends Bundle {}
+/**
+ * {@inheritdoc}
+ */
+class RapsysPackBundle extends Bundle {
+ /**
+ * {@inheritdoc}
+ */
+ public function getContainerExtension(): ?ExtensionInterface {
+ //Return created container extension
+ return $this->createContainerExtension();
+ }
+
+ /**
+ * Return bundle alias
+ *
+ * @return string The bundle alias
+ */
+ public static function getAlias(): string {
+ //With namespace
+ if ($npos = strrpos(static::class, '\\')) {
+ //Set name pos
+ $npos++;
+ //Without namespace
+ } else {
+ $npos = 0;
+ }
+
+ //With trailing bundle
+ if (substr(static::class, -strlen('Bundle'), strlen('Bundle')) === 'Bundle') {
+ //Set bundle pos
+ $bpos = strlen(static::class) - $npos - strlen('Bundle');
+ //Without bundle
+ } else {
+ //Set bundle pos
+ $bpos = strlen(static::class) - $npos;
+ }
+
+ //Return lowercase bundle alias
+ return strtolower(substr(static::class, $npos, $bpos));
+ }
+
+ /**
+ * Return bundle version
+ *
+ * @return string The bundle version
+ */
+ public static function getVersion(): string {
+ //Return version
+ return '0.5.0';
+ }
+}
+++ /dev/null
-#Services configuration
-services:
- #Register assets pack package
- rapsys.path_package:
- class: 'Rapsys\PackBundle\Asset\PathPackage'
- arguments: [ '/', '@assets.empty_version_strategy', '@assets.context' ]
- #Register twig pack extension
- rapsys.pack_extension:
- class: 'Rapsys\PackBundle\Twig\PackExtension'
- arguments: [ '@file_locator', '@service_container', '@rapsys.path_package' ]
- tags: [ 'twig.extension' ]
--- /dev/null
+# Parameters configuration
+parameters:
+ # User agent
+ env(RAPSYSPACK_AGENT): "rapsyspack/Ch4ng3m3!"
+ # Shuffled printable character range
+ env(RAPSYSPACK_RANGE): 'Ch4ng3m3!'
+ # Redirect
+ env(RAPSYSPACK_REDIRECT): 20
+ # Scheme
+ env(RAPSYSPACK_SCHEME): "https"
+ # Timeout
+ env(RAPSYSPACK_TIMEOUT): 60
+
+# Services configuration
+services:
+ # Replace assets.context definition
+ assets.context:
+ class: 'Rapsys\PackBundle\Context\RequestStackContext'
+ arguments: [ '@request_stack', '%asset.request_context.base_path%', '%asset.request_context.secure%' ]
+ # Replace assets.packages definition
+ assets.packages:
+ class: 'Symfony\Component\Asset\Packages'
+ arguments: [ '@rapsyspack.path_package' ]
+ # Register facebook util service
+ rapsyspack.facebook_util:
+ class: 'Rapsys\PackBundle\Util\FacebookUtil'
+ arguments: [ '@router', '%kernel.project_dir%/var/cache', '%rapsyspack.path%' ]
+ public: true
+ # Register image util service
+ rapsyspack.image_util:
+ class: 'Rapsys\PackBundle\Util\ImageUtil'
+ arguments: [ '@router', '@rapsyspack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsyspack.path%' ]
+ public: true
+ # Register intl util service
+ rapsyspack.intl_util:
+ class: 'Rapsys\PackBundle\Util\IntlUtil'
+ public: true
+ # Register map util service
+ rapsyspack.map_util:
+ class: 'Rapsys\PackBundle\Util\MapUtil'
+ arguments: [ '@router', '@rapsyspack.slugger_util' ]
+ public: true
+ # Register twig pack extension
+ rapsyspack.pack_extension:
+ class: 'Rapsys\PackBundle\Extension\PackExtension'
+ arguments: [ '@rapsyspack.intl_util', '@file_locator', '@rapsyspack.path_package', '@rapsyspack.slugger_util', '%rapsyspack%' ]
+ tags: [ 'twig.extension' ]
+ # Register assets pack package
+ rapsyspack.path_package:
+ class: 'Rapsys\PackBundle\Package\PathPackage'
+ arguments: [ '/', '@assets.empty_version_strategy', '@assets.context' ]
+ public: true
+ # Register slugger util service
+ rapsyspack.slugger_util:
+ class: 'Rapsys\PackBundle\Util\SluggerUtil'
+ arguments: [ '%kernel.secret%' ]
+ public: true
+ # Register range command
+ Rapsys\PackBundle\Command\RangeCommand:
+ arguments: [ '%kernel.project_dir%/.env.local' ]
+ tags: [ 'console.command' ]
+ # Register image controller
+ Rapsys\PackBundle\Controller\ImageController:
+ arguments: [ '@service_container', '@rapsyspack.image_util', '@rapsyspack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsyspack.path%' ]
+ tags: [ 'controller.service_arguments' ]
+ # Register map controller
+ Rapsys\PackBundle\Controller\MapController:
+ arguments: [ '@service_container', '@rapsyspack.map_util', '@rapsyspack.slugger_util', '%kernel.project_dir%/var/cache', '%rapsyspack.path%' ]
+ tags: [ 'controller.service_arguments' ]
+ # Register captcha form type
+ Rapsys\PackBundle\Form\CaptchaType:
+ arguments: [ '@rapsyspack.image_util', '@rapsyspack.slugger_util', '@translator' ]
+ tags: [ 'form.type' ]
+ # Register facebook event subscriber
+ Rapsys\PackBundle\Subscriber\FacebookSubscriber:
+ arguments: [ '@router', [] ]
+ tags: [ 'kernel.event_subscriber' ]
+ # Register facebook util class alias
+ Rapsys\PackBundle\Util\FacebookUtil:
+ alias: 'rapsyspack.facebook_util'
+ # Register image util class alias
+ Rapsys\PackBundle\Util\ImageUtil:
+ alias: 'rapsyspack.image_util'
+ # Register intl util class alias
+ Rapsys\PackBundle\Util\IntlUtil:
+ alias: 'rapsyspack.intl_util'
+ # Register map util class alias
+ Rapsys\PackBundle\Util\MapUtil:
+ alias: 'rapsyspack.map_util'
+ # Register slugger util class alias
+ Rapsys\PackBundle\Util\SluggerUtil:
+ alias: 'rapsyspack.slugger_util'
--- /dev/null
+#Routes configuration
+rapsyspack_captcha:
+ path: '/captcha/{hash<[a-zA-Z0-9=_-]+>}/{updated<\d+>}/{equation<[a-zA-Z0-9=_-]+>}/{width<\d+>?120}/{height<\d+>?36}.{!_format?jpeg}'
+ controller: Rapsys\PackBundle\Controller\ImageController::captcha
+ methods: GET
+
+#TODO: replace this url with a redirection route ???
+#XXX: we don't need the mtime, maybe we can drop it in this redirect instead of apache ?
+rapsyspack_facebook:
+ path: '/bundles/rapsyspack/facebook/{mtime<\d+>}{path</.*>}.{!_format?jpeg}'
+ methods: GET
+
+rapsyspack_map:
+ path: '/map/{hash<[a-zA-Z0-9=_-]+>}/{updated<\d+>}/{latitude<\d+(\.?\d+)?>}/{longitude<\d+(\.?\d+)?>}/{zoom<\d+>?17}/{width<\d+>?640}/{height<\d+>?640}.{!_format?jpeg}'
+ controller: Rapsys\PackBundle\Controller\MapController::map
+ methods: GET
+
+rapsyspack_multimap:
+ path: '/multimap/{hash<[a-zA-Z0-9=_-]+>}/{updated<\d+>}/{latitude<\d+(\.?\d+)?>}/{longitude<\d+(\.?\d+)?>}/{coordinates<(?:\d+(\.\d+)?,\d+(\.\d+)?(-\d+(\.\d+)?,\d+(\.\d+)?)*)?>}/{zoom<\d+>?15}/{width<\d+>?640}/{height<\d+>?640}.{!_format?jpeg}'
+ controller: Rapsys\PackBundle\Controller\MapController::multimap
+ methods: GET
+
+rapsyspack_thumb:
+ path: '/thumb/{hash<[a-zA-Z0-9=_-]+>}/{updated<\d+>}/{path<[a-zA-Z0-9=_-]+>}/{width<\d+>?640}/{height<\d+>?640}.{!_format?jpeg}'
+ controller: Rapsys\PackBundle\Controller\ImageController::thumb
+ methods: GET
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Subscriber;
+
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\Routing\RouterInterface;
+
+/**
+ * {@inheritdoc}
+ */
+class FacebookSubscriber implements EventSubscriberInterface {
+ /*
+ * Inject router interface and locales
+ *
+ * @param RouterInterface $router The router instance
+ * @param array $locales The supported locales
+ */
+ public function __construct(protected RouterInterface $router, protected array $locales) {
+ }
+
+ /**
+ * Change locale for request with ?fb_locale=xx
+ *
+ * @param RequestEvent The request event
+ */
+ public function onKernelRequest(RequestEvent $event): void {
+ //Without main request
+ if (!$event->isMainRequest()) {
+ return;
+ }
+
+ //Retrieve request
+ $request = $event->getRequest();
+
+ //Check for facebook locale
+ if (
+ $request->query->has('fb_locale') &&
+ in_array($locale = $request->query->get('fb_locale'), $this->locales)
+ ) {
+ //Set locale
+ $request->setLocale($locale);
+
+ //Set default locale
+ $request->setDefaultLocale($locale);
+
+ //Get router context
+ $context = $this->router->getContext();
+
+ //Set context locale
+ $context->setParameter('_locale', $locale);
+
+ //Set back router context
+ $this->router->setContext($context);
+ }
+ }
+
+ /**
+ * Get subscribed events
+ *
+ * @return array The subscribed events
+ */
+ public static function getSubscribedEvents(): array {
+ return [
+ // must be registered before the default locale listener
+ KernelEvents::REQUEST => [['onKernelRequest', 10]]
+ ];
+ }
+}
+++ /dev/null
-<?php
-
-// src/Rapsys/PackBundle/Twig/Filter/FilterInterface.php
-namespace Rapsys\PackBundle\Twig\Filter;
-
-interface FilterInterface {
- //TODO: see if we need something else (like a constructor that read parameters or something else ?)
- public function process($content);
-}
+++ /dev/null
-<?php
-// src/Rapsys/PackBundle/Twig/PackExtension.php
-namespace Rapsys\PackBundle\Twig;
-
-use Symfony\Component\HttpKernel\Config\FileLocator;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-use Symfony\Component\Asset\PackageInterface;
-use Twig\Extension\AbstractExtension;
-
-class PackExtension extends AbstractExtension {
- //The config
- private $config;
-
- //The output
- private $output;
-
- //The filter
- private $filters;
-
- //The file locator
- protected $locator;
-
- //The assets package
- protected $package;
-
- public function __construct(FileLocator $locator, ContainerInterface $container, PackageInterface $package) {
- //Set file locator
- $this->locator = $locator;
-
- //Set assets packages
- $this->package = $package;
-
- //Retrieve bundle config
- if ($parameters = $container->getParameter($this->getAlias())) {
- //Set config, output and filters arrays
- foreach(['config', 'output', 'filters'] as $k) {
- $this->$k = $parameters[$k];
- }
- }
- }
-
- public function getTokenParsers() {
- return [
- new PackTokenParser($this->locator, $this->package, $this->config, 'stylesheet', $this->output['css'], $this->filters['css']),
- new PackTokenParser($this->locator, $this->package, $this->config, 'javascript', $this->output['js'], $this->filters['js']),
- new PackTokenParser($this->locator, $this->package, $this->config, 'image', $this->output['img'], $this->filters['img'])
- ];
- }
-
- /**
- * {@inheritdoc}
- */
- public function getAlias() {
- return 'rapsys_pack';
- }
-}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Util;
+
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Routing\RouterInterface;
+
+/**
+ * Helps manage facebook images
+ */
+class FacebookUtil {
+ /**
+ * The default fonts
+ */
+ const fonts = [ 'default' => 'ttf/default.ttf' ];
+
+ /**
+ * The default font
+ */
+ const font = 'default';
+
+ /**
+ * The default font size
+ */
+ const size = 60;
+
+ /**
+ * The default width
+ */
+ const width = 15;
+
+ /**
+ * The default fill
+ */
+ const fill = 'white';
+
+ /**
+ * The default stroke
+ */
+ const stroke = '#00c3f9';
+
+ /**
+ * The default align
+ */
+ const align = 'center';
+
+ /**
+ * Creates a new facebook util
+ *
+ * @param RouterInterface $router The RouterInterface instance
+ * @param string $cache The cache directory
+ * @param string $path The public path
+ * @param string $prefix The prefix
+ * @param ?string $source The source
+ * @param array $fonts The fonts
+ * @param string $font The font
+ * @param int $size The size
+ * @param int $width The width
+ * @param string $fill The fill
+ * @param string $stroke The stroke
+ * @param string $align The align
+ */
+ function __construct(protected RouterInterface $router, protected string $cache = '../var/cache', protected string $path = './bundles/rapsyspack', protected string $prefix = 'facebook', protected ?string $source = null, protected array $fonts = self::fonts, protected string $font = self::font, protected int $size = self::size, protected int $width = self::width, protected string $fill = self::fill, protected string $stroke = self::stroke, protected string $align = self::align) {
+ }
+
+ /**
+ * Return the facebook image
+ *
+ * Generate simple image in jpeg format or load it from cache
+ *
+ * @param string $pathInfo The request path info
+ * @param array $texts The image texts
+ * @param int $updated The updated timestamp
+ * @param ?string $source The image source
+ * @param int $width The width
+ * @param int $height The height
+ * @return array The image array
+ */
+ public function getImage(string $pathInfo, array $texts, int $updated, ?string $source = null, int $width = 1200, int $height = 630): array {
+ //Without source
+ if ($source === null && $this->source === null) {
+ //Return empty image data
+ return [];
+ //Without local source
+ } elseif ($source === null) {
+ //Set local source
+ $source = $this->source;
+ }
+
+ //Set path file
+ $path = $this->path.'/'.$this->prefix.$pathInfo.'.jpeg';
+
+ //Without existing path
+ if (!is_dir($dir = dirname($path))) {
+ //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);
+ }
+ }
+
+ //With path file
+ if (is_file($path) && ($mtime = stat($path)['mtime']) && $mtime >= $updated) {
+ #XXX: we used to drop texts with $data['canonical'] === true !!!
+
+ //Return image data
+ return [
+ 'og:image' => $this->router->generate('rapsyspack_facebook', ['mtime' => $mtime, 'path' => $pathInfo], 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->cache.'/'.$this->prefix.$pathInfo.'.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', $this->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->fonts[$data['font']??$this->font]);
+
+ //Set font size
+ $draw->setFontSize($data['size']??$this->size);
+
+ //Set stroke width
+ $draw->setStrokeWidth($data['width']??$this->width);
+
+ //Set text alignment
+ $draw->setTextAlignment($align = ($aligns[$data['align']??$this->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['stroke']??$this->stroke));
+
+ //Set fill color
+ $draw->setFillColor(new \ImagickPixel($data['stroke']??$this->stroke));
+
+ //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->fonts[$data['font']??$this->font]);
+
+ //Set font size
+ $draw->setFontSize($data['size']??$this->size);
+
+ //Set text alignment
+ $draw->setTextAlignment($aligns[$data['align']??$this->align]);
+
+ //Set fill color
+ $draw->setFillColor(new \ImagickPixel($data['fill']??$this->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($path)) {
+ //Throw error
+ throw new \Exception(sprintf('Unable to write image "%s"', $path));
+ }
+
+ //Return image data
+ return [
+ 'og:image' => $this->router->generate('rapsyspack_facebook', ['mtime' => stat($path)['mtime'], 'path' => $pathInfo], UrlGeneratorInterface::ABSOLUTE_URL),
+ 'og:image:alt' => str_replace("\n", ' ', implode(' - ', array_keys($texts))),
+ 'og:image:height' => $height,
+ 'og:image:width' => $width
+ ];
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Util;
+
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Routing\RouterInterface;
+
+/**
+ * Manages image
+ */
+class ImageUtil {
+ /**
+ * The captcha width
+ */
+ const width = 192;
+
+ /**
+ * The captcha height
+ */
+ const height = 52;
+
+ /**
+ * The captcha background color
+ */
+ const background = 'white';
+
+ /**
+ * The captcha fill color
+ */
+ const fill = '#cff';
+
+ /**
+ * The captcha font size
+ */
+ const fontSize = 45;
+
+ /**
+ * The captcha stroke color
+ */
+ const stroke = '#00c3f9';
+
+ /**
+ * The captcha stroke width
+ */
+ const strokeWidth = 2;
+
+ /**
+ * The thumb width
+ */
+ const thumbWidth = 640;
+
+ /**
+ * The thumb height
+ */
+ const thumbHeight = 640;
+
+ /**
+ * 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(protected RouterInterface $router, protected SluggerUtil $slugger, protected string $cache = '../var/cache', protected string $path = './bundles/rapsyspack', protected string $prefix = 'image', protected string $background = self::background, protected string $fill = self::fill, protected int $fontSize = self::fontSize, protected string $stroke = self::stroke, protected int $strokeWidth = self::strokeWidth) {
+ }
+
+ /**
+ * Get captcha data
+ *
+ * @param int $updated The updated timestamp
+ * @param int $width The width
+ * @param int $height The height
+ * @return array The captcha data
+ */
+ public function getCaptcha(int $updated, int $width = self::width, int $height = self::height): array {
+ //Set a
+ $a = rand(0, 9);
+
+ //Set b
+ $b = rand(0, 5);
+
+ //Set c
+ $c = rand(0, 9);
+
+ //Set equation
+ $equation = $a.' * '.$b.' + '.$c;
+
+ //Short path
+ $short = $this->slugger->short($equation);
+
+ //Set hash
+ $hash = $this->slugger->serialize([$updated, $short, $width, $height]);
+
+ //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, 'updated' => $updated, 'equation' => $short, 'width' => $width, 'height' => $height]),
+ 'width' => $width,
+ 'height' => $height
+ ];
+ }
+
+ /**
+ * 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
+ * @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);
+
+ //Short path
+ $short = $this->slugger->short($path);
+
+ //Set link hash
+ $link = $this->slugger->serialize([$updated, $short, $imageWidth, $imageHeight]);
+
+ //Set src hash
+ $src = $this->slugger->serialize([$updated, $short, $width, $height]);
+
+ //Return array
+ return [
+ 'caption' => $caption,
+ 'link' => $this->router->generate('rapsyspack_thumb', ['hash' => $link, 'updated' => $updated, 'path' => $short, 'width' => $imageWidth, 'height' => $imageHeight]),
+ 'src' => $this->router->generate('rapsyspack_thumb', ['hash' => $src, 'updated' => $updated, 'path' => $short, 'width' => $width, 'height' => $height]),
+ 'width' => $width,
+ 'height' => $height
+ ];
+ }
+
+ /**
+ * Get captcha background color
+ */
+ public function getBackground() {
+ return $this->background;
+ }
+
+ /**
+ * Get captcha fill color
+ */
+ public function getFill() {
+ return $this->fill;
+ }
+
+ /**
+ * Get captcha font size
+ */
+ public function getFontSize() {
+ return $this->fontSize;
+ }
+
+ /**
+ * Get captcha stroke color
+ */
+ public function getStroke() {
+ return $this->stroke;
+ }
+
+ /**
+ * Get captcha stroke width
+ */
+ public function getStrokeWidth() {
+ return $this->strokeWidth;
+ }
+
+ /**
+ * Remove image
+ *
+ * @param int $updated The updated timestamp
+ * @param string $path The path
+ * @return array The thumb clear success
+ */
+ public function remove(int $updated, string $path): bool {
+ //Set hash tree
+ $hash = array_reverse(str_split(strval($updated)));
+
+ //Set dir
+ $dir = $this->path.'/'.$this->prefix.'/'.$hash[0].'/'.$hash[1].'/'.$hash[2].'/'.$updated.'/'.$this->slugger->short($path);
+
+ //Set removes
+ $removes = [];
+
+ //With dir
+ if (is_dir($dir)) {
+ //Add tree to remove
+ $removes[] = $dir;
+
+ //Iterate on each file
+ foreach(array_merge($removes, array_diff(scandir($dir), ['..', '.'])) as $file) {
+ //With file
+ if (is_file($dir.'/'.$file)) {
+ //Add file to remove
+ $removes[] = $dir.'/'.$file;
+ }
+ }
+ }
+
+ //Create filesystem object
+ $filesystem = new Filesystem();
+
+ try {
+ //Remove list
+ $filesystem->remove($removes);
+ } catch (IOExceptionInterface $e) {
+ //Throw error
+ throw new \Exception(sprintf('Unable to delete thumb directory "%s"', $dir), 0, $e);
+ }
+
+ //Return success
+ return true;
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Util;
+
+use Twig\Error\SyntaxError;
+use Twig\Environment;
+
+/**
+ * Manages intl conversions
+ */
+class IntlUtil {
+ /**
+ * Format date
+ */
+ public function date(Environment $env, \DateTime $date, string $dateFormat = 'medium', string $timeFormat = 'medium', ?string $locale = null, \IntlTimeZone|\DateTimeZone|string|null $timezone = null, ?string $calendar = null, ?string $pattern = null) {
+ //Get converted date
+ $date = twig_date_converter($env, $date, $timezone);
+
+ //Set date and time formatters
+ $formatters = [
+ 'none' => \IntlDateFormatter::NONE,
+ 'short' => \IntlDateFormatter::SHORT,
+ 'medium' => \IntlDateFormatter::MEDIUM,
+ 'long' => \IntlDateFormatter::LONG,
+ 'full' => \IntlDateFormatter::FULL,
+ ];
+
+ //Get formatter
+ $formatter = \IntlDateFormatter::create(
+ $locale,
+ $formatters[$dateFormat],
+ $formatters[$timeFormat],
+ \IntlTimeZone::createTimeZone($date->getTimezone()->getName()),
+ 'traditional' === $calendar ? \IntlDateFormatter::TRADITIONAL : \IntlDateFormatter::GREGORIAN,
+ $pattern
+ );
+
+ //Return formatted date
+ return $formatter->format($date->getTimestamp());
+ }
+
+ /**
+ * Format number
+ */
+ public function number(int|float $number, $style = 'decimal', $type = 'default', ?string $locale = null) {
+ //Set types
+ static $types = [
+ 'default' => NumberFormatter::TYPE_DEFAULT,
+ 'int32' => NumberFormatter::TYPE_INT32,
+ 'int64' => NumberFormatter::TYPE_INT64,
+ 'double' => NumberFormatter::TYPE_DOUBLE,
+ 'currency' => NumberFormatter::TYPE_CURRENCY,
+ ];
+
+ //Get formatter
+ $formatter = $this->getNumberFormatter($locale, $style);
+
+ //Without type
+ if (!isset($types[$type])) {
+ throw new SyntaxError(sprintf('The type "%s" does not exist. Known types are: "%s"', $type, implode('", "', array_keys($types))));
+ }
+
+ //Return formatted number
+ return $formatter->format($number, $types[$type]);
+ }
+
+ /**
+ * Format currency
+ */
+ public function currency(int|float $number, string $currency, ?string $locale = null) {
+ //Get formatter
+ $formatter = $this->getNumberFormatter($locale, 'currency');
+
+ //Return formatted currency
+ return $formatter->formatCurrency($number, $currency);
+ }
+
+ /**
+ * Compute eastern for selected year
+ *
+ * @param string $year The eastern year
+ *
+ * @return DateTime The eastern date
+ */
+ public function getEastern(string $year): \DateTime {
+ //Set static results
+ static $results = [];
+
+ //Check if already computed
+ if (isset($results[$year])) {
+ //Return computed eastern
+ return $results[$year];
+ }
+
+ $d = (19 * ($year % 19) + 24) % 30;
+
+ $e = (2 * ($year % 4) + 4 * ($year % 7) + 6 * $d + 5) % 7;
+
+ $day = 22 + $d + $e;
+
+ $month = 3;
+
+ if ($day > 31) {
+ $day = $d + $e - 9;
+ $month = 4;
+ } elseif ($d == 29 && $e == 6) {
+ $day = 10;
+ $month = 4;
+ } elseif ($d == 28 && $e == 6) {
+ $day = 18;
+ $month = 4;
+ }
+
+ //Store eastern in data
+ return ($results[$year] = new \DateTime(sprintf('%04d-%02d-%02d', $year, $month, $day)));
+ }
+
+ /**
+ * Gets number formatter instance matching locale and style.
+ *
+ * @param ?string $locale Locale in which the number would be formatted
+ * @param string $style Style of the formatting
+ *
+ * @return NumberFormatter A NumberFormatter instance
+ */
+ protected function getNumberFormatter(?string $locale, string $style): \NumberFormatter {
+ //Set static formatters
+ static $formatters = [];
+
+ //Set locale
+ $locale = null !== $locale ? $locale : Locale::getDefault();
+
+ //With existing formatter
+ if (isset($formatters[$locale][$style])) {
+ //Return the instance from previous call
+ return $formatters[$locale][$style];
+ }
+
+ //Set styles
+ static $styles = [
+ 'decimal' => \NumberFormatter::DECIMAL,
+ 'currency' => \NumberFormatter::CURRENCY,
+ 'percent' => \NumberFormatter::PERCENT,
+ 'scientific' => \NumberFormatter::SCIENTIFIC,
+ 'spellout' => \NumberFormatter::SPELLOUT,
+ 'ordinal' => \NumberFormatter::ORDINAL,
+ 'duration' => \NumberFormatter::DURATION,
+ ];
+
+ //Without styles
+ if (!isset($styles[$style])) {
+ throw new SyntaxError(sprintf('The style "%s" does not exist. Known styles are: "%s"', $style, implode('", "', array_keys($styleValues))));
+ }
+
+ //Return number formatter
+ return ($formatters[$locale][$style] = \NumberFormatter::create($locale, $styles[$style]));
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Util;
+
+use Symfony\Component\Routing\RouterInterface;
+
+/**
+ * Manages map
+ */
+class MapUtil {
+ /**
+ * The cycle tile server
+ *
+ * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
+ */
+ const cycle = 'http://a.tile.thunderforest.com/cycle/{Z}/{X}/{Y}.png';
+
+ /**
+ * The fill color
+ */
+ const fill = '#cff';
+
+ /**
+ * The font size
+ */
+ const fontSize = 20;
+
+ /**
+ * The high fill color
+ */
+ const highFill = '#c3c3f9';
+
+ /**
+ * The high font size
+ */
+ const highFontSize = 30;
+
+ /**
+ * The high radius size
+ */
+ const highRadius = 6;
+
+ /**
+ * The high stroke color
+ */
+ const highStroke = '#3333c3';
+
+ /**
+ * The high stroke width
+ */
+ const highStrokeWidth = 4;
+
+ /**
+ * The map width
+ */
+ const width = 640;
+
+ /**
+ * The map height
+ */
+ const height = 640;
+
+ /**
+ * The osm tile server
+ *
+ * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
+ */
+ const osm = 'https://tile.openstreetmap.org/{Z}/{X}/{Y}.png';
+
+ /**
+ * The radius size
+ */
+ const radius = 5;
+
+ /**
+ * The stroke color
+ */
+ const stroke = '#00c3f9';
+
+ /**
+ * The stroke width
+ */
+ const strokeWidth = 2;
+
+ /**
+ * The transport tile server
+ *
+ * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Tile_servers
+ */
+ const transport = 'http://a.tile.thunderforest.com/transport/{Z}/{X}/{Y}.png';
+
+ /**
+ * The tile size
+ */
+ const tz = 256;
+
+ /**
+ * The map zoom
+ */
+ const zoom = 17;
+
+ /**
+ * Creates a new map util
+ *
+ * @param RouterInterface $router The RouterInterface instance
+ * @param SluggerUtil $slugger The SluggerUtil instance
+ */
+ function __construct(protected RouterInterface $router, protected SluggerUtil $slugger, protected string $fill = self::fill, protected int $fontSize = self::fontSize, protected string $highFill = self::highFill, protected int $highFontSize = self::highFontSize, protected int $highRadius = self::highRadius, protected string $highStroke = self::highStroke, protected int $highStrokeWidth = self::highStrokeWidth, protected int $radius = self::radius, protected string $stroke = self::stroke, protected int $strokeWidth = self::strokeWidth) {
+ }
+
+ /**
+ * Get fill color
+ */
+ function getFill() {
+ return $this->fill;
+ }
+
+ /**
+ * Get font size
+ */
+ function getFontSize() {
+ return $this->fontSize;
+ }
+
+ /**
+ * Get high fill color
+ */
+ function getHighFill() {
+ return $this->highFill;
+ }
+
+ /**
+ * Get high font size
+ */
+ function getHighFontSize() {
+ return $this->highFontSize;
+ }
+
+ /**
+ * Get high radius size
+ */
+ function getHighRadius() {
+ return $this->highRadius;
+ }
+
+ /**
+ * Get high stroke color
+ */
+ function getHighStroke() {
+ return $this->highStroke;
+ }
+
+ /**
+ * Get high stroke width
+ */
+ function getHighStrokeWidth() {
+ return $this->highStrokeWidth;
+ }
+
+ /**
+ * Get radius size
+ */
+ function getRadius() {
+ return $this->radius;
+ }
+
+ /**
+ * Get stroke color
+ */
+ function getStroke() {
+ return $this->stroke;
+ }
+
+ /**
+ * Get stroke width
+ */
+ function getStrokeWidth() {
+ return $this->strokeWidth;
+ }
+
+ /**
+ * Get map data
+ *
+ * @param string $caption The caption
+ * @param int $updated The updated timestamp
+ * @param float $latitude The latitude
+ * @param float $longitude The longitude
+ * @param int $zoom The zoom
+ * @param int $width The width
+ * @param int $height The height
+ * @return array The map data
+ */
+ public function getMap(string $caption, int $updated, float $latitude, float $longitude, int $zoom = self::zoom, int $width = self::width, int $height = self::height): array {
+ //Set link hash
+ $link = $this->slugger->hash([$updated, $latitude, $longitude, $zoom + 1, $width * 2, $height * 2]);
+
+ //Set src hash
+ $src = $this->slugger->hash([$updated, $latitude, $longitude, $zoom, $width, $height]);
+
+ //Return array
+ return [
+ 'caption' => $caption,
+ 'link' => $this->router->generate('rapsyspack_map', ['hash' => $link, 'updated' => $updated, 'latitude' => $latitude, 'longitude' => $longitude, 'zoom' => $zoom + 1, 'width' => $width * 2, 'height' => $height * 2]),
+ 'src' => $this->router->generate('rapsyspack_map', ['hash' => $src, 'updated' => $updated, 'latitude' => $latitude, 'longitude' => $longitude, 'zoom' => $zoom, 'width' => $width, 'height' => $height]),
+ 'width' => $width,
+ 'height' => $height
+ ];
+ }
+
+ /**
+ * Get multi map data
+ *
+ * @param string $caption The caption
+ * @param int $updated The updated timestamp
+ * @param array $coordinates The coordinates array
+ * @param int $width The width
+ * @param int $height The height
+ * @return array The multi map data
+ */
+ public function getMultiMap(string $caption, int $updated, array $coordinates, int $width = self::width, int $height = self::height): array {
+ //Without coordinates
+ if (empty($coordinates)) {
+ //Return empty array
+ return [];
+ }
+
+ //Set latitudes
+ $latitudes = array_map(function ($v) { return $v['latitude']; }, $coordinates);
+
+ //Set longitudes
+ $longitudes = array_map(function ($v) { return $v['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->getMultiZoom($latitude, $longitude, $coordinates, $width, $height);
+
+ //Set coordinate
+ $coordinate = implode('-', array_map(function ($v) { return $v['latitude'].','.$v['longitude']; }, $coordinates));
+
+ //Set coordinate hash
+ $hash = $this->slugger->hash($coordinate);
+
+ //Set link hash
+ $link = $this->slugger->hash([$updated, $latitude, $longitude, $hash, $zoom + 1, $width * 2, $height * 2]);
+
+ //Set src hash
+ $src = $this->slugger->hash([$updated, $latitude, $longitude, $hash, $zoom, $width, $height]);
+
+ //Return array
+ return [
+ 'caption' => $caption,
+ 'link' => $this->router->generate('rapsyspack_multimap', ['hash' => $link, 'updated' => $updated, 'latitude' => $latitude, 'longitude' => $longitude, 'coordinates' => $coordinate, 'zoom' => $zoom + 1, 'width' => $width * 2, 'height' => $height * 2]),
+ 'src' => $this->router->generate('rapsyspack_multimap', ['hash' => $src, 'updated' => $updated, 'latitude' => $latitude, 'longitude' => $longitude, 'coordinates' => $coordinate, 'zoom' => $zoom, 'width' => $width, 'height' => $height]),
+ 'width' => $width,
+ 'height' => $height
+ ];
+ }
+
+ /**
+ * 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)
+ *
+ * @see Wether we need to take in consideration circle radius in coordinates comparisons, likely +/-(radius / self::tz)
+ *
+ * @param float $latitude The latitude
+ * @param float $longitude The longitude
+ * @param array $coordinates The coordinates array
+ * @param int $width The width
+ * @param int $height The height
+ * @param int $zoom The zoom
+ * @return int The zoom
+ */
+ public function getMultiZoom(float $latitude, float $longitude, array $coordinates, int $width, int $height, int $zoom = self::zoom): int {
+ //Iterate on each zoom
+ for ($i = $zoom; $i >= 1; $i--) {
+ //Get tile xy
+ $centerX = self::longitudeToX($longitude, $i);
+ $centerY = self::latitudeToY($latitude, $i);
+
+ //Calculate start xy
+ $startX = floor($centerX - $width / 2 / self::tz);
+ $startY = floor($centerY - $height / 2 / self::tz);
+
+ //Calculate end xy
+ $endX = ceil($centerX + $width / 2 / self::tz);
+ $endY = ceil($centerY + $height / 2 / self::tz);
+
+ //Iterate on each coordinates
+ foreach($coordinates as $k => $coordinate) {
+ //Set dest x
+ $destX = self::longitudeToX($coordinate['longitude'], $i);
+
+ //With outside point
+ if ($startX >= $destX || $endX <= $destX) {
+ //Skip zoom
+ continue(2);
+ }
+
+ //Set dest y
+ $destY = self::latitudeToY($coordinate['latitude'], $i);
+
+ //With outside point
+ if ($startY >= $destY || $endY <= $destY) {
+ //Skip zoom
+ continue(2);
+ }
+ }
+
+ //Found zoom
+ break;
+ }
+
+ //Return zoom
+ return $i;
+ }
+
+ /**
+ * 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 static 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 static 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 static 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 static 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 static 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 static 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');
+ }
+}
--- /dev/null
+<?php declare(strict_types=1);
+
+/*
+ * This file is part of the Rapsys PackBundle package.
+ *
+ * (c) Raphaël Gertz <symfony@rapsys.eu>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Rapsys\PackBundle\Util;
+
+/**
+ * Manages string conversions
+ */
+class SluggerUtil {
+ /**
+ * The alpha array
+ */
+ protected array $alpha;
+
+ /**
+ * The rev array
+ */
+ protected array $rev;
+
+ /**
+ * The alpha array key number
+ */
+ protected int $count;
+
+ /**
+ * The offset reduced from secret
+ */
+ protected int $offset;
+
+ /**
+ * Construct slugger util
+ *
+ * Run "bin/console rapsyspack:range" to generate RAPSYSPACK_RANGE="ayl[...]z9w" range in .env.local
+ *
+ * @todo Use Cache like in calendar controller through FilesystemAdapter ?
+ *
+ * @param string $secret The secret string
+ */
+ public function __construct(protected string $secret) {
+ //Without range
+ if (empty($range = $_ENV['RAPSYSPACK_RANGE']) || $range === 'Ch4ng3m3!') {
+ //Protect member variable setup
+ return;
+ }
+
+ /**
+ * Get pseuto-random alphabet by splitting range string
+ * TODO: see required range by json_encode result and short input (0->255 ???)
+ * XXX: The key count mismatch, count(alpha)>count(rev), resulted in a data corruption due to duplicate numeric values
+ */
+ $this->alpha = str_split($range);
+
+ //Init rev array
+ $this->count = count($rev = $this->rev = array_flip($this->alpha));
+
+ //Init split
+ $split = str_split($this->secret);
+
+ //Set offset
+ //TODO: protect undefined index ?
+ $this->offset = array_reduce($split, function ($res, $a) use ($rev) { return $res += $rev[$a]; }, count($split)) % $this->count;
+ }
+
+ /**
+ * Flatten recursively an array
+ *
+ * @param array|string $data The data tree
+ * @param string|null $current The current prefix
+ * @param string $sep The key separator
+ * @param string $prefix The key prefix
+ * @param string $suffix The key suffix
+ * @return array The flattened data
+ */
+ public function flatten($data, ?string $current = null, string $sep = '.', string $prefix = '', string $suffix = ''): array {
+ //Init result
+ $ret = [];
+
+ //Look for data array
+ if (is_array($data)) {
+ //Iteare on each pair
+ foreach($data as $k => $v) {
+ //Merge flattened value in return array
+ $ret += $this->flatten($v, empty($current) ? $k : $current.$sep.$k, $sep, $prefix, $suffix);
+ }
+ //Look flat data
+ } else {
+ //Store data in flattened key
+ $ret[$prefix.$current.$suffix] = $data;
+ }
+
+ //Return result
+ return $ret;
+ }
+
+ /**
+ * Crypt and base64uri encode string
+ *
+ * @param array|string $data The data string
+ * @return string The hashed data
+ */
+ public function hash(array|string $data): string {
+ //With array
+ if (is_array($data)) {
+ //Json encode array
+ $data = json_encode($data);
+ }
+
+ //Return hashed data
+ //XXX: we use hash_hmac with md5 hash
+ //XXX: crypt was dropped because it provided identical signature for string starting with same pattern
+ return str_replace(['+','/'], ['-','_'], base64_encode(hash_hmac('md5', $data, $this->secret, true)));
+ }
+
+ /**
+ * Serialize then short
+ *
+ * @param array $data The data array
+ * @return string The serialized and shorted data
+ */
+ public function serialize(array $data): string {
+ //Return shorted serialized data
+ //XXX: dropped serialize use to prevent short function from dropping utf-8 characters
+ return $this->short(json_encode($data));
+ }
+
+ /**
+ * Short
+ *
+ * @param string $data The data string
+ * @return string The shorted data
+ */
+ public function short(string $data): string {
+ //Return string
+ $ret = '';
+
+ //With data
+ if (!empty($data)) {
+ //Iterate on each character
+ foreach(str_split($data) as $k => $c) {
+ if (isset($this->rev[$c]) && isset($this->alpha[($this->rev[$c]+$this->offset)%$this->count])) {
+ //XXX: Remap char to an other one
+ $ret .= chr(($this->rev[$c] - $this->offset + $this->count) % $this->count);
+ } else {
+ throw new \RuntimeException(sprintf('Unable to retrieve character: %c', $c));
+ }
+ }
+ }
+
+ //Send result
+ return str_replace(['+','/','='], ['-','_',''], base64_encode($ret));
+ }
+
+ /**
+ * Convert string to safe slug
+ *
+ * @param string $data The data string
+ * @return ?string The slugged data
+ */
+ function slug(?string $data): ?string {
+ //With null
+ if ($data === null) {
+ //Return null
+ return $data;
+ }
+
+ //Use Transliterator if available
+ if (class_exists('Transliterator')) {
+ //Convert from any to latin, then to ascii and lowercase
+ $trans = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
+ //Replace every non alphanumeric character by dash then trim dash
+ return trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $trans->transliterate($data)), '-');
+ }
+
+ //Convert from utf-8 to ascii, replace quotes with space, remove non alphanumericseparator, replace separator with dash and trim dash
+ return trim(preg_replace('/[\/_|+ -]+/', '-', strtolower(preg_replace('/[^a-zA-Z0-9\/_|+ -]/', '', str_replace(['\'', '"'], ' ', iconv('UTF-8', 'ASCII//TRANSLIT', $data))))), '-');
+ }
+
+ /**
+ * Convert string to latin
+ *
+ * @param string $data The data string
+ * @return ?string The slugged data
+ */
+ function latin(?string $data): ?string {
+ //With null
+ if ($data === null) {
+ //Return null
+ return $data;
+ }
+
+ //Use Transliterator if available
+ if (class_exists('Transliterator')) {
+ //Convert from any to latin, then to ascii and lowercase
+ $trans = \Transliterator::create('Any-Latin; Latin-ASCII');
+ //Replace every non alphanumeric character by dash then trim dash
+ return trim($trans->transliterate($data));
+ }
+
+ //Convert from utf-8 to ascii
+ return trim(iconv('UTF-8', 'ASCII//TRANSLIT', $data));
+ }
+
+ /**
+ * Unshort then unserialize
+ *
+ * @param string $data The data string
+ * @return array The unshorted and unserialized data
+ */
+ public function unserialize(string $data): array {
+ //Return unshorted unserialized string
+ return json_decode($this->unshort($data), true);
+ }
+
+ /**
+ * Unshort
+ *
+ * @param string $data The data string
+ * @return string The unshorted data
+ */
+ public function unshort(string $data): string {
+ //Return string
+ $ret = '';
+
+ //Iterate on each character
+ foreach(str_split(base64_decode(str_replace(['-','_'], ['+','/'], $data))) as $c) {
+ //XXX: Reverse map char to an other one
+ $ret .= $this->alpha[(ord($c) + $this->offset) % $this->count];
+ }
+
+ //Send result
+ return $ret;
+ }
+}
"type": "symfony-bundle",
"authors": [{
"name": "Raphaël Gertz",
- "email": "packbundle@rapsys.eu"
+ "email": "symfony@rapsys.eu"
}],
"autoload": {
"psr-4": {
}
},
"require": {
- "php": "*",
- "symfony/config": "*",
- "symfony/dependency-injection": "*",
- "symfony/asset": "*",
- "twig/extensions": "*"
+ "symfony/asset": "^7.0",
+ "symfony/flex": "^2.0",
+ "symfony/framework-bundle": "^7.0",
+ "symfony/process": "^7.0",
+ "symfony/twig-bundle": "^7.0"
},
"extra": {
"branch-alias": {