File size: 7,460 Bytes
d641f6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e8b84c
 
 
 
 
 
b80bb93
 
 
 
1e8b84c
 
 
 
d641f6e
 
b80bb93
 
 
 
 
 
 
 
 
d641f6e
 
 
 
 
 
 
 
 
 
 
 
 
 
1e8b84c
 
 
 
 
 
 
d641f6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
"""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
    ``<img src="report-assets/…">`` 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/<doc_id>.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",
]