From 96161f34aecd04b684ca64510d1a0f260daa81bd Mon Sep 17 00:00:00 2001 From: Jonathan Miller <230051081+jmiller-moko@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:45:34 -0500 Subject: [PATCH] Add favicon configuration with auto-generation from PNG upload - New "Favicon" tab in template config with media picker for PNG upload - helper/favicon.php generates all standard sizes from source PNG using GD: - favicon.ico (16x16 + 32x32 embedded) - apple-touch-icon.png (180x180) - favicon-32x32.png, favicon-16x16.png - android-chrome-192x192.png, android-chrome-512x512.png - site.webmanifest for PWA icon discovery - Generated files cached in images/favicons/ with timestamp checking - Link tags auto-injected in when favicon source is configured - Language strings added for en-GB and en-US Co-Authored-By: Claude Opus 4.6 (1M context) --- src/helper/favicon.php | 173 ++++++++++++++++++++++ src/helper/index.html | 1 + src/index.php | 17 +++ src/language/en-GB/tpl_mokocassiopeia.ini | 6 + src/language/en-US/tpl_mokocassiopeia.ini | 6 + src/templateDetails.xml | 7 + 6 files changed, 210 insertions(+) create mode 100644 src/helper/favicon.php create mode 100644 src/helper/index.html diff --git a/src/helper/favicon.php b/src/helper/favicon.php new file mode 100644 index 0000000..2567e86 --- /dev/null +++ b/src/helper/favicon.php @@ -0,0 +1,173 @@ + + * + * 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 PNG uploaded via the template config. + */ + +defined('_JEXEC') or die; + +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'], + ]; + + /** + * 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. + */ + public static function generate(string $sourcePath, string $outputDir): bool + { + if (!is_file($sourcePath) || !extension_loaded('gd')) { + return false; + } + + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + $sourceTime = filemtime($sourcePath); + $stampFile = $outputDir . '/.favicon_generated'; + + // Skip if already up to date + if (is_file($stampFile) && filemtime($stampFile) >= $sourceTime) { + return true; + } + + $source = imagecreatefrompng($sourcePath); + if (!$source) { + 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); + + 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 = []; + 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); + imagecopyresampled($resized, $source, 0, 0, 0, 0, $size, $size, $srcW, $srcH); + + ob_start(); + imagepng($resized, null, 9); + $pngData = ob_get_clean(); + imagedestroy($resized); + + $entries[] = ['size' => $size, 'data' => $pngData]; + } + + // ICO header: 2 bytes reserved, 2 bytes type (1=ICO), 2 bytes count + $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; + } + + file_put_contents($outPath, $ico . $imageData); + } + + /** + * Write a site.webmanifest for Android/PWA icon discovery. + */ + 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) + ); + } + + /** + * 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 = rtrim($basePath, '/'); + + return '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n"; + } +} diff --git a/src/helper/index.html b/src/helper/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/helper/index.html @@ -0,0 +1 @@ + diff --git a/src/index.php b/src/index.php index 69e7525..1b85efb 100644 --- a/src/index.php +++ b/src/index.php @@ -57,6 +57,7 @@ $params_googlesitekey = $this->params->get('googlesitekey', null); $params_custom_head_start = $this->params->get('custom_head_start', null); $params_custom_head_end = $this->params->get('custom_head_end', null); $params_developmentmode = $this->params->get('developmentmode', false); +$params_favicon_source = (string) $this->params->get('favicon_source', ''); // Theme params $params_theme_enabled = $this->params->get('theme_enabled', 1); @@ -78,6 +79,19 @@ $pageclass = $menu !== null ? $menu->getParams()->get('pageclass_sfx', '') : ''; // Template/Media path $templatePath = 'media/templates/site/mokocassiopeia'; +// Favicon generation +$faviconHeadTags = ''; +if ($params_favicon_source) { + require_once __DIR__ . '/helper/favicon.php'; + $faviconSourceAbs = JPATH_ROOT . '/' . ltrim($params_favicon_source, '/'); + $faviconOutputDir = JPATH_ROOT . '/images/favicons'; + $faviconUrlBase = Uri::root(true) . '/images/favicons'; + + if (MokoFaviconHelper::generate($faviconSourceAbs, $faviconOutputDir)) { + $faviconHeadTags = MokoFaviconHelper::getHeadTags($faviconUrlBase); + } +} + // Core template CSS $wa->useStyle('template.base'); // css/template.css @@ -246,6 +260,9 @@ $wa->useScript('user.js'); // js/user.js + + +