"""Chargement et préparation des assets du rapport HTML.
Ce module concentre tout ce qui touche aux ressources binaires
embarquées ou référencées par le rapport :
- ``load_vendor_js`` lit un fichier JS vendorisé (Chart.js, etc.).
- ``encode_image_b64`` redimensionne et encode une image en data-URI.
- ``encode_images_b64_from_result`` itère sur un BenchmarkResult.
- ``externalize_images_to_dir`` écrit les images sur disque à côté
du HTML (mode ``--lazy-images`` du Sprint A5).
Extrait de ``picarones/report/generator.py`` lors du sprint de
découpage : isole l'I/O image et vendor du reste de l'orchestration.
"""
from __future__ import annotations
import base64
import io
import logging
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from picarones.core.results import BenchmarkResult
logger = logging.getLogger(__name__)
#: Dossier où sont stockées les ressources JS embarquées.
_VENDOR_DIR = Path(__file__).parent / "vendor"
def load_vendor_js(name: str) -> str:
"""Lit un fichier JS vendorisé et retourne son contenu.
Si le fichier n'existe pas, retourne un commentaire JS qui
garde le rapport valide (pas de SyntaxError côté navigateur).
"""
p = _VENDOR_DIR / name
if p.exists():
return p.read_text(encoding="utf-8")
return f"/* vendor/{name} non trouvé */"
def encode_image_b64(image_path: str, max_width: int = 1200) -> str:
"""Lit une image, la redimensionne si besoin, et retourne un data-URI base64.
Retourne ``""`` si l'image est introuvable ou si l'encodage
échoue (Pillow indisponible, format non géré, fichier corrompu).
Logue un avertissement dans ce dernier cas — le rapport reste
fonctionnel mais l'image manquera dans la galerie.
Distingue ``ImportError`` (Pillow non installée — problème
d'environnement) du reste (problème par image) pour aider au
diagnostic en logs de production.
"""
p = Path(image_path)
if not p.exists():
return ""
try:
from PIL import Image
except ImportError as exc:
logger.warning(
"[report] Pillow indisponible : %s — toutes les images "
"du rapport seront omises. Installer ``pip install Pillow`` "
"ou ``pip install picarones[report]``.",
exc,
)
return ""
try:
with Image.open(p) as img:
if img.width > max_width:
ratio = max_width / img.width
new_h = max(1, int(img.height * ratio))
img = img.resize((max_width, new_h), Image.LANCZOS)
# Convertir en RGB pour éviter les problèmes de mode (RGBA, palette…)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
buf = io.BytesIO()
fmt = "JPEG" if p.suffix.lower() in (".jpg", ".jpeg") else "PNG"
img.save(buf, format=fmt, optimize=True, quality=85)
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
mime = "image/jpeg" if fmt == "JPEG" else "image/png"
return f"data:{mime};base64,{b64}"
except Exception as exc: # noqa: BLE001 — fallback gracieux + warning
logger.warning(
"[report] échec d'encodage base64 de l'image %s : %s — "
"le rapport ignorera cette image",
image_path,
exc,
)
return ""
def encode_images_b64_from_result(
benchmark: "BenchmarkResult", max_width: int = 1200,
) -> dict[str, str]:
"""Encode toutes les images d'un BenchmarkResult en base64.
Returns
-------
dict
``{doc_id: data_uri}``
"""
images: dict[str, str] = {}
if not benchmark.engine_reports:
return images
for dr in benchmark.engine_reports[0].document_results:
if dr.image_path and dr.doc_id not in images:
uri = encode_image_b64(dr.image_path, max_width=max_width)
if uri:
images[dr.doc_id] = uri
return images
def externalize_images_to_dir(
benchmark: "BenchmarkResult",
output_dir: Path,
max_width: int = 1200,
asset_subdir: str = "report-assets",
) -> dict[str, str]:
"""Sprint A5 (item M-16) — écrit les images sur disque dans un
sous-dossier à côté du HTML, et retourne ``{doc_id: url_relative}``.
Mode « lazy loading » : au lieu d'embarquer chaque image en
base64 dans le HTML (50 MB+ pour un corpus de 100 documents,
~200 MB+ pour 1 000 documents), on les externalise en fichiers
PNG/JPEG locaux. Le HTML les référence via
``
`` avec ``loading="lazy"`` côté
navigateur.
Le rapport reste auto-portant si l'utilisateur copie le dossier
``report-assets/`` à côté du HTML (cf. CLI ``--lazy-images``).
Parameters
----------
benchmark:
Résultat de benchmark (lit ``image_path`` de chaque DocumentResult).
output_dir:
Dossier où le HTML sera écrit ; le sous-dossier d'assets sera
créé à côté.
max_width:
Largeur max du redimensionnement (cohérent avec
``encode_image_b64``).
asset_subdir:
Nom du sous-dossier d'assets (défaut ``"report-assets"``).
Returns
-------
dict[str, str]
``{doc_id: "report-assets/.png"}`` (URL relative
consommable directement dans un attribut HTML ``src``).
"""
from PIL import Image
assets_dir = output_dir / asset_subdir
assets_dir.mkdir(parents=True, exist_ok=True)
out: dict[str, str] = {}
seen_ids: set[str] = set()
for engine_report in benchmark.engine_reports:
for dr in engine_report.document_results:
doc_id = dr.doc_id
if doc_id in seen_ids:
continue
seen_ids.add(doc_id)
try:
src = Path(dr.image_path)
if not src.exists():
continue
# Nom de fichier dérivé du doc_id, normalisé sans
# caractères dangereux pour le filesystem.
safe_id = "".join(
c if c.isalnum() or c in "._-" else "_" for c in doc_id
)
dest = assets_dir / f"{safe_id}{src.suffix.lower() or '.png'}"
with Image.open(src) as img:
if img.width > max_width:
ratio = max_width / img.width
new_h = max(1, int(img.height * ratio))
img = img.resize((max_width, new_h), Image.LANCZOS)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
fmt = "JPEG" if dest.suffix in (".jpg", ".jpeg") else "PNG"
img.save(dest, format=fmt, optimize=True, quality=85)
# URL relative (POSIX style même sur Windows pour HTML).
out[doc_id] = f"{asset_subdir}/{dest.name}"
except Exception as exc: # noqa: BLE001 — fallback silencieux + warning
logger.warning(
"[report] échec d'externalisation de l'image %s : %s — "
"le rapport ignorera cette image",
dr.image_path,
exc,
)
return out
__all__ = [
"load_vendor_js",
"encode_image_b64",
"encode_images_b64_from_result",
"externalize_images_to_dir",
]