* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later */ /** * Favicon generator — creates ICO, Apple Touch Icon, and Android icons * 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; use Joomla\CMS\Log\Log; class MokoFaviconHelper { private const SIZES = [ '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 image. */ public static function generate(string $sourcePath, string $outputDir): bool { if (!is_file($sourcePath)) { self::log('Favicon: source file not found: ' . $sourcePath, 'warning'); return false; } if (!is_dir($outputDir)) { mkdir($outputDir, 0755, true); } $sourceTime = filemtime($sourcePath); $stampFile = $outputDir . '/.favicon_generated'; if (is_file($stampFile) && filemtime($stampFile) >= $sourceTime) { return true; } // 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) { self::log('Favicon: cannot read image: ' . $sourcePath, 'warning'); return false; } $source = match ($imageInfo[2]) { IMAGETYPE_PNG => @imagecreatefrompng($sourcePath), IMAGETYPE_JPEG => @imagecreatefromjpeg($sourcePath), IMAGETYPE_GIF => @imagecreatefromgif($sourcePath), IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($sourcePath) : false, default => false, }; if (!$source) { self::log('Favicon: unsupported image type', 'warning'); return false; } imagealphablending($source, false); imagesavealpha($source, true); $srcW = imagesx($source); $srcH = imagesy($source); foreach (self::SIZES as $filename => [$w, $h]) { $resized = imagecreatetruecolor($w, $h); imagealphablending($resized, false); imagesavealpha($resized, true); 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); } // ICO from GD $icoEntries = []; foreach ([16, 32] as $size) { $resized = imagecreatetruecolor($size, $size); imagealphablending($resized, false); imagesavealpha($resized, true); 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); $icoEntries[] = ['size' => $size, 'data' => ob_get_clean()]; imagedestroy($resized); } self::writeIco($icoEntries, $outputDir . '/favicon.ico'); 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; } // 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); $offset = 6 + ($count * 16); $imageData = ''; foreach ($entries as $entry) { $size = $entry['size'] >= 256 ? 0 : $entry['size']; $dataLen = strlen($entry['data']); $ico .= pack('CCCCvvVV', $size, $size, 0, 0, 1, 32, $dataLen, $offset); $imageData .= $entry['data']; $offset += $dataLen; } file_put_contents($outPath, $ico . $imageData); } private static function generateManifest(string $outputDir): void { $manifest = [ 'icons' => [ ['src' => 'android-chrome-192x192.png', 'sizes' => '192x192', 'type' => 'image/png'], ['src' => 'android-chrome-512x512.png', 'sizes' => '512x512', 'type' => 'image/png'], ], ]; file_put_contents( $outputDir . '/site.webmanifest', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ); } public static function getHeadTags(string $basePath): string { $basePath = htmlspecialchars(rtrim($basePath, '/'), ENT_QUOTES, 'UTF-8'); return '' . "\n" . '' . "\n" . '' . "\n" . '' . "\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'); } }