From b92f6a41ff785f46c6a347243ffe6fad3287330f Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 19 Apr 2026 13:52:06 -0500 Subject: [PATCH] =?UTF-8?q?Favicon:=20tri-backend=20support=20=E2=80=94=20?= =?UTF-8?q?GD,=20Imagick,=20or=20pure=20PHP=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/helper/favicon.php | 469 ++++++++++++++++++++++++++++++++++------- 1 file changed, 395 insertions(+), 74 deletions(-) diff --git a/src/helper/favicon.php b/src/helper/favicon.php index 78d6269..ca64d61 100644 --- a/src/helper/favicon.php +++ b/src/helper/favicon.php @@ -1,6 +1,6 @@ + * Copyright (C) 2026 Moko Consulting * * 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; + } + + // ── 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; } - // ICO header: 2 bytes reserved, 2 bytes type (1=ICO), 2 bytes count + // 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 tags to inject into . - * - * @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 . '' . "\n" . '' . "\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'); + } }