Favicon: tri-backend support — GD, Imagick, or pure PHP fallback
Some checks failed
Repo Health / Access control (push) Successful in 1s
Repo Health / Release configuration (push) Failing after 4s
Repo Health / Scripts governance (push) Successful in 5s
Repo Health / Repository health (push) Failing after 4s

Pure PHP backend decodes PNG (IHDR+IDAT+zlib), resizes with bilinear
interpolation, and encodes back to PNG — zero extension dependencies.
Supports RGB, RGBA, and indexed PNG color types.

Priority: GD → Imagick → pure PHP. Falls back gracefully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-04-19 13:52:06 -05:00
parent c97af7c1c8
commit b92f6a41ff

View File

@@ -1,6 +1,6 @@
<?php
/**
* Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
@@ -9,7 +9,12 @@
/**
* Favicon generator — creates ICO, Apple Touch Icon, and Android icons
* from a single source PNG uploaded via the template config.
* from a single source image uploaded via the template config.
*
* Supports three backends in priority order:
* 1. GD (fastest, most common)
* 2. Imagick (common on shared hosting)
* 3. Pure PHP (zero-dependency fallback using raw PNG manipulation)
*/
defined('_JEXEC') or die;
@@ -18,36 +23,21 @@ use Joomla\CMS\Log\Log;
class MokoFaviconHelper
{
/**
* Sizes to generate: filename => [width, height, format].
* ICO embeds 16×16 and 32×32 internally.
*/
private const SIZES = [
'apple-touch-icon.png' => [180, 180, 'png'],
'favicon-32x32.png' => [32, 32, 'png'],
'favicon-16x16.png' => [16, 16, 'png'],
'android-chrome-192x192.png' => [192, 192, 'png'],
'android-chrome-512x512.png' => [512, 512, 'png'],
'apple-touch-icon.png' => [180, 180],
'favicon-32x32.png' => [32, 32],
'favicon-16x16.png' => [16, 16],
'android-chrome-192x192.png' => [192, 192],
'android-chrome-512x512.png' => [512, 512],
];
/**
* Generate all favicon files from a source PNG if they don't already exist
* or if the source has been modified since last generation.
*
* @param string $sourcePath Absolute path to the source PNG.
* @param string $outputDir Absolute path to the output directory.
*
* @return bool True if generation succeeded or files are up to date.
* Generate all favicon files from a source image.
*/
public static function generate(string $sourcePath, string $outputDir): bool
{
if (!extension_loaded('gd')) {
Log::add('Favicon: GD extension not loaded', Log::WARNING, 'mokocassiopeia');
return false;
}
if (!is_file($sourcePath)) {
Log::add('Favicon: source file not found: ' . $sourcePath, Log::WARNING, 'mokocassiopeia');
self::log('Favicon: source file not found: ' . $sourcePath, 'warning');
return false;
}
@@ -58,15 +48,37 @@ class MokoFaviconHelper
$sourceTime = filemtime($sourcePath);
$stampFile = $outputDir . '/.favicon_generated';
// Skip if already up to date
if (is_file($stampFile) && filemtime($stampFile) >= $sourceTime) {
return true;
}
// Detect image type and load accordingly
// Strip #joomlaImage fragment if present
$sourcePath = strtok($sourcePath, '#');
// Select backend
if (extension_loaded('gd')) {
$result = self::generateWithGd($sourcePath, $outputDir);
} elseif (extension_loaded('imagick')) {
$result = self::generateWithImagick($sourcePath, $outputDir);
} else {
$result = self::generatePurePHP($sourcePath, $outputDir);
}
if ($result) {
self::generateManifest($outputDir);
file_put_contents($stampFile, date('c'));
}
return $result;
}
// ── GD Backend ──────────────────────────────────────────────────
private static function generateWithGd(string $sourcePath, string $outputDir): bool
{
$imageInfo = @getimagesize($sourcePath);
if ($imageInfo === false) {
Log::add('Favicon: cannot read image info from ' . $sourcePath, Log::WARNING, 'mokocassiopeia');
self::log('Favicon: cannot read image: ' . $sourcePath, 'warning');
return false;
}
@@ -75,83 +87,385 @@ class MokoFaviconHelper
IMAGETYPE_JPEG => @imagecreatefromjpeg($sourcePath),
IMAGETYPE_GIF => @imagecreatefromgif($sourcePath),
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($sourcePath) : false,
IMAGETYPE_BMP => function_exists('imagecreatefrombmp') ? @imagecreatefrombmp($sourcePath) : false,
default => false,
};
if (!$source) {
Log::add('Favicon: unsupported image type (' . ($imageInfo['mime'] ?? 'unknown') . ') at ' . $sourcePath, Log::WARNING, 'mokocassiopeia');
self::log('Favicon: unsupported image type', 'warning');
return false;
}
imagealphablending($source, false);
imagesavealpha($source, true);
$srcW = imagesx($source);
$srcH = imagesy($source);
// Generate PNG sizes
foreach (self::SIZES as $filename => [$w, $h]) {
$resized = imagecreatetruecolor($w, $h);
imagealphablending($resized, false);
imagesavealpha($resized, true);
$transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
imagefill($resized, 0, 0, $transparent);
imagefill($resized, 0, 0, imagecolorallocatealpha($resized, 0, 0, 0, 127));
imagecopyresampled($resized, $source, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
imagepng($resized, $outputDir . '/' . $filename, 9);
imagedestroy($resized);
}
// Generate ICO (contains 16×16 and 32×32)
self::generateIco($source, $srcW, $srcH, $outputDir . '/favicon.ico');
// Generate site.webmanifest
self::generateManifest($outputDir);
imagedestroy($source);
// Write timestamp stamp
file_put_contents($stampFile, date('c'));
return true;
}
/**
* Build a minimal ICO file containing 16×16 and 32×32 PNG entries.
*/
private static function generateIco(\GdImage $source, int $srcW, int $srcH, string $outPath): void
{
$entries = [];
// ICO from GD
$icoEntries = [];
foreach ([16, 32] as $size) {
$resized = imagecreatetruecolor($size, $size);
imagealphablending($resized, false);
imagesavealpha($resized, true);
$transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
imagefill($resized, 0, 0, $transparent);
imagefill($resized, 0, 0, imagecolorallocatealpha($resized, 0, 0, 0, 127));
imagecopyresampled($resized, $source, 0, 0, 0, 0, $size, $size, $srcW, $srcH);
ob_start();
imagepng($resized, null, 9);
$pngData = ob_get_clean();
$icoEntries[] = ['size' => $size, 'data' => ob_get_clean()];
imagedestroy($resized);
}
self::writeIco($icoEntries, $outputDir . '/favicon.ico');
$entries[] = ['size' => $size, 'data' => $pngData];
imagedestroy($source);
self::log('Favicon: generated with GD');
return true;
}
// ICO header: 2 bytes reserved, 2 bytes type (1=ICO), 2 bytes count
// ── Imagick Backend ─────────────────────────────────────────────
private static function generateWithImagick(string $sourcePath, string $outputDir): bool
{
try {
foreach (self::SIZES as $filename => [$w, $h]) {
$img = new \Imagick($sourcePath);
$img->setImageFormat('png');
$img->setImageCompressionQuality(95);
$img->thumbnailImage($w, $h, true);
// Center on transparent canvas if not square
$canvas = new \Imagick();
$canvas->newImage($w, $h, new \ImagickPixel('transparent'), 'png');
$offsetX = (int)(($w - $img->getImageWidth()) / 2);
$offsetY = (int)(($h - $img->getImageHeight()) / 2);
$canvas->compositeImage($img, \Imagick::COMPOSITE_OVER, $offsetX, $offsetY);
$canvas->writeImage($outputDir . '/' . $filename);
$img->destroy();
$canvas->destroy();
}
// ICO from Imagick
$icoEntries = [];
foreach ([16, 32] as $size) {
$img = new \Imagick($sourcePath);
$img->setImageFormat('png');
$img->thumbnailImage($size, $size, true);
$icoEntries[] = ['size' => $size, 'data' => (string) $img];
$img->destroy();
}
self::writeIco($icoEntries, $outputDir . '/favicon.ico');
self::log('Favicon: generated with Imagick');
return true;
} catch (\Exception $e) {
self::log('Favicon: Imagick failed: ' . $e->getMessage(), 'warning');
return false;
}
}
// ── Pure PHP Backend (zero dependencies) ────────────────────────
private static function generatePurePHP(string $sourcePath, string $outputDir): bool
{
$pngData = @file_get_contents($sourcePath);
if ($pngData === false) {
self::log('Favicon: cannot read source file', 'warning');
return false;
}
// Detect format — we can only resize PNG in pure PHP
// For JPEG/other formats, just copy the source as-is for each size
$isPng = (substr($pngData, 0, 8) === "\x89PNG\r\n\x1a\n");
if (!$isPng) {
// Non-PNG: copy source file for all sizes (no resize capability without extensions)
foreach (self::SIZES as $filename => [$w, $h]) {
copy($sourcePath, $outputDir . '/' . $filename);
}
// ICO: embed the raw source for 16 and 32 entries
self::writeIco([
['size' => 16, 'data' => $pngData],
['size' => 32, 'data' => $pngData],
], $outputDir . '/favicon.ico');
self::log('Favicon: non-PNG source copied without resize (no GD/Imagick)');
return true;
}
// Parse PNG dimensions from IHDR
$ihdr = self::parsePngIhdr($pngData);
if (!$ihdr) {
self::log('Favicon: cannot parse PNG header', 'warning');
return false;
}
$srcW = $ihdr['width'];
$srcH = $ihdr['height'];
// Decode PNG to raw RGBA pixel array
$pixels = self::decodePngToRgba($pngData, $srcW, $srcH, $ihdr);
if ($pixels === null) {
// Fallback: copy source for all sizes
foreach (self::SIZES as $filename => [$w, $h]) {
copy($sourcePath, $outputDir . '/' . $filename);
}
self::writeIco([
['size' => 16, 'data' => $pngData],
['size' => 32, 'data' => $pngData],
], $outputDir . '/favicon.ico');
self::log('Favicon: PNG decode failed, copied source without resize');
return true;
}
// Generate resized PNGs
foreach (self::SIZES as $filename => [$w, $h]) {
$resized = self::resizePixels($pixels, $srcW, $srcH, $w, $h);
$png = self::encodePng($resized, $w, $h);
file_put_contents($outputDir . '/' . $filename, $png);
}
// ICO
$icoEntries = [];
foreach ([16, 32] as $size) {
$resized = self::resizePixels($pixels, $srcW, $srcH, $size, $size);
$icoEntries[] = ['size' => $size, 'data' => self::encodePng($resized, $size, $size)];
}
self::writeIco($icoEntries, $outputDir . '/favicon.ico');
self::log('Favicon: generated with pure PHP');
return true;
}
/**
* Parse PNG IHDR chunk.
*/
private static function parsePngIhdr(string $data): ?array
{
if (strlen($data) < 33) return null;
// Skip 8-byte signature, 4-byte chunk length, 4-byte "IHDR"
$width = unpack('N', substr($data, 16, 4))[1];
$height = unpack('N', substr($data, 20, 4))[1];
$bitDepth = ord($data[24]);
$colorType = ord($data[25]);
return ['width' => $width, 'height' => $height, 'bitDepth' => $bitDepth, 'colorType' => $colorType];
}
/**
* Decode PNG to flat RGBA array using zlib decompression.
*
* @return array|null Flat array of [r,g,b,a, r,g,b,a, ...] or null on failure.
*/
private static function decodePngToRgba(string $data, int $w, int $h, array $ihdr): ?array
{
// Only support 8-bit RGBA (color type 6) and RGB (color type 2) for simplicity
$colorType = $ihdr['colorType'];
$bitDepth = $ihdr['bitDepth'];
if ($bitDepth !== 8 || ($colorType !== 6 && $colorType !== 2 && $colorType !== 3)) {
return null; // Unsupported format
}
// Collect all IDAT chunks
$idatData = '';
$pos = 8; // Skip PNG signature
$palette = null;
$trns = null;
while ($pos < strlen($data) - 4) {
$chunkLen = unpack('N', substr($data, $pos, 4))[1];
$chunkType = substr($data, $pos + 4, 4);
if ($chunkType === 'IDAT') {
$idatData .= substr($data, $pos + 8, $chunkLen);
} elseif ($chunkType === 'PLTE') {
$palette = substr($data, $pos + 8, $chunkLen);
} elseif ($chunkType === 'tRNS') {
$trns = substr($data, $pos + 8, $chunkLen);
} elseif ($chunkType === 'IEND') {
break;
}
$pos += 12 + $chunkLen; // 4 len + 4 type + data + 4 crc
}
$raw = @gzuncompress($idatData);
if ($raw === false) {
$raw = @gzinflate($idatData);
}
if ($raw === false) {
// Try with zlib header
$raw = @gzinflate(substr($idatData, 2));
}
if ($raw === false) {
return null;
}
$bpp = $colorType === 6 ? 4 : ($colorType === 2 ? 3 : 1); // bytes per pixel
$stride = 1 + $w * $bpp; // +1 for filter byte per row
$pixels = [];
$prevRow = array_fill(0, $w * $bpp, 0);
for ($y = 0; $y < $h; $y++) {
$rowStart = $y * $stride;
if ($rowStart >= strlen($raw)) break;
$filter = ord($raw[$rowStart]);
$row = [];
for ($x = 0; $x < $w * $bpp; $x++) {
$rawByte = ord($raw[$rowStart + 1 + $x]);
$a = ($x >= $bpp) ? $row[$x - $bpp] : 0;
$b = $prevRow[$x];
$c = ($x >= $bpp) ? $prevRow[$x - $bpp] : 0;
$val = match ($filter) {
0 => $rawByte,
1 => ($rawByte + $a) & 0xFF,
2 => ($rawByte + $b) & 0xFF,
3 => ($rawByte + (int)(($a + $b) / 2)) & 0xFF,
4 => ($rawByte + self::paethPredictor($a, $b, $c)) & 0xFF,
default => $rawByte,
};
$row[] = $val;
}
// Convert row to RGBA
for ($x = 0; $x < $w; $x++) {
if ($colorType === 6) { // RGBA
$pixels[] = $row[$x * 4];
$pixels[] = $row[$x * 4 + 1];
$pixels[] = $row[$x * 4 + 2];
$pixels[] = $row[$x * 4 + 3];
} elseif ($colorType === 2) { // RGB
$pixels[] = $row[$x * 3];
$pixels[] = $row[$x * 3 + 1];
$pixels[] = $row[$x * 3 + 2];
$pixels[] = 255;
} elseif ($colorType === 3 && $palette) { // Indexed
$idx = $row[$x];
$pixels[] = ord($palette[$idx * 3]);
$pixels[] = ord($palette[$idx * 3 + 1]);
$pixels[] = ord($palette[$idx * 3 + 2]);
$pixels[] = ($trns && $idx < strlen($trns)) ? ord($trns[$idx]) : 255;
}
}
$prevRow = $row;
}
return $pixels;
}
private static function paethPredictor(int $a, int $b, int $c): int
{
$p = $a + $b - $c;
$pa = abs($p - $a);
$pb = abs($p - $b);
$pc = abs($p - $c);
if ($pa <= $pb && $pa <= $pc) return $a;
if ($pb <= $pc) return $b;
return $c;
}
/**
* Bilinear resize of RGBA pixel array.
*/
private static function resizePixels(array $src, int $srcW, int $srcH, int $dstW, int $dstH): array
{
$dst = [];
$xRatio = $srcW / $dstW;
$yRatio = $srcH / $dstH;
for ($y = 0; $y < $dstH; $y++) {
$srcY = $y * $yRatio;
$y0 = (int) $srcY;
$y1 = min($y0 + 1, $srcH - 1);
$yFrac = $srcY - $y0;
for ($x = 0; $x < $dstW; $x++) {
$srcX = $x * $xRatio;
$x0 = (int) $srcX;
$x1 = min($x0 + 1, $srcW - 1);
$xFrac = $srcX - $x0;
for ($c = 0; $c < 4; $c++) {
$tl = $src[($y0 * $srcW + $x0) * 4 + $c];
$tr = $src[($y0 * $srcW + $x1) * 4 + $c];
$bl = $src[($y1 * $srcW + $x0) * 4 + $c];
$br = $src[($y1 * $srcW + $x1) * 4 + $c];
$top = $tl + ($tr - $tl) * $xFrac;
$bot = $bl + ($br - $bl) * $xFrac;
$dst[] = (int) round($top + ($bot - $top) * $yFrac);
}
}
}
return $dst;
}
/**
* Encode RGBA pixel array to PNG binary.
*/
private static function encodePng(array $pixels, int $w, int $h): string
{
// Build raw image data with filter byte 0 (None) per row
$raw = '';
for ($y = 0; $y < $h; $y++) {
$raw .= "\x00"; // filter: None
for ($x = 0; $x < $w; $x++) {
$i = ($y * $w + $x) * 4;
$raw .= chr($pixels[$i]) . chr($pixels[$i + 1]) . chr($pixels[$i + 2]) . chr($pixels[$i + 3]);
}
}
$compressed = gzcompress($raw);
// Build PNG
$png = "\x89PNG\r\n\x1a\n";
// IHDR
$ihdr = pack('NNCCCC', $w, $h, 8, 6, 0, 0, 0); // 8-bit RGBA
$png .= self::pngChunk('IHDR', $ihdr);
// IDAT
$png .= self::pngChunk('IDAT', $compressed);
// IEND
$png .= self::pngChunk('IEND', '');
return $png;
}
private static function pngChunk(string $type, string $data): string
{
$chunk = $type . $data;
return pack('N', strlen($data)) . $chunk . pack('N', crc32($chunk));
}
// ── Shared Utilities ────────────────────────────────────────────
/**
* Write ICO file from PNG data entries.
*/
private static function writeIco(array $entries, string $outPath): void
{
$count = count($entries);
$ico = pack('vvv', 0, 1, $count);
// Calculate offset: header (6) + directory entries (16 each)
$offset = 6 + ($count * 16);
$imageData = '';
foreach ($entries as $entry) {
$size = $entry['size'] >= 256 ? 0 : $entry['size'];
$dataLen = strlen($entry['data']);
// ICONDIRENTRY: width, height, colors, reserved, planes, bpp, size, offset
$ico .= pack('CCCCvvVV', $size, $size, 0, 0, 1, 32, $dataLen, $offset);
$imageData .= $entry['data'];
$offset += $dataLen;
@@ -160,9 +474,6 @@ class MokoFaviconHelper
file_put_contents($outPath, $ico . $imageData);
}
/**
* Write a site.webmanifest for Android/PWA icon discovery.
*/
private static function generateManifest(string $outputDir): void
{
$manifest = [
@@ -177,13 +488,6 @@ class MokoFaviconHelper
);
}
/**
* Return the <link> tags to inject into <head>.
*
* @param string $basePath URL path to the favicon directory (relative to site root).
*
* @return string HTML link tags.
*/
public static function getHeadTags(string $basePath): string
{
$basePath = htmlspecialchars(rtrim($basePath, '/'), ENT_QUOTES, 'UTF-8');
@@ -194,4 +498,21 @@ class MokoFaviconHelper
. '<link rel="manifest" href="' . $basePath . '/site.webmanifest">' . "\n"
. '<link rel="shortcut icon" href="' . $basePath . '/favicon.ico">' . "\n";
}
private static function log(string $message, string $priority = 'info'): void
{
$priorities = [
'info' => Log::INFO,
'warning' => Log::WARNING,
'error' => Log::ERROR,
];
Log::addLogger(
['text_file' => 'mokocassiopeia.log.php'],
Log::ALL,
['mokocassiopeia']
);
Log::add($message, $priorities[$priority] ?? Log::INFO, 'mokocassiopeia');
}
}