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>
519 lines
18 KiB
PHP
519 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
*
|
|
* 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 '<link rel="apple-touch-icon" sizes="180x180" href="' . $basePath . '/apple-touch-icon.png">' . "\n"
|
|
. '<link rel="icon" type="image/png" sizes="32x32" href="' . $basePath . '/favicon-32x32.png">' . "\n"
|
|
. '<link rel="icon" type="image/png" sizes="16x16" href="' . $basePath . '/favicon-16x16.png">' . "\n"
|
|
. '<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');
|
|
}
|
|
}
|