Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -106,9 +106,10 @@ For a given site, the package:

1. Fetches the page and parses `<link rel="icon">`, `rel="shortcut icon"`, `rel="apple-touch-icon"`, `rel="apple-touch-icon-precomposed"`, and `rel="mask-icon"` tags, plus `<link rel="manifest">` 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

Expand Down
13 changes: 7 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 5 additions & 1 deletion src/ImagickConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion src/Sources/HtmlLinkParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand Down Expand Up @@ -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);
}
Expand Down
19 changes: 19 additions & 0 deletions tests/Unit/HtmlLinkParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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><head>
<link rel="icon" href="/favicon-32.png" sizes="32x32">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png">
<link rel="icon" href="/favicon.png">
</head></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();
});
38 changes: 38 additions & 0 deletions tests/Unit/ImagickConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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]);
Loading