Favicon: tri-backend support — GD, Imagick, or pure PHP fallback
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:
@@ -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;
|
||||
}
|
||||
|
||||
// ── 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 <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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user