From 27059f770575e724dbb7633100cee15775cc1bee Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Sat, 11 May 2024 08:35:29 +0200 Subject: [PATCH] Add favicon generation functionalities (#194) * Add favicon generation functionalities Introduce favicon generation capabilities using the GDImage library and workbox integration for service worker builds. This includes browser config for Windows 8+ tile architecture, supporting different image sizes and output formats. New validations in the configuration test ensure that these additions function as expected. Modifications in the service worker compiler class aim at better usage of interfaces and performance improvements. --- castor.php | 19 +- composer.json | 1 + phpstan-baseline.neon | 69 +--- rector.php | 4 +- src/Command/CreateIconsCommand.php | 29 +- src/Command/CreateScreenshotCommand.php | 56 +-- src/DataCollector/PwaCollector.php | 63 +++- src/Dto/Favicons.php | 4 +- src/ImageProcessor/Configuration.php | 34 ++ src/ImageProcessor/ConfigurationTrait.php | 48 +++ src/ImageProcessor/GDImageProcessor.php | 258 ++++++++++++-- .../ImageProcessorInterface.php | 8 +- src/ImageProcessor/ImagickImageProcessor.php | 115 ++++-- src/Resources/config/definition/favicons.php | 16 +- src/Service/Data.php | 7 +- src/Service/FaviconsCompiler.php | 327 +++++++++++++++--- src/Twig/PwaRuntime.php | 35 +- templates/Collector/favicons-tab.html.twig | 116 +++++++ templates/Collector/manifest-tab.html.twig | 49 ++- .../Collector/serviceworker-tab.html.twig | 19 +- templates/Collector/template.html.twig | 14 + tests/DummyImageProcessor.php | 10 +- 22 files changed, 1033 insertions(+), 268 deletions(-) create mode 100644 src/ImageProcessor/Configuration.php create mode 100644 src/ImageProcessor/ConfigurationTrait.php create mode 100644 templates/Collector/favicons-tab.html.twig diff --git a/castor.php b/castor.php index da1a11b..5820b93 100644 --- a/castor.php +++ b/castor.php @@ -1,6 +1,7 @@ title('Running coding standards check'); @@ -77,10 +78,16 @@ function cs( } #[AsTask(description: 'Running PHPStan')] -function stan(): void +function stan( + #[AsOption(description: 'Generate baseline')] + bool $baseline = false +): void { io()->title('Running PHPStan'); $command = ['php', 'vendor/bin/phpstan', 'analyse']; + if ($baseline) { + $command[] = '--generate-baseline'; + } $environment = [ 'XDEBUG_MODE' => 'off', ]; @@ -119,7 +126,7 @@ function checkLicenses( io()->error('Cannot determine licenses'); exit(1); } - $licenses = json_decode($result->getOutput(), true); + $licenses = json_decode((string) $result->getOutput(), true); $disallowed = array_filter( $licenses['dependencies'], static fn (array $info, $name) => ! in_array($name, $allowedExceptions, true) @@ -161,9 +168,9 @@ function checkLicenses( #[AsTask(description: 'Run Rector')] function rector( - #[\Castor\Attribute\AsOption(description: 'Fix issues if possible')] + #[AsOption(description: 'Fix issues if possible')] bool $fix = false, - #[\Castor\Attribute\AsOption(description: 'Clear cache')] + #[AsOption(description: 'Clear cache')] bool $clearCache = false ): void { io()->title('Running Rector'); diff --git a/composer.json b/composer.json index c5bb537..5dc62af 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "symfony/asset-mapper": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", + "symfony/deprecation-contracts": "^3.5", "symfony/http-kernel": "^6.4|^7.0", "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6db0444..12bfeb3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -75,11 +75,6 @@ parameters: count: 1 path: src/Command/CreateIconsCommand.php - - - message: "#^Parameter \\#1 \\$mimeType of method Symfony\\\\Component\\\\Mime\\\\MimeTypes\\:\\:getExtensions\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: src/Command/CreateIconsCommand.php - - message: "#^Parameter \\#1 \\$source of method SpomkyLabs\\\\PwaBundle\\\\Command\\\\CreateIconsCommand\\:\\:getSourcePath\\(\\) expects string, mixed given\\.$#" count: 1 @@ -90,19 +85,9 @@ parameters: count: 1 path: src/Command/CreateIconsCommand.php - - - message: "#^Parameter \\#4 \\$format of method SpomkyLabs\\\\PwaBundle\\\\ImageProcessor\\\\ImageProcessorInterface\\:\\:process\\(\\) expects string\\|null, mixed given\\.$#" - count: 1 - path: src/Command/CreateIconsCommand.php - - - - message: "#^Parameter \\#5 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" - count: 1 - path: src/Command/CreateIconsCommand.php - - message: "#^Cannot cast mixed to int\\.$#" - count: 6 + count: 2 path: src/Command/CreateScreenshotCommand.php - @@ -125,21 +110,6 @@ parameters: count: 3 path: src/Command/CreateScreenshotCommand.php - - - message: "#^Parameter \\#1 \\$image of method SpomkyLabs\\\\PwaBundle\\\\ImageProcessor\\\\ImageProcessorInterface\\:\\:getSizes\\(\\) expects string, string\\|false given\\.$#" - count: 1 - path: src/Command/CreateScreenshotCommand.php - - - - message: "#^Parameter \\#1 \\$image of method SpomkyLabs\\\\PwaBundle\\\\ImageProcessor\\\\ImageProcessorInterface\\:\\:process\\(\\) expects string, string\\|false given\\.$#" - count: 1 - path: src/Command/CreateScreenshotCommand.php - - - - message: "#^Parameter \\#1 \\$mimeType of method Symfony\\\\Component\\\\Mime\\\\MimeTypes\\:\\:getExtensions\\(\\) expects string, string\\|null given\\.$#" - count: 1 - path: src/Command/CreateScreenshotCommand.php - - message: "#^Parameter \\#2 \\$uri of method Symfony\\\\Component\\\\Panther\\\\Client\\:\\:request\\(\\) expects string, mixed given\\.$#" count: 1 @@ -150,11 +120,6 @@ parameters: count: 1 path: src/Command/CreateScreenshotCommand.php - - - message: "#^Parameter \\#4 \\$format of method SpomkyLabs\\\\PwaBundle\\\\ImageProcessor\\\\ImageProcessorInterface\\:\\:process\\(\\) expects string\\|null, mixed given\\.$#" - count: 1 - path: src/Command/CreateScreenshotCommand.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$forceSyncFallback\\. Give it default value or assign it in the constructor\\.$#" count: 1 @@ -446,34 +411,19 @@ parameters: path: src/ImageProcessor/GDImageProcessor.php - - message: "#^Parameter \\#1 \\$dst_image of function imagecopyresampled expects GdImage, GdImage\\|false given\\.$#" - count: 1 - path: src/ImageProcessor/GDImageProcessor.php - - - - message: "#^Parameter \\#1 \\$image of function imagealphablending expects GdImage, GdImage\\|false given\\.$#" - count: 1 - path: src/ImageProcessor/GDImageProcessor.php - - - - message: "#^Parameter \\#1 \\$image of function imagepng expects GdImage, GdImage\\|false given\\.$#" - count: 1 - path: src/ImageProcessor/GDImageProcessor.php - - - - message: "#^Parameter \\#1 \\$image of function imagesavealpha expects GdImage, GdImage\\|false given\\.$#" - count: 2 + message: "#^Should not use node with type \"Stmt_Echo\", please change the code\\.$#" + count: 3 path: src/ImageProcessor/GDImageProcessor.php - - message: "#^Parameter \\#1 \\$image of function imagesx expects GdImage, GdImage\\|false given\\.$#" + message: "#^Since spomky\\-labs/pwa\\-bundle 1\\.2\\.0\\: The \"format\", \"width\" and \"height\" parameters are deprecated and will be removed in 2\\.0\\.0\\. Please use \"configuration\" instead\\.\\.$#" count: 1 path: src/ImageProcessor/GDImageProcessor.php - - message: "#^Parameter \\#1 \\$image of function imagesy expects GdImage, GdImage\\|false given\\.$#" + message: "#^Since spomky\\-labs/pwa\\-bundle 1\\.2\\.0\\: The \"format\", \"width\" and \"height\" parameters are deprecated and will be removed in 2\\.0\\.0\\. Please use \"configuration\" instead\\.\\.$#" count: 1 - path: src/ImageProcessor/GDImageProcessor.php + path: src/ImageProcessor/ImagickImageProcessor.php - message: "#^PHPDoc tag @return with type array\\ is incompatible with native type string\\.$#" @@ -630,6 +580,11 @@ parameters: count: 1 path: src/Resources/config/definition/web_client.php + - + message: "#^Cannot call method process\\(\\) on SpomkyLabs\\\\PwaBundle\\\\ImageProcessor\\\\ImageProcessorInterface\\|null\\.$#" + count: 1 + path: src/Service/FaviconsCompiler.php + - message: "#^Parameter \\#2 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" count: 1 @@ -643,4 +598,4 @@ parameters: - message: "#^Method SpomkyLabs\\\\PwaBundle\\\\SpomkyLabsPwaBundle\\:\\:loadExtension\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" count: 1 - path: src/SpomkyLabsPwaBundle.php \ No newline at end of file + path: src/SpomkyLabsPwaBundle.php diff --git a/rector.php b/rector.php index 14591a0..53df710 100644 --- a/rector.php +++ b/rector.php @@ -26,7 +26,9 @@ PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES, ]); $config->phpVersion(PhpVersion::PHP_82); - $config->paths([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/ecs.php', __DIR__ . '/rector.php']); + $config->paths( + [__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/castor.php', __DIR__ . '/ecs.php', __DIR__ . '/rector.php'] + ); $config->skip([ RemoveEmptyClassMethodRector::class => [__DIR__ . '/tests/Controller/'], ]); diff --git a/src/Command/CreateIconsCommand.php b/src/Command/CreateIconsCommand.php index 1ff0aac..87e9b81 100644 --- a/src/Command/CreateIconsCommand.php +++ b/src/Command/CreateIconsCommand.php @@ -4,6 +4,7 @@ namespace SpomkyLabs\PwaBundle\Command; +use SpomkyLabs\PwaBundle\ImageProcessor\Configuration; use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessorInterface; use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -17,7 +18,6 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Yaml\Yaml; -use function count; use function is_string; #[AsCommand(name: 'pwa:create:icons', description: 'Generate icons for your PWA')] @@ -60,7 +60,7 @@ protected function configure(): void 'f', InputOption::VALUE_OPTIONAL, 'The format of the icons', - null, + 'png', ['png', 'jpg', 'webp'] ); $this->addArgument( @@ -84,6 +84,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $dest = rtrim((string) $input->getOption('output'), '/'); $filename = $input->getOption('filename'); $format = $input->getOption('format'); + if (! is_string($format)) { + $io->error('The format must be a string.'); + return self::FAILURE; + } $sizes = $input->getArgument('sizes'); $sourcePath = $this->getSourcePath($source); @@ -97,28 +101,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->filesystem->mkdir($dest); } - $mime = MimeTypes::getDefault(); - if ($format === null) { - $mimeType = $mime->guessMimeType($sourcePath); - $extensions = $mime->getExtensions($mimeType); - if (count($extensions) === 0) { - $io->error(sprintf('Unable to guess the extension for the mime type "%s".', $mimeType)); - return self::FAILURE; - } - $format = current($extensions); - } - $generatedIcons = []; foreach ($sizes as $size) { $size = (int) $size; $outputSize = $size === 0 ? 'any' : sprintf('%sx%s', $size, $size); $io->info(sprintf('Processing icon %s', $outputSize)); - $tmp = $this->imageProcessor->process( - file_get_contents($sourcePath), - $size === 0 ? null : $size, - $size === 0 ? null : $size, - $format - ); + $configuration = Configuration::create($size, $size, $format); + $tmp = $this->imageProcessor->process(file_get_contents($sourcePath), null, null, null, $configuration); $filePath = sprintf('%s/%s-%s.%s', $dest, $filename, $outputSize, $format); $this->filesystem->dumpFile($filePath, $tmp); $asset = $this->assetMapper->getAssetFromSourcePath($filePath); @@ -126,7 +115,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'src' => $asset === null ? $filePath : $asset->logicalPath, 'sizes' => [$size], ]; - $destMimeType = $mime->guessMimeType($filePath); + $destMimeType = MimeTypes::getDefault()->guessMimeType($filePath); if ($destMimeType !== null) { $config['type'] = $destMimeType; } diff --git a/src/Command/CreateScreenshotCommand.php b/src/Command/CreateScreenshotCommand.php index 7d5bdab..bf2da79 100644 --- a/src/Command/CreateScreenshotCommand.php +++ b/src/Command/CreateScreenshotCommand.php @@ -5,6 +5,7 @@ namespace SpomkyLabs\PwaBundle\Command; use Facebook\WebDriver\WebDriverDimension; +use SpomkyLabs\PwaBundle\ImageProcessor\Configuration; use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessorInterface; use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -21,7 +22,8 @@ use Symfony\Component\Yaml\Yaml; use Throwable; use function assert; -use function count; +use function is_int; +use function is_string; #[AsCommand( name: 'pwa:create:screenshot', @@ -72,7 +74,7 @@ protected function configure(): void 'f', InputOption::VALUE_OPTIONAL, 'The format of the screenshots', - null, + 'png', ['png', 'jpg', 'webp'] ); } @@ -91,20 +93,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int $height = $input->getOption('height'); $width = $input->getOption('width'); $format = $input->getOption('format'); + if (! is_string($format)) { + $io->error('The format must be defined.'); + return self::FAILURE; + } $client = $this->getClient(); $crawler = $client->request('GET', $url); $tmpName = $this->filesystem ->tempnam('', 'pwa-'); + if ($width !== null xor $height !== null) { + $io->error('If you define a width, you must define a height.'); + return self::FAILURE; + } if ($width !== null && $height !== null) { - if ($width < 0 || $height < 0) { + $width = (int) $width; + $height = (int) $height; + if ($width <= 0 || $height <= 0) { $io->error('Width and height must be positive integers.'); return self::FAILURE; } $client->manage() ->window() - ->setSize(new WebDriverDimension((int) $width, (int) $height)); + ->setSize(new WebDriverDimension($width, $height)); } $client->manage() ->window() @@ -118,38 +130,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int $title = null; } - if ($format !== null) { - $data = $this->imageProcessor->process(file_get_contents($tmpName), null, null, $format); - file_put_contents($tmpName, $data); - } - if ($width === null || $height === null) { - ['width' => $width, 'height' => $height] = $this->imageProcessor->getSizes(file_get_contents($tmpName)); - } - - $mime = MimeTypes::getDefault(); - $mimeType = $mime->guessMimeType($tmpName); - $extensions = $mime->getExtensions($mimeType); - if (count($extensions) === 0) { - $io->error(sprintf('Unable to guess the extension for the mime type "%s".', $mimeType)); - return self::FAILURE; - } - $sizes = ''; - if ($width !== null && $height !== null) { - $sizes = sprintf('-%dx%d', (int) $width, (int) $height); - } + $data = file_get_contents($tmpName); + assert(is_string($data)); + ['width' => $width, 'height' => $height] = $this->imageProcessor->getSizes($data); + assert(is_int($width)); + assert(is_int($height)); + $configuration = Configuration::create($width, $height, $format); + $data = $this->imageProcessor->process($data, null, null, null, $configuration); + file_put_contents($tmpName, $data); - $format = current($extensions); - $filename = sprintf('%s/%s%s.%s', $dest, $input->getOption('filename'), $sizes, $format); + $filename = sprintf('%s/%s-%dx%d.%s', $dest, $input->getOption('filename'), $width, $height, $format); $this->filesystem->copy($tmpName, $filename, true); $this->filesystem->remove($tmpName); $asset = $this->assetMapper->getAssetFromSourcePath($filename); - $outputMimeType = $mime->guessMimeType($filename); + $outputMimeType = MimeTypes::getDefault()->guessMimeType($filename); $config = [ 'src' => $asset === null ? $filename : $asset->logicalPath, - 'width' => (int) $width, - 'height' => (int) $height, + 'width' => $width, + 'height' => $height, 'reference' => $url, ]; if ($outputMimeType !== null) { diff --git a/src/DataCollector/PwaCollector.php b/src/DataCollector/PwaCollector.php index 7e3b233..bab5743 100644 --- a/src/DataCollector/PwaCollector.php +++ b/src/DataCollector/PwaCollector.php @@ -6,9 +6,13 @@ use SpomkyLabs\PwaBundle\CachingStrategy\CacheStrategyInterface; use SpomkyLabs\PwaBundle\CachingStrategy\HasCacheStrategiesInterface; +use SpomkyLabs\PwaBundle\Dto\Favicons; use SpomkyLabs\PwaBundle\Dto\Manifest; use SpomkyLabs\PwaBundle\Dto\ServiceWorker; use SpomkyLabs\PwaBundle\Dto\Workbox; +use SpomkyLabs\PwaBundle\Service\FaviconsCompiler; +use SpomkyLabs\PwaBundle\Service\ManifestCompiler; +use SpomkyLabs\PwaBundle\Service\ServiceWorkerCompiler; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -20,6 +24,7 @@ use Throwable; use function count; use function in_array; +use function is_array; use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; use const JSON_UNESCAPED_SLASHES; @@ -36,6 +41,10 @@ public function __construct( private readonly iterable $cachingServices, private readonly Manifest $manifest, private readonly ServiceWorker $serviceWorker, + private readonly Favicons $favicons, + private readonly ManifestCompiler $manifestCompiler, + private readonly ServiceWorkerCompiler $serviceWorkerCompiler, + private readonly FaviconsCompiler $faviconsCompiler, ) { } @@ -52,12 +61,28 @@ public function collect(Request $request, Response $response, Throwable $excepti $this->data['cachingStrategies'][] = $cacheStrategy; } } - $this->data['serviceWorker'] = $this->serviceWorker; + $swFiles = $this->serviceWorkerCompiler->getFiles(); + $swFiles = is_array($swFiles) ? $swFiles : iterator_to_array($swFiles); + $this->data['serviceWorker'] = [ + 'enabled' => $this->serviceWorker->enabled, + 'data' => $this->serviceWorker, + 'files' => $swFiles, + ]; + $manifestFiles = $this->manifestCompiler->getFiles(); + $manifestFiles = is_array($manifestFiles) ? $manifestFiles : iterator_to_array($manifestFiles); $this->data['manifest'] = [ 'enabled' => $this->serviceWorker->enabled, 'data' => $this->manifest, 'installable' => $this->isInstallable(), 'output' => $this->serializer->serialize($this->manifest, 'json', $jsonOptions), + 'files' => $manifestFiles, + ]; + + $faviconsFiles = $this->faviconsCompiler->getFiles(); + $this->data['favicons'] = [ + 'enabled' => $this->favicons->enabled, + 'data' => $this->favicons, + 'files' => $faviconsFiles, ]; } @@ -82,9 +107,43 @@ public function getManifest(): Manifest return $this->data['manifest']['data']; } + /** + * @return array + */ + public function getManifestFiles(): array + { + return $this->data['manifest']['files']; + } + + public function getServiceWorker(): ServiceWorker + { + return $this->data['serviceWorker']['data']; + } + + /** + * @return array + */ + public function getServiceWorkerFiles(): array + { + return $this->data['serviceWorker']['files']; + } + public function getWorkbox(): Workbox { - return $this->data['serviceWorker']->workbox; + return $this->data['serviceWorker']['data']->workbox; + } + + public function getFavicons(): Favicons + { + return $this->data['favicons']['data']; + } + + /** + * @return array + */ + public function getFaviconsFiles(): array + { + return $this->data['favicons']['files']; } public function getName(): string diff --git a/src/Dto/Favicons.php b/src/Dto/Favicons.php index 17b3658..71b7f55 100644 --- a/src/Dto/Favicons.php +++ b/src/Dto/Favicons.php @@ -33,8 +33,8 @@ final class Favicons #[SerializedName('image_scale')] public null|int $imageScale = null; - #[SerializedName('only_high_resolution')] - public null|bool $onlyHighResolution = null; + #[SerializedName('low_resolution')] + public null|bool $lowResolution = null; #[SerializedName('only_tile_silhouette')] public null|bool $onlyTileSilhouette = null; diff --git a/src/ImageProcessor/Configuration.php b/src/ImageProcessor/Configuration.php new file mode 100644 index 0000000..6c20f58 --- /dev/null +++ b/src/ImageProcessor/Configuration.php @@ -0,0 +1,34 @@ +borderRadius !== null && $this->backgroundColor === null) { + throw new InvalidArgumentException('The background color must be set when the border radius is set'); + } + } + + public static function create( + int $width, + int $height, + string $format, + null|string $backgroundColor = null, + null|int $borderRadius = null, + null|int $imageScale = null, + ): self { + return new self($width, $height, $format, $backgroundColor, $borderRadius, $imageScale); + } +} diff --git a/src/ImageProcessor/ConfigurationTrait.php b/src/ImageProcessor/ConfigurationTrait.php new file mode 100644 index 0000000..7a2d5b1 --- /dev/null +++ b/src/ImageProcessor/ConfigurationTrait.php @@ -0,0 +1,48 @@ + $width, 'height' => $height] = $this->getSizes($image); + } + assert(is_int($width)); + assert(is_int($height)); + assert(is_string($format)); + + return Configuration::create($width, $height, $format); + } +} diff --git a/src/ImageProcessor/GDImageProcessor.php b/src/ImageProcessor/GDImageProcessor.php index a108a0e..4153e0c 100644 --- a/src/ImageProcessor/GDImageProcessor.php +++ b/src/ImageProcessor/GDImageProcessor.php @@ -4,32 +4,65 @@ namespace SpomkyLabs\PwaBundle\ImageProcessor; +use GdImage; +use InvalidArgumentException; use function assert; +use function is_string; +use function mb_strlen; final readonly class GDImageProcessor implements ImageProcessorInterface { - public function process(string $image, ?int $width, ?int $height, ?string $format): string - { - if ($width === null && $height === null) { - ['width' => $width, 'height' => $height] = $this->getSizes($image); - } - $image = imagecreatefromstring($image); - assert($image !== false); - imagealphablending($image, true); - if ($width !== null && $height !== null) { - if ($width === $height) { - $image = imagescale($image, $width, $height); - } else { - $newImage = imagecreatetruecolor($width, $height); - imagealphablending($newImage, false); - imagesavealpha($newImage, true); - imagecopyresampled($newImage, $image, 0, 0, 0, 0, $width, $height, imagesx($image), imagesy($image)); - $image = $newImage; - } - } + use ConfigurationTrait; + + public function process( + string $image, + ?int $width, + ?int $height, + ?string $format, + null|Configuration $configuration = null + ): string { + $configuration = $this->getConfiguration($image, $width, $height, $format, $configuration); + $mainImage = $this->createMainImage($image, $configuration); + $background = $this->createBackground($configuration); + imagecopy($background, $mainImage, 0, 0, 0, 0, $configuration->width, $configuration->height); + ob_start(); - imagesavealpha($image, true); - imagepng($image); + switch ($configuration->format) { + case 'jpeg': + case 'jpg': + imagejpeg($background); + break; + case 'png': + imagesavealpha($background, true); + imagepng($background); + break; + case 'gif': + imagegif($background); + break; + case 'ico': + ob_start(); + imagesavealpha($background, true); + imagepng($background); + $pngData = ob_get_clean(); + assert(is_string($pngData)); + + echo pack('v3', 0, 1, 1); + echo pack( + 'C4v2V2', + $configuration->width, + $configuration->height, + 0, + 0, + 1, + 32, + mb_strlen($pngData, '8bit'), + 22 + ); + echo $pngData; + break; + default: + throw new InvalidArgumentException('Unsupported format'); + } return ob_get_clean(); } @@ -39,9 +72,188 @@ public function process(string $image, ?int $width, ?int $height, ?string $forma public function getSizes(string $image): array { $image = imagecreatefromstring($image); + assert($image !== false); + $width = imagesx($image); + $height = imagesy($image); + imagedestroy($image); + return [ - 'width' => imagesx($image), - 'height' => imagesy($image), + 'width' => $width, + 'height' => $height, ]; } + + private function createMainImage(string $image, Configuration $configuration): GdImage + { + $mainImage = imagecreatefromstring($image); + assert($mainImage !== false); + $transparent = imagecolorallocatealpha($mainImage, 0, 0, 0, 127); + assert($transparent !== false); + imagealphablending($mainImage, true); + imagesavealpha($mainImage, true); + + if ($configuration->imageScale !== null) { + $width = imagesx($mainImage); + $height = imagesy($mainImage); + $newWidth = (int) ($width * $configuration->imageScale / 100); + $newHeight = (int) ($height * $configuration->imageScale / 100); + $dstWidth = (int) (($width - $newWidth) / 2); + $dstHeight = (int) (($height - $newHeight) / 2); + + $newImage = imagecreatetruecolor($width, $height); + assert($newImage !== false); + imagefill($newImage, 0, 0, $transparent); + imagealphablending($newImage, false); + imagesavealpha($newImage, true); + imagecopyresampled( + $newImage, + $mainImage, + $dstWidth, + $dstHeight, + 0, + 0, + $newWidth, + $newHeight, + $width, + $height, + ); + $mainImage = $newImage; + } + + /*if ($configuration->width === $configuration->height) { + $mainImage = imagescale($mainImage, $configuration->width, $configuration->height); + assert($mainImage !== false); + + return $mainImage; + }*/ + + $srcWidth = imagesx($mainImage); + $srcHeight = imagesy($mainImage); + if ($configuration->width >= $configuration->height) { + $ratio = $srcHeight / $srcWidth; + $newWidth = (int) ($configuration->height / $ratio); + $newHeight = $configuration->height; + } else { + $ratio = $srcWidth / $srcHeight; + $newWidth = $configuration->width; + $newHeight = (int) ($configuration->width / $ratio); + } + + $newImage = imagecreatetruecolor($configuration->width, $configuration->height); + assert($newImage !== false); + imagealphablending($newImage, false); + imagesavealpha($newImage, true); + imagefill($newImage, 0, 0, $transparent); + + $dstX = (int) (($configuration->width - $newWidth) / 2); + $dstY = (int) (($configuration->height - $newHeight) / 2); + imagecopyresampled( + $newImage, + $mainImage, + $dstX, + $dstY, + 0, + 0, + $newWidth, + $newHeight, + $srcWidth, + $srcHeight, + ); + + return $newImage; + } + + private function createBackground(Configuration $configuration): GdImage + { + // Create a blank image + $background = imagecreatetruecolor($configuration->width, $configuration->height); + assert($background !== false); + $transparent = imagecolorallocatealpha($background, 0, 0, 0, 127); + assert($transparent !== false); + + // Fill the image with the transparent color + if ($configuration->backgroundColor === null) { + imagefill($background, 0, 0, $transparent); + return $background; + } + + $hex = ltrim($configuration->backgroundColor, '#'); + $r = (int) hexdec(mb_substr($hex, 0, 2)); + $g = (int) hexdec(mb_substr($hex, 2, 2)); + $b = (int) hexdec(mb_substr($hex, 4, 2)); + $color = imagecolorallocate($background, $r, $g, $b); + assert($color !== false); + imagefill($background, 0, 0, $color); + + if ($configuration->borderRadius === null) { + return $background; + } + + // Choose a ghost color (not used in the image) + do { + $r = random_int(0, 255); + $g = random_int(0, 255); + $b = random_int(0, 255); + } while (imagecolorexact($background, $r, $g, $b) < 0); + $ghostColor = imagecolorallocate($background, $r, $g, $b); + assert($ghostColor !== false); + + // Draw the border radius + $radiusX = (int) ($configuration->borderRadius * $configuration->width / 100); + $radiusY = (int) ($configuration->borderRadius * $configuration->height / 100); + + imagearc($background, $radiusX - 1, $radiusY - 1, $radiusX * 2, $radiusY * 2, 180, 270, $ghostColor); + imagefilltoborder($background, 0, 0, $ghostColor, $transparent); + imagearc( + $background, + $configuration->width - $radiusX, + $radiusY - 1, + $radiusX * 2, + $radiusY * 2, + 270, + 0, + $ghostColor + ); + imagefilltoborder($background, $configuration->width - 1, 0, $ghostColor, $transparent); + imagearc( + $background, + $radiusX - 1, + $configuration->height - $radiusY, + $radiusX * 2, + $radiusY * 2, + 90, + 180, + $ghostColor + ); + imagefilltoborder($background, 0, $configuration->height - 1, $ghostColor, $transparent); + imagearc( + $background, + $configuration->width - $radiusX, + $configuration->height - $radiusY, + $radiusX * 2, + $radiusY * 2, + 0, + 90, + $ghostColor + ); + imagefilltoborder( + $background, + $configuration->width - 1, + $configuration->height - 1, + $ghostColor, + $transparent + ); + + imagesavealpha($background, true); + for ($x = imagesx($background); $x--;) { + for ($y = imagesy($background); $y--;) { + $c = imagecolorat($background, $x, $y); + if ($c === $ghostColor) { + imagesetpixel($background, $x, $y, $color); + } + } + } + + return $background; + } } diff --git a/src/ImageProcessor/ImageProcessorInterface.php b/src/ImageProcessor/ImageProcessorInterface.php index ca74886..437f9c3 100644 --- a/src/ImageProcessor/ImageProcessorInterface.php +++ b/src/ImageProcessor/ImageProcessorInterface.php @@ -6,7 +6,13 @@ interface ImageProcessorInterface { - public function process(string $image, ?int $width, ?int $height, ?string $format): string; + public function process( + string $image, + ?int $width, + ?int $height, + ?string $format, + null|Configuration $configuration = null + ): string; /** * @return array{width: int, height: int} diff --git a/src/ImageProcessor/ImagickImageProcessor.php b/src/ImageProcessor/ImagickImageProcessor.php index 34730bc..ef68ccb 100644 --- a/src/ImageProcessor/ImagickImageProcessor.php +++ b/src/ImageProcessor/ImagickImageProcessor.php @@ -5,35 +5,27 @@ namespace SpomkyLabs\PwaBundle\ImageProcessor; use Imagick; +use ImagickDraw; use ImagickPixel; final readonly class ImagickImageProcessor implements ImageProcessorInterface { - public function process(string $image, ?int $width, ?int $height, ?string $format): string - { - if ($width === null && $height === null) { - ['width' => $width, 'height' => $height] = $this->getSizes($image); - } - $imagick = new Imagick(); - $imagick->readImageBlob($image); - if ($width !== null && $height !== null) { - if ($width === $height) { - $imagick->scaleImage($width, $height); - } else { - $imagick->scaleImage(min($width, $height), min($width, $height)); - $imagick->extentImage( - $width, - $height, - -($width - min($width, $height)) / 2, - -($height - min($width, $height)) / 2 - ); - } - } - $imagick->setImageBackgroundColor(new ImagickPixel('transparent')); - if ($format !== null) { - $imagick->setImageFormat($format); - } - return $imagick->getImageBlob(); + use ConfigurationTrait; + + public function process( + string $image, + null|int $width, + null|int $height, + null|string $format, + null|Configuration $configuration = null + ): string { + $configuration = $this->getConfiguration($image, $width, $height, $format, $configuration); + $mainImage = $this->createMainImage($image, $configuration); + $background = $this->createBackground($configuration); + $background->compositeImage($mainImage, Imagick::COMPOSITE_OVER, 0, 0); + $background->setImageFormat($configuration->format); + + return $background->getImageBlob(); } public function getSizes(string $image): array @@ -45,4 +37,77 @@ public function getSizes(string $image): array 'height' => $imagick->getImageHeight(), ]; } + + private function createMainImage(string $image, Configuration $configuration): Imagick + { + $mainImage = new Imagick(); + $mainImage->readImageBlob($image); + $mainImage->setImageBackgroundColor(new ImagickPixel('transparent')); + + if ($configuration->imageScale !== null) { + $width = $mainImage->getImageWidth(); + $height = $mainImage->getImageHeight(); + $newWidth = (int) ($width * $configuration->imageScale / 100); + $newHeight = (int) ($height * $configuration->imageScale / 100); + $widthCenter = (int) (-($width - $newWidth) / 2); + $heightCenter = (int) (-($height - $newHeight) / 2); + + $mainImage->scaleImage($newWidth, $newHeight); + $mainImage->extentImage($width, $height, $widthCenter, $heightCenter); + } + + if ($configuration->width === $configuration->height) { + $mainImage->scaleImage($configuration->width, $configuration->height); + + return $mainImage; + } + + $mainImage->scaleImage( + min($configuration->width, $configuration->height), + min($configuration->width, $configuration->height) + ); + $mainImage->extentImage( + $configuration->width, + $configuration->height, + -($configuration->width - min($configuration->width, $configuration->height)) / 2, + -($configuration->height - min($configuration->width, $configuration->height)) / 2 + ); + + return $mainImage; + } + + private function createBackground(Configuration $configuration): Imagick + { + if ($configuration->backgroundColor === null) { + $background = new Imagick(); + $background->newImage($configuration->width, $configuration->height, new ImagickPixel('transparent')); + return $background; + } + + if ($configuration->borderRadius === null) { + $background = new Imagick(); + $background->newImage( + $configuration->width, + $configuration->height, + new ImagickPixel($configuration->backgroundColor) + ); + return $background; + } + + $rectangle = new ImagickDraw(); + $rectangle->setFillColor(new ImagickPixel($configuration->backgroundColor)); + $rectangle->roundRectangle( + 0, + 0, + $configuration->width, + $configuration->height, + (int) ($configuration->borderRadius * $configuration->width / 100), + (int) ($configuration->borderRadius * $configuration->height / 100) + ); + $background = new Imagick(); + $background->newImage($configuration->width, $configuration->height, new ImagickPixel('transparent')); + $background->drawImage($rectangle); + + return $background; + } } diff --git a/src/Resources/config/definition/favicons.php b/src/Resources/config/definition/favicons.php index dff6b7a..66769b4 100644 --- a/src/Resources/config/definition/favicons.php +++ b/src/Resources/config/definition/favicons.php @@ -6,15 +6,19 @@ return static function (DefinitionConfigurator $definition): void { $definition->rootNode() - ->beforeNormalization() + /*->beforeNormalization() ->ifTrue( - static fn (null|array $v): bool => $v !== null && isset($v['manifest']) && $v['manifest']['enabled'] === true && isset($v['favicons']) && $v['favicons']['enabled'] === true && isset($v['manifest']['theme_color']) + static fn (null|array $v): bool => $v !== null && isset($v['manifest']) && $v['manifest']['enabled'] === true && isset($v['favicons']) && $v['favicons']['enabled'] === true ) ->then(static function (array $v): array { + if (isset($v['favicons']['background_color']) || ! isset($v['manifest']['theme_color'])) { + return $v; + } + $v['favicons']['background_color'] = $v['manifest']['theme_color']; return $v; }) - ->end() + ->end()*/ ->children() ->arrayNode('favicons') ->canBeEnabled() @@ -56,9 +60,9 @@ ->defaultFalse() ->info('Generate precomposed icons. Useful for old iOS devices.') ->end() - ->booleanNode('only_high_resolution') - ->defaultTrue() - ->info('Only high resolution icons.') + ->booleanNode('low_resolution') + ->defaultFalse() + ->info('Include low resolution icons.') ->end() ->booleanNode('only_tile_silhouette') ->defaultTrue() diff --git a/src/Service/Data.php b/src/Service/Data.php index 59276a9..ac259de 100644 --- a/src/Service/Data.php +++ b/src/Service/Data.php @@ -15,15 +15,16 @@ public function __construct( public string $url, public string $data, - public array $headers + public array $headers, + public null|string $html = null, ) { } /** * @param array $headers */ - public static function create(string $url, string $data, array $headers = []): self + public static function create(string $url, string $data, array $headers = [], null|string $html = null): self { - return new self($url, $data, $headers); + return new self($url, $data, $headers, $html); } } diff --git a/src/Service/FaviconsCompiler.php b/src/Service/FaviconsCompiler.php index 13d139f..ab784d6 100644 --- a/src/Service/FaviconsCompiler.php +++ b/src/Service/FaviconsCompiler.php @@ -5,6 +5,7 @@ namespace SpomkyLabs\PwaBundle\Service; use SpomkyLabs\PwaBundle\Dto\Favicons; +use SpomkyLabs\PwaBundle\ImageProcessor\Configuration; use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessorInterface; use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\AssetMapper\MappedAsset; @@ -41,30 +42,202 @@ public function getFiles(): array } $asset = $this->assetMapper->getAsset($this->favicons->src->src); assert($asset !== null, 'The asset does not exist.'); - $this->files = [ - '/favicon.ico' => $this->processIcon($asset, '/favicon.ico', 16, 16, 'ico', 'image/x-icon'), + $this->files = []; + $sizes = [ + //Always + [ + 'url' => '/favicon.ico', + 'width' => 16, + 'height' => 16, + 'format' => 'ico', + 'mimetype' => 'image/x-icon', + 'rel' => 'icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 16, + 'height' => 16, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 32, + 'height' => 32, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + //High resolution iOS + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 180, + 'height' => 180, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'apple-touch-icon', + ], + //High resolution chrome + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 192, + 'height' => 192, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 512, + 'height' => 512, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], ]; - $sizes = [16, 32, 36, 48, 57, 60, 70, 72, 76, 96, 114, 120, 144, 150, 152, 180, 192, 194, 256, 310, 384, 512]; + if ($this->favicons->lowResolution === true) { + $sizes = [ + ...$sizes, + //Prior iOS 6 + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 57, + 'height' => 57, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'apple-touch-icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 72, + 'height' => 72, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'apple-touch-icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 114, + 'height' => 114, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'apple-touch-icon', + ], + + //Prior iOS 7 + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 60, + 'height' => 60, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'apple-touch-icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 76, + 'height' => 76, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'apple-touch-icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 120, + 'height' => 120, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'apple-touch-icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 152, + 'height' => 152, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'apple-touch-icon', + ], + + //Other resolution + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 36, + 'height' => 36, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 48, + 'height' => 48, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 72, + 'height' => 72, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 96, + 'height' => 96, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 144, + 'height' => 144, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 256, + 'height' => 256, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + [ + 'url' => '/favicons/icon-%sx%s.{hash}.png', + 'width' => 384, + 'height' => 384, + 'format' => 'png', + 'mimetype' => 'image/png', + 'rel' => 'icon', + ], + ]; + } + foreach ($sizes as $size) { - $this->files[sprintf('/favicons/icon-%dx%d.png', $size, $size)] = $this->processIcon( + $configuration = Configuration::create( + $size['width'], + $size['height'], + $size['format'], + $this->favicons->backgroundColor, + $this->favicons->borderRadius, + $this->favicons->imageScale, + ); + $this->files[sprintf($size['url'], $size['width'], $size['height'])] = $this->processIcon( $asset, - sprintf('/favicons/icon-%dx%d.{hash}.png', $size, $size), - $size, - $size, - 'png', - 'image/png' + sprintf($size['url'], $size['width'], $size['height']), + $configuration, + $size['mimetype'], + $size['rel'], ); } if ($this->favicons->tileColor !== null) { - $this->files['/favicons/icon-310x150.png'] = $this->processIcon( - $asset, - '/favicons/icon-310x150.{hash}.png', - 310, - 150, - 'png', - 'image/png' - ); - $this->files['/favicons/browserconfig.xml'] = $this->processBrowserConfig(); + $this->files = [...$this->files, ...$this->processBrowserConfig($asset)]; } return $this->files; @@ -73,54 +246,102 @@ public function getFiles(): array private function processIcon( MappedAsset $asset, string $publicUrl, - int $width, - int $height, - string $format, - string $mimeType + Configuration $configuration, + string $mimeType, + null|string $rel, ): Data { $content = file_get_contents($asset->sourcePath); assert($content !== false); if ($this->debug === true) { - $hash = hash('xxh128', $content); + $data = $this->imageProcessor->process($content, null, null, null, $configuration); + $url = str_replace('{hash}', '', $publicUrl); + $html = $rel === null ? null : sprintf( + '', + $rel, + $configuration->width, + $configuration->height, + $mimeType, + $url + ); return Data::create( - str_replace(['{hash}', '.png'], [$hash, '.svg'], $publicUrl), - $content, + $url, + $data, [ 'Cache-Control' => 'public, max-age=604800, immutable', - 'Content-Type' => 'image/svg+xml', + 'Content-Type' => $mimeType, 'X-Favicons-Dev' => true, - 'Etag' => $hash, - ] + ], + $html ); } assert($this->imageProcessor !== null); - $data = $this->imageProcessor->process($content, $width, $height, $format); + $data = $this->imageProcessor->process($content, null, null, null, $configuration); + $url = str_replace('{hash}', hash('xxh128', $data), $publicUrl); return Data::create( - str_replace('{hash}', hash('xxh128', $data), $publicUrl), + $url, $data, [ 'Cache-Control' => 'public, max-age=604800, immutable', 'Content-Type' => $mimeType, 'X-Favicons-Dev' => true, 'Etag' => hash('xxh128', $data), - ] + ], + sprintf( + '', + $rel, + $configuration->width, + $configuration->height, + $mimeType, + $url + ) ); } - private function processBrowserConfig(): Data + /** + * @return array + */ + private function processBrowserConfig(MappedAsset $asset): array { - $icon310x150 = $this->files['/favicons/icon-310x150.png'] ?? null; - $icon70x70 = $this->files['/favicons/icon-70x70.png'] ?? null; - $icon150x150 = $this->files['/favicons/icon-150x150.png'] ?? null; - $icon310x310 = $this->files['/favicons/icon-310x310.png'] ?? null; - assert($icon310x150 !== null); - assert($icon70x70 !== null); - assert($icon150x150 !== null); - assert($icon310x310 !== null); + $icon70x70 = $this->processIcon( + $asset, + '/favicons/icon-70x70.{hash}.png', + Configuration::create(70, 70, 'png', null, null, $this->favicons->imageScale), + 'image/png', + null + ); + $icon150x150 = $this->processIcon( + $asset, + '/favicons/icon-150x150.{hash}.png', + Configuration::create(150, 150, 'png', null, null, $this->favicons->imageScale), + 'image/png', + null + ); + $icon310x310 = $this->processIcon( + $asset, + '/favicons/icon-310x310.{hash}.png', + Configuration::create(310, 310, 'png', null, null, $this->favicons->imageScale), + 'image/png', + null + ); + $icon310x150 = $this->processIcon( + $asset, + '/favicons/icon-310x150.{hash}.png', + Configuration::create(310, 150, 'png', null, null, $this->favicons->imageScale), + 'image/png', + null + ); + $icon144x144 = $this->processIcon( + $asset, + '/favicons/icon-144x144.{hash}.png', + Configuration::create(144, 144, 'png', null, null, $this->favicons->imageScale), + 'image/png', + null + ); + if ($this->favicons->tileColor === null) { $tileColor = ''; } else { - $tileColor = sprintf(PHP_EOL . ' %s', $this->favicons->tileColor); + $tileColor = PHP_EOL . sprintf(' %s', $this->favicons->tileColor); } $content = << XML; - $hash = hash('xxh128', $content); - return Data::create( - sprintf('/favicons/browserconfig.%s.xml', $hash), + $hash = $this->debug === true ? '' : hash('xxh128', $content); + $url = sprintf('/favicons/browserconfig.%s.xml', $hash); + $browserConfig = Data::create( + $url, $content, [ 'Cache-Control' => 'public, max-age=604800, immutable', 'Content-Type' => 'application/xml', 'X-Favicons-Dev' => true, 'Etag' => $hash, - ] + ], + sprintf('', $url) ); + + return [ + $icon70x70, + $icon150x150, + $icon310x310, + $icon310x150, + Data::create( + $icon144x144->url, + $icon144x144->data, + $icon144x144->headers, + sprintf('', $icon144x144->url) + ), + $browserConfig, + ]; } } diff --git a/src/Twig/PwaRuntime.php b/src/Twig/PwaRuntime.php index 1388ed9..763b26b 100644 --- a/src/Twig/PwaRuntime.php +++ b/src/Twig/PwaRuntime.php @@ -225,38 +225,25 @@ private function injectFavicons(string $output, bool $injectFavicons): string } $files = $this->faviconsCompiler->getFiles(); + foreach ($files as $file) { + if ($file->html === null) { + continue; + } - $output .= PHP_EOL . ''; - foreach ([57, 60, 72, 76, 114, 120, 144, 152, 180] as $size) { - $output .= PHP_EOL . sprintf( - '', - $size, - $size, - $files[sprintf('/favicons/icon-%dx%d.png', $size, $size)]->url - ); - } - foreach ([16, 32, 48, 96, 192, 256, 384, 512] as $size) { - $output .= PHP_EOL . sprintf( - '', - $size, - $size, - $files[sprintf('/favicons/icon-%dx%d.png', $size, $size)]->url - ); + $output .= PHP_EOL . $file->html; } + if ($this->favicons->tileColor !== null) { - $output .= PHP_EOL . sprintf( - '', - $files['/favicons/browserconfig.xml']->url - ); $output .= PHP_EOL . sprintf( '', $this->favicons->tileColor ); + /*$output .= PHP_EOL . sprintf( + '', + $files['/favicons/icon-144x144.png']->url + );*/ } - return $output . (PHP_EOL . sprintf( - '', - $files['/favicons/icon-144x144.png']->url - )); + return $output; } } diff --git a/templates/Collector/favicons-tab.html.twig b/templates/Collector/favicons-tab.html.twig new file mode 100644 index 0000000..28dba8b --- /dev/null +++ b/templates/Collector/favicons-tab.html.twig @@ -0,0 +1,116 @@ +

General information

+

+ Status: + {% if collector.data.favicons.enabled %} + enabled + {% else %} + disabled + {% endif %} +

+

Details

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
Low resolution included? + {% if collector.favicons.lowResolution %} + Yes, included sizes are: +
    +
  • 16x16 (ICO and PNG): basic favicons
  • +
  • 32x32 (PNG): basic favicon
  • +
  • 57x57, 72x72, 114x114 (PNG): prior iOS 6
  • +
  • 60x60, 76x76, 120x120, 152x152 (PNG): prior iOS 7
  • +
  • 180x180 (PNG): iOS 7+
  • +
  • 36x36, 48x48, 72x72, 96x96, 144x144, 256x256, 384x384, 192x192, 512x512 (PNG): all platforms/browsers
  • +
+ {% else %} + No, included sizes are: +
    +
  • 16x16 (ICO and PNG): basic favicons
  • +
  • 32x32 (PNG): basic favicon
  • +
  • 180x180 (PNG): iOS 7+
  • +
  • 192x192, 512x512 (PNG): all platforms/browsers
  • +
+ {% endif %} +
Image scale + {% if collector.favicons.imageScale %} + {{ collector.favicons.imageScale }}% + {% else %} + n / a + {% endif %} +
Tile color + {% if collector.favicons.tileColor %} + Windows 8/10 tiles are enabled with color + {{ collector.favicons.tileColor }} + {{ collector.favicons.tileColor }} +
+ Note that this will add instructions to the HTML to enable the tile color (XML file provided, see below). +
+ Also, the following sizes are served: +
    +
  • 70x70 (PNG)
  • +
  • 150x150 (PNG)
  • +
  • 310x150 (PNG)
  • +
  • 310x310 (PNG)
  • +
+ {% else %} + Windows 8/10 tiles not enabled + {% endif %} +
Composed images + {% if collector.favicons.backgroundColor %} + The composed images have a background color of + {{ collector.favicons.backgroundColor }} + {{ collector.favicons.backgroundColor }} + {% if collector.favicons.borderRadius %} +
+ And a border radius of + {{ collector.favicons.borderRadius }}% (rounded corners). 50% is a circle. + {% endif %} + {% else %} + Composed images are transparent + {% endif %} +
Safari pin tab color + Not supported at the moment +
Tile silhouette only + Not supported at the moment +
+

Generated files

+
    + {% for file in collector.faviconsFiles %} +
  • + {{ file.url }} + {% if file.html is not null %} + (will be included in the HTML) + {% endif %} +
  • + {% endfor %} +
\ No newline at end of file diff --git a/templates/Collector/manifest-tab.html.twig b/templates/Collector/manifest-tab.html.twig index 876e021..40b2a3f 100644 --- a/templates/Collector/manifest-tab.html.twig +++ b/templates/Collector/manifest-tab.html.twig @@ -37,26 +37,26 @@ ID - {{ collector.getManifest().id }} + {{ collector.manifest.id }} Name - {{ collector.getManifest().name|trans }} + {{ collector.manifest.name|trans }} Short name - {{ collector.getManifest().shortName|trans }} + {{ collector.manifest.shortName|trans }} Description name - {{ collector.getManifest().description|trans }} + {{ collector.manifest.description|trans }} Theme color - {% if collector.getManifest().themeColor %} - {{ collector.getManifest().themeColor }} - {{ collector.getManifest().themeColor }} + {% if collector.manifest.themeColor %} + {{ collector.manifest.themeColor }} + {{ collector.manifest.themeColor }} {% else %} n/a {% endif %} @@ -65,9 +65,9 @@ Background color - {% if collector.getManifest().backgroundColor %} - {{ collector.getManifest().backgroundColor }} - {{ collector.getManifest().backgroundColor }} + {% if collector.manifest.backgroundColor %} + {{ collector.manifest.backgroundColor }} + {{ collector.manifest.backgroundColor }} {% else %} n/a {% endif %} @@ -75,28 +75,28 @@ Display - {{ collector.getManifest().display }} + {{ collector.manifest.display }} Orientation - {{ collector.getManifest().orientation }} + {{ collector.manifest.orientation }} Scope - {{ collector.getManifest().scope }} + {{ collector.manifest.scope }} Start URL - {{ collector.getManifest().startUrl }} + {{ collector.manifest.startUrl }} Categories - {% if collector.getManifest().categories|length == 0 %} + {% if collector.manifest.categories|length == 0 %} none {% else %}
    - {% for category in collector.getManifest().categories %} + {% for category in collector.manifest.categories %}
  • {{ category|trans }}
  • {% endfor %}
@@ -117,7 +117,7 @@ - {% for icon in collector.getManifest().icons %} + {% for icon in collector.manifest.icons %} {{ icon.src.src }} {{ icon.getSizeList() }} @@ -142,7 +142,7 @@ - {% for screenshot in collector.getManifest().screenshots %} + {% for screenshot in collector.manifest.screenshots %} {{ screenshot.src.src }}
@@ -167,4 +167,15 @@

Output

{{ collector.data.manifest.output|nl2br }}
-
\ No newline at end of file + +

Generated files

+
    + {% for file in collector.manifestFiles %} +
  • + {{ file.url }} + {% if file.html is not null %} + (will be included in the HTML) + {% endif %} +
  • + {% endfor %} +
\ No newline at end of file diff --git a/templates/Collector/serviceworker-tab.html.twig b/templates/Collector/serviceworker-tab.html.twig index 6de1d18..e93c3e9 100644 --- a/templates/Collector/serviceworker-tab.html.twig +++ b/templates/Collector/serviceworker-tab.html.twig @@ -15,19 +15,19 @@ Destination - {{ collector.data.serviceWorker.dest }} + {{ collector.serviceWorker.dest }} Scope - {{ collector.data.serviceWorker.scope }} + {{ collector.serviceWorker.scope }} Use Cache - {{ collector.data.serviceWorker.useCache ? 'Yes' : 'No' }} + {{ collector.serviceWorker.useCache ? 'Yes' : 'No' }} Skip Waiting - {{ collector.data.serviceWorker.skipWaiting ? 'Yes' : 'No' }} + {{ collector.serviceWorker.skipWaiting ? 'Yes' : 'No' }}

Workbox

@@ -166,3 +166,14 @@ {% endfor %} +

Generated files

+
    + {% for file in collector.serviceWorkerFiles %} +
  • + {{ file.url }} + {% if file.html is not null %} + (will be included in the HTML) + {% endif %} +
  • + {% endfor %} +
diff --git a/templates/Collector/template.html.twig b/templates/Collector/template.html.twig index 5ab231d..7ab256b 100644 --- a/templates/Collector/template.html.twig +++ b/templates/Collector/template.html.twig @@ -31,6 +31,14 @@ Disabled {% endif %} +
+ Favicons + {% if collector.data.favicons.enabled %} + Enabled + {% else %} + Disabled + {% endif %} +
{% endset %} {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true }) }} @@ -97,5 +105,11 @@ {% include '@SpomkyLabsPwa/Collector/serviceworker-tab.html.twig' %} +
+

Favicons

+
+ {% include '@SpomkyLabsPwa/Collector/favicons-tab.html.twig' %} +
+
{% endblock %} \ No newline at end of file diff --git a/tests/DummyImageProcessor.php b/tests/DummyImageProcessor.php index 4e6a858..d898fbe 100644 --- a/tests/DummyImageProcessor.php +++ b/tests/DummyImageProcessor.php @@ -4,6 +4,7 @@ namespace SpomkyLabs\PwaBundle\Tests; +use SpomkyLabs\PwaBundle\ImageProcessor\Configuration; use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessorInterface; use function assert; @@ -12,8 +13,13 @@ */ class DummyImageProcessor implements ImageProcessorInterface { - public function process(string $image, ?int $width, ?int $height, ?string $format): string - { + public function process( + string $image, + ?int $width, + ?int $height, + ?string $format, + null|Configuration $configuration = null + ): string { $json = json_encode([ 'width' => $width, 'height' => $height,