Files
fitme3070.pt/config/functions_images.php
T
andre 4f44bc1609 feat(images): JPEG XL optimizer with webp/jpg fallback and <picture> serving
img_optimize() decodes .jxl with djxl or Imagick, resizes with GD, re-encodes
with cjxl and writes foto.jxl.webp beside it; skips cleanly without a decoder.
Gallery, hero, navbar logos and JSON-LD serve the JXL with the fallback, and
trash/removal move the fallback with its .jxl.
2026-10-26 11:18:13 +00:00

441 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ─── Image optimization helpers ────────────────────────────────────────────────
// Resizes raster images IN PLACE so the longest side fits the per-area cap and
// re-encodes at web quality. Called on every upload (galerias, team,
// modalidades) and by the "Otimizar" buttons in the backoffice, which run the
// same pass over files uploaded before this existed.
//
// Idempotent by design: images already within the cap are left untouched, so
// the bulk buttons can be pressed repeatedly without generation loss.
// SVGs and animated GIFs are skipped (GD would rasterize/flatten them).
// Requires the GD extension; degrades to a no-op (status "failed") without it,
// so an upload never breaks because optimization couldn't run.
// Caps per area — longest side, in pixels.
const IMG_MAX_GALLERY = 1920;
const IMG_MAX_TEAM = 512;
const IMG_MAX_MODALIDADE = 256;
const IMG_MAX_EVENT = 1600;
const IMG_JPEG_QUALITY = 82;
const IMG_WEBP_QUALITY = 80;
// ─── Validação de uploads ────────────────────────────────────────────────────
// A extensão vem do nome que o browser manda e não prova nada: um PHP chamado
// foto.jpg passava. Aqui decide-se pelo conteúdo (finfo, com getimagesize como
// segunda opinião) e devolve-se a extensão CERTA para gravar, ou null.
//
// SVG é texto e pode levar <script>: só passa se a lista o permitir e se
// svg_is_safe() não encontrar nada executável. Aberto diretamente no browser,
// um SVG corre no domínio do site.
function upload_image_ext(array $file, array $allowed): ?string {
$tmp = (string) ($file['tmp_name'] ?? '');
if ($tmp === '' || !is_uploaded_file($tmp)) return null;
$mime = '';
if (class_exists('finfo')) {
$mime = (string) (new finfo(FILEINFO_MIME_TYPE))->file($tmp);
}
$raster = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
// JPEG XL: o getimagesize()/GD não o conhecem e o finfo só nas versões
// recentes, por isso vê-se pela assinatura (codestream FF 0A ou contentor
// ISOBMFF "JXL "). Só entra onde quem chama pôs 'jxl' em $allowed; o
// img_optimize() salta-o (não há GD para JXL).
if (in_array('jxl', $allowed, true)) {
$head = (string) @file_get_contents($tmp, false, null, 0, 12);
if (str_starts_with($head, "\xFF\x0A") || $head === "\x00\x00\x00\x0CJXL \x0D\x0A\x87\x0A") return 'jxl';
}
if (isset($raster[$mime]) || $mime === '') {
$info = @getimagesize($tmp);
$ext = $info ? ($raster[$info['mime']] ?? null) : null;
if ($ext === null) return null;
if ($ext === 'jpg' && !in_array('jpg', $allowed, true) && in_array('jpeg', $allowed, true)) $ext = 'jpeg';
return in_array($ext, $allowed, true) ? $ext : null;
}
// SVG: o finfo diz image/svg+xml, text/xml ou text/plain conforme o ficheiro.
if (in_array('svg', $allowed, true) && in_array($mime, ['image/svg+xml', 'text/xml', 'application/xml', 'text/plain', 'text/html'], true)) {
$svg = (string) @file_get_contents($tmp, false, null, 0, 2 * 1024 * 1024);
if (stripos($svg, '<svg') === false) return null;
return svg_is_safe($svg) ? 'svg' : null;
}
return null;
}
// Recusa tudo o que num SVG executa código ou vai buscar coisas fora:
// <script>, handlers on*=, javascript:, <foreignObject> (HTML embutido),
// iframes/objetos, entidades XML e href/xlink:href que não sejam âncoras
// internas (#id) nem imagens data:.
function svg_is_safe(string $svg): bool {
$bad = '/<\s*script|<\s*foreignObject|<\s*(iframe|embed|object)\b|\bon[a-z]+\s*=|javascript\s*:|<!ENTITY|<!DOCTYPE[^>]*\[/i';
if (preg_match($bad, $svg)) return false;
if (preg_match_all('/\b(?:xlink:)?href\s*=\s*["\']\s*([^"\']*)/i', $svg, $m)) {
foreach ($m[1] as $href) {
if ($href === '' || $href[0] === '#' || stripos($href, 'data:image/') === 0) continue;
return false;
}
}
return true;
}
// Resize one file in place. Returns ['status' => 'resized'|'skipped'|'failed',
// 'saved' => bytes freed (0 unless resized), 'reason' => short PT label].
function img_optimize(string $path, int $max_dim): array {
$skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why];
$fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why];
if (!is_file($path)) return $fail('ficheiro não encontrado');
if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'jxl') return img_jxl_optimize($path, $max_dim);
$info = @getimagesize($path);
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
[$w, $h] = $info;
$type = $info[2];
$supported = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF];
if (function_exists('imagecreatefromwebp')) $supported[] = IMAGETYPE_WEBP;
if (!in_array($type, $supported, true)) return $skip('formato não suportado');
// GIFs animados perdiam a animação no GD — não tocar.
if ($type === IMAGETYPE_GIF
&& substr_count((string) file_get_contents($path), "\x21\xF9\x04") > 1) {
return $skip('GIF animado');
}
// Orientação EXIF: fotos de telemóvel guardadas "deitadas" contam para o
// lado maior depois de rodadas, por isso resolve-se antes do teste de tamanho.
$rotation = 0;
if ($type === IMAGETYPE_JPEG && function_exists('exif_read_data')) {
$orientation = (int) (@exif_read_data($path)['Orientation'] ?? 1);
$rotation = [3 => 180, 6 => -90, 8 => 90][$orientation] ?? 0;
if (abs($rotation) === 90) [$w, $h] = [$h, $w];
}
if ($w <= $max_dim && $h <= $max_dim && $rotation === 0) {
return $skip('já dentro do limite');
}
// Imagens grandes podem exceder o memory_limit ao descodificar.
if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M');
$src = match ($type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
IMAGETYPE_PNG => @imagecreatefrompng($path),
IMAGETYPE_GIF => @imagecreatefromgif($path),
IMAGETYPE_WEBP => @imagecreatefromwebp($path),
default => false,
};
if (!$src) return $fail('imagem ilegível');
if ($rotation !== 0) {
$rotated = imagerotate($src, $rotation, 0);
if ($rotated) { imagedestroy($src); $src = $rotated; }
$w = imagesx($src); $h = imagesy($src);
}
$scale = min(1, $max_dim / max($w, $h, 1));
$nw = max(1, (int) round($w * $scale));
$nh = max(1, (int) round($h * $scale));
$dst = imagecreatetruecolor($nw, $nh);
if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_WEBP) {
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127));
} elseif ($type === IMAGETYPE_GIF) {
$tidx = imagecolortransparent($src);
if ($tidx >= 0 && $tidx < imagecolorstotal($src)) {
$t = imagecolorsforindex($src, $tidx);
$newt = imagecolorallocate($dst, $t['red'], $t['green'], $t['blue']);
imagefilledrectangle($dst, 0, 0, $nw, $nh, $newt);
imagecolortransparent($dst, $newt);
}
}
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($src);
$tmp = $path . '.opt.tmp';
if ($type === IMAGETYPE_JPEG) imageinterlace($dst, true); // JPEG progressivo
$ok = match ($type) {
IMAGETYPE_JPEG => imagejpeg($dst, $tmp, IMG_JPEG_QUALITY),
IMAGETYPE_PNG => imagepng($dst, $tmp, 9),
IMAGETYPE_GIF => imagegif($dst, $tmp),
IMAGETYPE_WEBP => imagewebp($dst, $tmp, IMG_WEBP_QUALITY),
default => false,
};
imagedestroy($dst);
if (!$ok || !is_file($tmp)) { @unlink($tmp); return $fail('falha ao gravar'); }
$before = (int) filesize($path);
$after = (int) filesize($tmp);
if (!@rename($tmp, $path)) { // alguns FS não substituem no rename
@unlink($path);
if (!@rename($tmp, $path)) { @unlink($tmp); return $fail('falha ao substituir'); }
}
return ['status' => 'resized', 'saved' => max(0, $before - $after), 'reason' => "{$w}×{$h} → {$nw}×{$nh}"];
}
// Run img_optimize() over every raster file directly inside $dir (no recursion).
// Returns aggregate counters: resized / skipped / failed / saved (bytes).
function img_optimize_dir(string $dir, int $max_dim): array {
@set_time_limit(0);
$stats = ['resized' => 0, 'skipped' => 0, 'failed' => 0, 'saved' => 0];
if (!is_dir($dir)) return $stats;
foreach (scandir($dir) ?: [] as $file) {
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file;
if (!is_file($path) || img_is_jxl_fallback($file)) continue;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl'], true)) continue;
$r = img_optimize($path, $max_dim);
$stats[$r['status']]++;
$stats['saved'] += $r['saved'];
}
return $stats;
}
// PT-PT flash message for the bulk buttons.
function img_stats_message(array $s): string {
$parts = [];
if ($s['resized']) {
$msg = $s['resized'] . ' imagem(ns) redimensionada(s)';
if ($s['saved'] > 0) $msg .= ' — poupados ' . img_human_bytes($s['saved']);
$parts[] = $msg;
}
if ($s['skipped']) $parts[] = $s['skipped'] . ' já dentro do limite ou não aplicável';
if ($s['failed']) $parts[] = $s['failed'] . ' com falha';
return $parts ? 'Otimização concluída: ' . implode(', ', $parts) . '.' : 'Nenhuma imagem encontrada.';
}
function img_human_bytes(int $bytes): string {
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1, ',', '') . ' MB';
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
return $bytes . ' B';
}
// ─── JPEG XL ─────────────────────────────────────────────────────────────────
// O GD não lê JXL. Descodifica-se com os binários do libjxl (djxl/cjxl, pacote
// Debian libjxl-tools) ou com o Imagick se tiver o formato JXL; sem nenhum, o
// .jxl fica como foi enviado (status "skipped") e o upload não falha.
// Ao lado de cada foto.jxl grava-se foto.jxl.webp (ou .jpg sem WebP no GD):
// é a versão para os browsers sem JXL, servida por img_tag()/img_css_bg() e
// escondida das listagens (img_list_dir). Redimensiona-se a partir da versão
// descodificada e, havendo encoder, o .jxl é regravado ao novo tamanho.
const IMG_JXL_DISTANCE = '1.0'; // cjxl -d: 1.0 = visualmente sem perdas
// Backends on this server: ['imagick' => bool, 'djxl' => path|'', 'cjxl' => path|''].
function img_jxl_tools(): array {
static $tools = null;
if ($tools !== null) return $tools;
$imagick = class_exists('Imagick') && in_array('JXL', \Imagick::queryFormats('JXL'), true);
$find = static function (string $bin): string {
if (!function_exists('proc_open')) return '';
$dirs = array_merge(explode(PATH_SEPARATOR, (string) getenv('PATH')), ['/usr/bin', '/usr/local/bin']);
foreach (array_unique(array_filter($dirs)) as $d) {
$p = rtrim($d, '/\\') . DIRECTORY_SEPARATOR . $bin;
if (@is_file($p) && @is_executable($p)) return $p;
}
return '';
};
return $tools = ['imagick' => $imagick, 'djxl' => $find('djxl'), 'cjxl' => $find('cjxl')];
}
function img_jxl_can_decode(): bool {
$t = img_jxl_tools();
return $t['imagick'] || $t['djxl'] !== '';
}
// Runs a binary with an argument list (no shell). True when it exits 0 and wrote $out.
function _img_run(string $bin, array $args, string $out): bool {
$proc = @proc_open(array_merge([$bin], $args), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($proc)) return false;
stream_get_contents($pipes[1]); stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
clearstatcache(true, $out);
return proc_close($proc) === 0 && is_file($out) && filesize($out) > 0;
}
function img_jxl_decode(string $jxl, string $png): bool {
$t = img_jxl_tools();
if ($t['djxl'] !== '' && _img_run($t['djxl'], [$jxl, $png], $png)) return true;
if ($t['imagick']) {
try {
$im = new \Imagick($jxl);
$im->setImageFormat('PNG');
return $im->writeImage($png) && is_file($png);
} catch (\Throwable $e) { return false; }
}
return false;
}
function img_jxl_encode(string $png, string $jxl): bool {
$t = img_jxl_tools();
if ($t['cjxl'] !== '' && _img_run($t['cjxl'], [$png, $jxl, '-d', IMG_JXL_DISTANCE, '-e', '7'], $jxl)) return true;
if ($t['imagick']) {
try {
$im = new \Imagick($png);
$im->setImageFormat('JXL');
$im->setImageCompressionQuality(90);
return $im->writeImage($jxl) && is_file($jxl);
} catch (\Throwable $e) { return false; }
}
return false;
}
// "foto.jxl.webp" / "foto.jxl.jpg": the fallback copies, never listed as photos.
function img_is_jxl_fallback(string $name): bool {
return (bool) preg_match('/\.jxl\.(webp|jpe?g|png)$/i', $name);
}
// Absolute path of the fallback beside $jxl_path, or '' when there is none.
function img_jxl_fallback(string $jxl_path): string {
foreach (['webp', 'jpg', 'png'] as $e) {
if (is_file($jxl_path . '.' . $e)) return $jxl_path . '.' . $e;
}
return '';
}
// Moves (or, with $to = '', deletes) the fallback that goes with an image file.
// Callers that move/delete an upload call this too, so the pair stays together.
function img_fallback_follow(string $from, string $to = ''): void {
if (!preg_match('/\.jxl$/i', $from)) return;
foreach (['webp', 'jpg', 'png'] as $e) {
if (!is_file($from . '.' . $e)) continue;
if ($to === '') @unlink($from . '.' . $e);
else @rename($from . '.' . $e, $to . '.' . $e);
}
}
function img_jxl_optimize(string $path, int $max_dim): array {
$skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why];
$fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why];
// Fallback newer than the .jxl and within the cap: nothing to do (idempotent).
$old = img_jxl_fallback($path);
if ($old !== '' && filemtime($old) >= filemtime($path)) {
$d = @getimagesize($old);
if ($d && $d[0] <= $max_dim && $d[1] <= $max_dim) return $skip('já dentro do limite');
}
if (!img_jxl_can_decode()) return $skip('JPEG XL sem descodificador no servidor (libjxl-tools)');
$dec = $path . '.dec.png';
if (!img_jxl_decode($path, $dec)) { @unlink($dec); return $fail('JPEG XL ilegível'); }
$info = @getimagesize($dec);
if (!$info) { @unlink($dec); return $fail('JPEG XL ilegível'); }
[$w, $h] = $info;
if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M');
$src = @imagecreatefrompng($dec);
if (!$src) { @unlink($dec); return $fail('JPEG XL ilegível'); }
$scale = min(1, $max_dim / max($w, $h, 1));
$nw = max(1, (int) round($w * $scale));
$nh = max(1, (int) round($h * $scale));
if ($scale < 1) {
$dst = imagecreatetruecolor($nw, $nh);
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127));
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($src);
$src = $dst;
}
// Fallback: WebP keeps transparency; without WebP in GD, JPEG.
img_fallback_follow($path);
if (function_exists('imagewebp')) {
imagesavealpha($src, true);
$ok = imagewebp($src, $path . '.webp', IMG_WEBP_QUALITY);
} else {
imageinterlace($src, true);
$ok = imagejpeg($src, $path . '.jpg', IMG_JPEG_QUALITY);
}
// Smaller JXL: re-encode from the resized pixels when there is an encoder.
$saved = 0;
if ($ok && $scale < 1 && imagepng($src, $dec)) {
$tmp = $path . '.opt.tmp';
$before = (int) filesize($path);
if (img_jxl_encode($dec, $tmp) && filesize($tmp) < $before) {
if (!@rename($tmp, $path)) { @unlink($path); @rename($tmp, $path); }
clearstatcache(true, $path);
$saved = max(0, $before - (int) filesize($path));
}
@unlink($tmp);
}
imagedestroy($src);
@unlink($dec);
if (!$ok) return $fail('falha ao gravar a versão alternativa');
// The fallback must not look older than the .jxl (idempotence check above).
@touch(img_jxl_fallback($path));
return ['status' => 'resized', 'saved' => $saved,
'reason' => ($scale < 1 ? "{$w}×{$h} → {$nw}×{$nh}, " : '') . 'versão alternativa criada'];
}
// ─── Servir imagens (JPEG XL com alternativa) ────────────────────────────────
// $rel é o caminho relativo à raiz do projeto (o que a BD guarda / o glob dá).
function _img_abs(string $rel): string {
return dirname(__DIR__) . '/' . ltrim($rel, '/');
}
// Fallback of a .jxl as a project-relative path, or '' (not JXL / no fallback).
function img_fallback_rel(string $rel): string {
if (!preg_match('/\.jxl$/i', $rel)) return '';
$fb = img_jxl_fallback(_img_abs($rel));
return $fb === '' ? '' : $rel . substr($fb, -strlen(strrchr($fb, '.')));
}
// Where only one image fits (lightbox, og:image, preload, e-mail): the
// fallback of a .jxl when there is one, else the file itself.
function img_compat_rel(string $rel): string {
return img_fallback_rel($rel) ?: $rel;
}
// <img> for $rel; a .jxl with a fallback becomes <picture> with the JXL source
// first. $attrs: extra <img> attributes (trusted names, values escaped here);
// width/height come from the file (or its fallback) unless given.
function img_tag(string $rel, array $attrs = []): string {
$fb = img_fallback_rel($rel);
$src = $fb ?: $rel;
if (!isset($attrs['width'])) {
$d = @getimagesize(_img_abs($src));
if ($d) { $attrs['width'] = $d[0]; $attrs['height'] = $d[1]; }
}
$a = '';
foreach ($attrs as $k => $v) {
if ($v === null || $v === false) continue;
$a .= ' ' . $k . ($v === true ? '' : '="' . htmlspecialchars((string) $v) . '"');
}
$img = '<img src="' . htmlspecialchars(base_url($src)) . '"' . $a . '>';
if ($fb === '') return $img;
return '<picture><source type="image/jxl" srcset="' . htmlspecialchars(base_url($rel)) . '">' . $img . '</picture>';
}
// CSS for a style="" attribute: url(fallback) first, then image-set() with the
// JXL, so browsers without image-set()/JXL keep the fallback.
function img_css_bg(string $rel): string {
$q = static fn(string $r) => "url('" . str_replace(["'", '\\'], ['%27', '%5C'], base_url($r)) . "')";
$fb = img_fallback_rel($rel);
if ($fb === '') return 'background-image:' . $q($rel);
$type = str_ends_with(strtolower($fb), '.webp') ? 'image/webp' : 'image/jpeg';
return 'background-image:' . $q($fb) . ';background-image:image-set(' . $q($rel) . ' type("image/jxl"), ' . $q($fb) . ' type("' . $type . '"))';
}
// Photos directly inside a project-relative folder ("uploads/gallery/3/"),
// sorted, without the .jxl fallbacks.
function img_list_dir(string $dir_rel): array {
$dir_rel = rtrim($dir_rel, '/') . '/';
$out = [];
foreach (glob(_img_abs($dir_rel) . '*') ?: [] as $p) {
$name = basename($p);
if (!is_file($p) || img_is_jxl_fallback($name) || !preg_match('/\.(jpe?g|png|gif|webp|jxl)$/i', $name)) continue;
$out[] = $dir_rel . $name;
}
sort($out);
return $out;
}