fix(site): width/height nas imagens da home (auditoria 4.7)

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 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Sonnet 5 committed 2026-09-25 21:44:29 +01:00
1 parent a42b6d33c0
commit 595a37fd8b
4 files changed
+59 -9

No files matched your search

+48 -1
View File
@@ -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 <img> 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 '<img loading="lazy" decoding="async" draggable="false" src="' . htmlspecialchars(site_img_url($icon, 'uploads/modalidades/icons')) . '" alt="">';
$dims = site_img_dims_attr($icon, 'uploads/modalidades/icons');
return '<img loading="lazy" decoding="async" draggable="false" ' . $dims
. ' src="' . htmlspecialchars(site_img_url($icon, 'uploads/modalidades/icons')) . '" alt="">';
}