diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c569bcb..22171c3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,11 @@ jobs: matrix: php: [8.2, 8.3] laravel: [10.*, 11.*, 12.*] + include: + - php: 8.3 + laravel: 13.* + - php: 8.4 + laravel: 13.* name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} diff --git a/README.md b/README.md index 594e294..83e13d9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Given a site URL, the package discovers the best available icon source (preferri - PHP 8.2+ - The `imagick` PHP extension, ideally built with the `librsvg` delegate (for SVG rasterization) and `libheif` delegate (for AVIF) -- Laravel 10, 11, or 12 +- Laravel 10, 11, 12, or 13 ## Installation @@ -106,9 +106,10 @@ For a given site, the package: 1. Fetches the page and parses ``, `rel="shortcut icon"`, `rel="apple-touch-icon"`, `rel="apple-touch-icon-precomposed"`, and `rel="mask-icon"` tags, plus `` and its `icons` array. 2. Falls back to `/favicon.ico` at the domain root. -3. Ranks every candidate: SVG first, then ICO, then PNG/WebP/AVIF (largest declared size wins), then JPG. +3. Ranks every candidate: SVG first, then ICO, then PNG/WebP/AVIF (largest declared size wins; an apple-touch-icon without `sizes` counts as 180px), then JPG. 4. Downloads the best candidate and sniffs its real type from magic bytes (not the `Content-Type` header, which is often wrong). 5. If the source is a multi-frame `.ico`, the largest embedded frame is used. +6. Resizes with a Lanczos filter, so the thin strokes of a detailed icon survive being scaled down to 16 or 32px. ## Storage diff --git a/composer.json b/composer.json index 36ac1b2..1aeb3bf 100644 --- a/composer.json +++ b/composer.json @@ -21,14 +21,15 @@ "require": { "php": "^8.2", "ext-imagick": "*", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0" + "guzzlehttp/guzzle": "^7.2", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0" }, "require-dev": { - "orchestra/testbench": "^8.0|^9.0|^10.0", - "pestphp/pest": "^2.34|^3.0", - "pestphp/pest-plugin-laravel": "^2.4|^3.0" + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^2.34|^3.0|^4.0|^5.0", + "pestphp/pest-plugin-laravel": "^2.4|^3.0|^4.0|^5.0" }, "autoload": { "psr-4": { diff --git a/src/ImagickConverter.php b/src/ImagickConverter.php index e6b9b9d..0d91441 100644 --- a/src/ImagickConverter.php +++ b/src/ImagickConverter.php @@ -128,7 +128,11 @@ private function loadLargestIcoFrame(string $bytes): Imagick private function toSquare(Imagick $image, int $size): Imagick { $image->setImageFormat('png32'); - $image->thumbnailImage($size, $size, true, false); + // resizeImage rather than thumbnailImage: the latter point-samples + // large reductions (e.g. 192 -> 32) down to 5x the target first, + // which drops the thin strokes favicons are full of. + $image->resizeImage($size, $size, Imagick::FILTER_LANCZOS, 1, true); + $image->stripImage(); $image->extentImage( $size, $size, diff --git a/src/Sources/HtmlLinkParser.php b/src/Sources/HtmlLinkParser.php index a135429..58a7384 100644 --- a/src/Sources/HtmlLinkParser.php +++ b/src/Sources/HtmlLinkParser.php @@ -14,6 +14,11 @@ final class HtmlLinkParser 'apple-touch-icon-precomposed', ]; + // iOS renders touch icons at 180x180, so one declared without a + // `sizes` attribute is assumed to be that large rather than unknown, + // letting it outrank a small, explicitly sized tab icon. + private const APPLE_TOUCH_ICON_SIZE = 180; + /** * @return array{icons: FaviconSource[], manifest: string|null} */ @@ -52,7 +57,8 @@ public function parse(string $html, string $baseUrl): array $absolute = $this->resolveUrl($href, $baseUrl); $type = $this->guessTypeFromUrl($absolute, $link->getAttribute('type')); - $sizeHint = $this->parseSizes($link->getAttribute('sizes')); + $sizeHint = $this->parseSizes($link->getAttribute('sizes')) + ?? (str_starts_with($rel, 'apple-touch-icon') ? self::APPLE_TOUCH_ICON_SIZE : null); $icons[] = new FaviconSource($absolute, $type, $sizeHint); } diff --git a/tests/Unit/HtmlLinkParserTest.php b/tests/Unit/HtmlLinkParserTest.php index 8825ec1..89cb225 100644 --- a/tests/Unit/HtmlLinkParserTest.php +++ b/tests/Unit/HtmlLinkParserTest.php @@ -51,3 +51,22 @@ expect($icons[0]->sizeHint)->toBe(192); expect($icons[1]->sizeHint)->toBe(512); }); + +it('assumes an apple touch icon without sizes is 180px', function () { + $html = <<<'HTML' +
+ + + + + + HTML; + + $hints = collect((new HtmlLinkParser())->parse($html, 'https://example.com/')['icons']) + ->mapWithKeys(fn ($icon) => [$icon->url => $icon->sizeHint]); + + expect($hints['https://example.com/apple-touch-icon.png'])->toBe(180); + expect($hints['https://example.com/apple-touch-icon-precomposed.png'])->toBe(180); + expect($hints['https://example.com/favicon-32.png'])->toBe(32); + expect($hints['https://example.com/favicon.png'])->toBeNull(); +}); diff --git a/tests/Unit/ImagickConverterTest.php b/tests/Unit/ImagickConverterTest.php index 7a2c872..825e9f4 100644 --- a/tests/Unit/ImagickConverterTest.php +++ b/tests/Unit/ImagickConverterTest.php @@ -24,7 +24,24 @@ function transparentSourcePng(): string return $bytes; } +function imagickEncodes(string $format): bool +{ + try { + $probe = new Imagick(); + $probe->newImage(1, 1, new ImagickPixel('white')); + $probe->setImageFormat($format); + + return $probe->getImageBlob() !== ''; + } catch (ImagickException) { + return false; + } +} + it('converts a transparent source into every supported raster type', function (string $type) { + if ($type === 'avif' && ! imagickEncodes('avif')) { + $this->markTestSkipped('This ImageMagick build has no AVIF encoder.'); + } + $bytes = converter()->toRaster(transparentSourcePng(), 'png', $type, 32); expect($bytes)->not->toBeEmpty(); @@ -66,3 +83,24 @@ function transparentSourcePng(): string unlink($path); }); + +it('keeps a one pixel stroke visible when downscaling a large source', function (int $x) { + $source = new Imagick(); + $source->newImage(192, 192, new ImagickPixel('white')); + $line = new ImagickDraw(); + $line->setFillColor(new ImagickPixel('black')); + $line->rectangle($x, 0, $x, 191); + $source->drawImage($line); + $source->setImageFormat('png32'); + + $image = new Imagick(); + $image->readImageBlob(converter()->toRaster($source->getImageBlob(), 'png', 'png', 32)); + + $darkest = min(array_map( + fn (int $column) => $image->getImagePixelColor($column, 16)->getColor()['r'], + range(0, 31), + )); + + // A point-sampling pre-pass drops the column entirely (pure white). + expect($darkest)->toBeLessThan(240); +})->with([1, 4, 7, 96]);