From 595a37fd8be7248cf7cc506eb819254e20086c43 Mon Sep 17 00:00:00 2001 From: andre Date: Fri, 25 Sep 2026 21:44:29 +0100 Subject: [PATCH] fix(site): width/height nas imagens da home (auditoria 4.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 125 imagens na home, 0 com srcset e 122 sem width/height — o browser só sabia o tamanho depois de descarregar, o que causa layout shift. Uploads são redimensionados a um limite de lado maior (functions_images.php), não a uma dimensão fixa (fotos de equipa vão de 389x512 a 512x512, ícones de oferta de 128x128 a 256x256), por isso as dimensões não dão para hardcode: site_img_dims_attr() em functions_site.php lê o ficheiro real com getimagesize() por pedido, tal como o gallery.php já fazia, e devolve o par width="" height="" ou "" se o ficheiro não existir. SVG não passa por getimagesize() (sem decoder embutido) — svg_img_dims_attr() lê os atributos width/height (ícones fitme-*.svg, 48x48 fixo) ou cai para o viewBox (logos do navbar, só têm viewBox). Aplicado a: ícones da oferta, fotos de equipa (card + painel de detalhe), ícone de modalidade em modalidade_icon_html() e no horário (index.php e includes/horario.php), logo do navbar. srcset ficou de fora: as imagens já vêm coartadas para pouco mais do que o maior tamanho a que aparecem (fotos de equipa a 512px cabidas numa caixa de no máx. 200px CSS com object-fit:cover; ícones a 128-256px mostrados a 44px) — é a folga normal para ecrãs retina, não desperdício a resolver com múltiplas resoluções. E o layout é sempre CSS-driven (width/height: 100% + object-fit), por isso um srcset não mudaria o que é descarregado por variação de viewport, só pouparia bytes nalguns casos — gerar 2-3 tamanhos por upload via GD seria trabalho a mais para o ganho real neste site. Fica só o fix de dimensões, que é o que resolve o CLS que a auditoria mediu. Co-Authored-By: Claude Sonnet 5 --- config/functions_site.php | 49 ++++++++++++++++++++++++++++++++++++++- site/includes/horario.php | 2 +- site/includes/navbar.php | 8 ++++--- site/index.php | 9 +++---- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/config/functions_site.php b/config/functions_site.php index 93eba77..f9d96c9 100644 --- a/config/functions_site.php +++ b/config/functions_site.php @@ -104,6 +104,51 @@ function site_img_url($value, $base_dir): string { return base_url(rtrim((string) $base_dir, '/') . '/' . ltrim($value, '/')); } +// Same value/$base_dir resolution as site_img_url(), but to a filesystem path +// (for getimagesize()) instead of a URL. Empty string for a remote http(s) +// image or anything that doesn't resolve to a local file. +function site_img_path($value, $base_dir): string { + $value = (string) $value; + if ($value === '' || substr($value, 0, 4) === 'http') return ''; + $root = dirname(__DIR__); + $rel = (substr($value, 0, 8) === 'uploads/') + ? $value + : rtrim((string) $base_dir, '/') . '/' . ltrim($value, '/'); + return $root . '/' . $rel; +} + +// width="" height="" attribute string for an uploaded image, read from the +// file itself (uploads aren't resized to one fixed size — see +// config/functions_images.php — so this can't be hardcoded). Empty string +// when the file is missing/unreadable, so the just renders without the +// attributes rather than with bogus 0x0 ones. +function site_img_dims_attr($value, $base_dir): string { + $path = site_img_path($value, $base_dir); + if ($path === '') return ''; + return svg_img_dims_attr($path); +} + +// width="" height="" for any local raster or SVG file. getimagesize() covers +// raster; for SVG (which it doesn't decode) this reads the width/height +// attributes and falls back to the viewBox aspect ratio when only that is +// set (e.g. the site logos). Empty string when the file is missing or has no +// size info to go on. +function svg_img_dims_attr(string $path): string { + if (!is_file($path)) return ''; + if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'svg') { + $info = @getimagesize($path); + return $info ? 'width="' . (int) $info[0] . '" height="' . (int) $info[1] . '"' : ''; + } + $svg = (string) @file_get_contents($path, false, null, 0, 4096); + if (preg_match('/\bwidth="([\d.]+)"/', $svg, $mw) && preg_match('/\bheight="([\d.]+)"/', $svg, $mh)) { + return 'width="' . (int) round((float) $mw[1]) . '" height="' . (int) round((float) $mh[1]) . '"'; + } + if (preg_match('/\bviewBox="[\d.\-]+\s+[\d.\-]+\s+([\d.]+)\s+([\d.]+)"/', $svg, $mv)) { + return 'width="' . (int) round((float) $mv[1]) . '" height="' . (int) round((float) $mv[2]) . '"'; + } + return ''; +} + // Schedule joined to modalidades (name/colour/icon), optionally filtered by // local (e.g. 'crossfit'). Mirrors Horario_model::getAll(). function site_horario(PDO $pdo, string $local = '', ?int $gym_id = null): array { @@ -194,5 +239,7 @@ function modalidade_icon_html(?string $icon): string { return $svg; } } - return ''; + $dims = site_img_dims_attr($icon, 'uploads/modalidades/icons'); + return ''; } diff --git a/site/includes/horario.php b/site/includes/horario.php index 9b9a7ee..f4fcef1 100644 --- a/site/includes/horario.php +++ b/site/includes/horario.php @@ -69,7 +69,7 @@ foreach ($rows as $d) { $grid[$d['dia_semana']][$d['hora_inicio']][] = $d; }
- <?= htmlspecialchars((string) $d['nome']) ?> + <?= htmlspecialchars((string) $d['nome']) ?> decoding="async">
diff --git a/site/includes/navbar.php b/site/includes/navbar.php index 0e8712d..6152ca1 100644 --- a/site/includes/navbar.php +++ b/site/includes/navbar.php @@ -1,16 +1,18 @@