SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
50.5 KB · 1,268 lines python
Raw Blame History
1# Auteur : Simon-Pierre Boucher — contact@spboucher.ai2# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v33# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit4# les rapports estampillés Groupe-KA. 5 modes fixes :5#   complet      — toutes les sections (KPI, jauges, séries + stats, multi-6#                  séries, empilées, distributions, répartitions, géo,7#                  heatmap horaire, tableaux, records)8#   synthese     — couverture + KPI + records (2-3 pages)9#   tendances    — KPI + toutes les séries temporelles + stats de séries10#   repartitions — breakdowns, distributions, géo, activité horaire11#   donnees      — tous les tableaux en version longue (400 lignes max)12# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par13# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/14# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport15# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",16# "render": "bar"}, …]} et respecte l'ordre demandé.17# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.18# Usage :19#   from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename20#   pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",21#       "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,22#       mode="complet").build()23#   pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",24#       spec={"title": "Mon rapport", "blocks": [...]}).build()25# Dépendance : pip install fpdf2  (aucune autre)26from __future__ import annotations2728import math29from datetime import datetime30from zoneinfo import ZoneInfo3132from fpdf import FPDF3334INK = (20, 24, 20)35INK2 = (77, 85, 81)36INK3 = (139, 146, 140)37PAPER = (245, 243, 238)38SURFACE2 = (250, 249, 245)39GREEN = (28, 92, 65)40DANGER = (179, 66, 58)41WHITE = (255, 255, 255)4243REPORT_MODES = {44    "complet": "Rapport complet",45    "synthese": "Synthèse exécutive",46    "tendances": "Tendances & évolution",47    "repartitions": "Répartitions & géographie",48    "donnees": "Données détaillées",49}50# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)51CUSTOM_MODE = "personnalise"52CUSTOM_LABEL = "Rapport personnalisé"5354# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;55# « table » est toujours offert : toute donnée a un équivalent tableau)56RENDER_LABELS = {57    "line": "Courbe", "area": "Aire", "bar": "Barres verticales",58    "bars": "Barres horizontales", "donut": "Anneau",59    "lines": "Multi-courbes", "stacked": "Barres empilées",60    "histogram": "Histogramme", "heatmap": "Heatmap",61    "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",62}63SECTION_LABELS = {64    "kpis": "Indicateurs", "gauges": "Taux & couvertures",65    "series": "Évolution", "multiseries": "Comparaisons",66    "stacked": "Compositions", "breakdowns": "Répartitions",67    "distributions": "Distributions", "geo": "Géographie",68    "heatmap": "Calendrier", "hourly": "Activité horaire",69    "tables": "Tableaux", "records": "Records",70}717273def catalog(dash: dict) -> list[dict]:74    """v3 — blocs composables d'un dashboard : ce que le constructeur de75    rapports personnalisés peut inclure, avec les rendus compatibles.76    key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""77    out: list[dict] = []7879    def add(key, title, renders, default=None, count=None):80        b = {"key": key, "section": key.split(":")[0], "title": title,81             "renders": renders, "default_render": default or renders[0]}82        if count is not None:83            b["count"] = count84        out.append(b)8586    if dash.get("kpis"):87        add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],88            count=len(dash["kpis"]))89    gs = [g for g in (dash.get("gauges") or [])90          if isinstance(g.get("value"), (int, float)) and g.get("max")]91    if gs:92        add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],93            count=len(gs))94    for s in dash.get("series") or []:95        if len(s.get("points") or []) < 2:96            continue97        kind = s.get("kind") or "line"98        default = kind if kind in ("line", "area", "bar") else "line"99        add(f"series:{s.get('id')}", s.get("title", ""),100            ["line", "area", "bar", "table"], default,101            len(s.get("points") or []))102    for ms in dash.get("multiseries") or []:103        if not (ms.get("series") or []):104            continue105        add(f"multiseries:{ms.get('id')}", ms.get("title", ""),106            ["lines", "table"], count=len(ms["series"]))107    for st in dash.get("stacked") or []:108        if not (st.get("points") or []):109            continue110        add(f"stacked:{st.get('id')}", st.get("title", ""),111            ["stacked", "table"], count=len(st.get("keys") or []))112    for b in dash.get("breakdowns") or []:113        if not (b.get("items") or []):114            continue115        default = "donut" if b.get("kind") == "donut" else "bars"116        add(f"breakdowns:{b.get('id')}", b.get("title", ""),117            ["donut", "bars", "table"], default, len(b["items"]))118    for d in dash.get("distributions") or []:119        if not (d.get("bins") or []):120            continue121        add(f"distributions:{d.get('id')}", d.get("title", ""),122            ["histogram", "table"], count=len(d["bins"]))123    geo = dash.get("geo") or {}124    if geo.get("items"):125        add("geo", geo.get("title", "Répartition géographique"),126            ["bars", "table"], count=len(geo["items"]))127    hm = dash.get("heatmap") or {}128    if hm.get("cells"):129        add("heatmap", hm.get("title", "Calendrier d'activité"),130            ["heatmap", "table"])131    hr = dash.get("hourly") or {}132    if hr.get("cells"):133        add("hourly", hr.get("title", "Activité par jour et heure"),134            ["heatmap", "table"])135    for t in dash.get("tables") or []:136        if not (t.get("rows") or []):137            continue138        add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],139            count=len(t["rows"]))140    if dash.get("records"):141        add("records", "Records & faits marquants", ["cards", "table"],142            count=len(dash["records"]))143    return out144145EMAILS = [146    ("contact@groupe-ka.com", "Projets, partenariats & données"),147    ("info@groupe-ka.com", "Médias & questions générales"),148    ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),149]150DISCLAIMER = (151    "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "152    "rien et ne sommes partie à aucune transaction. Données lues à la source, "153    "rien d'inventé, tout est traçable."154)155156157def _hex(c: str) -> tuple[int, int, int]:158    c = c.lstrip("#")159    return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4))  # type: ignore160161162def _fr(n) -> str:163    if isinstance(n, float) and not n.is_integer():164        return f"{n:,.2f}".replace(",", " ").replace(".", ",")165    return f"{int(n):,}".replace(",", " ")166167168_SUBST = {169    "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",170    "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',171    "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",172    "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",173}174175176def _latin1(s: str) -> str:177    for k, v in _SUBST.items():178        s = s.replace(k, v)179    return s.encode("latin-1", "replace").decode("latin-1")180181182class _PDF(FPDF):183    """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).184    Les polices core sont latin-1 : normalize_text sanitise en amont."""185186    def normalize_text(self, text):187        return super().normalize_text(_latin1(text))188189    def __init__(self, brand: str, accent: tuple, period_label: str):190        super().__init__(orientation="P", unit="mm", format="A4")191        self.brand = brand192        self.accent = accent193        self.period_label = period_label194        self.cover_mode = False195        self.set_margins(18, 20, 18)196        self.set_auto_page_break(True, margin=22)197198    def header(self):199        if self.cover_mode or self.page_no() == 1:200            return201        self.set_font("helvetica", "B", 8.5)202        self.set_text_color(*INK)203        self.set_xy(18, 9)204        self.cell(0, 5, f"Groupe KA · {self.brand}")205        self.set_font("helvetica", "", 8)206        self.set_text_color(*INK3)207        self.set_xy(18, 9)208        self.cell(0, 5, "Rapport statistique", align="R")209        self.set_draw_color(*INK)210        self.set_line_width(0.5)211        self.line(18, 15.5, 192, 15.5)212        self.set_y(20)213214    def footer(self):215        # page 1 = couverture (le flag cover_mode est déjà retombé quand216        # add_page() clôt la page 1 → tester aussi le numéro de page)217        if self.cover_mode or self.page_no() == 1:218            return219        self.set_y(-15)220        self.set_draw_color(*INK3)221        self.set_line_width(0.2)222        self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)223        self.set_font("helvetica", "", 7.5)224        self.set_text_color(*INK3)225        year = datetime.now(ZoneInfo("America/Toronto")).year226        self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")227        self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")228229230class GroupeKAReport:231    def __init__(self, site: dict, dashboard: dict, mode: str = "complet",232                 spec: dict | None = None):233        self.site = site234        self.d = dashboard235        self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"236        self.spec = spec or {}237        self.accent = _hex(site.get("accent", "#d9f26b"))238        period = dashboard.get("period", {}) or {}239        self.period_label = period.get("label") or "toute la période"240        self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)241        self.toc: list[tuple[str, int]] = []242243    @property244    def mode_label(self) -> str:245        if self.mode == CUSTOM_MODE:246            t = str(self.spec.get("title") or "").strip()247            return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL248        return REPORT_MODES[self.mode]249250    # ---------- primitives ----------251    def _card(self, x, y, w, h, fill=WHITE):252        p = self.pdf253        p.set_draw_color(*INK)254        p.set_line_width(0.45)255        p.set_fill_color(*fill)256        p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)257258    def _shade(self, i, n=8):259        shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]260        f = shades[i % len(shades)]261        return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))262263    def _kicker(self, text):264        p = self.pdf265        p.set_font("helvetica", "B", 8)266        p.set_text_color(*GREEN)267        p.set_draw_color(*GREEN)268        p.set_line_width(0.6)269        y = p.get_y() + 2270        p.line(p.l_margin, y, p.l_margin + 7, y)271        p.set_xy(p.l_margin + 9, y - 2.5)272        p.cell(0, 5, text.upper())273        p.ln(8)274275    def _section_title(self, title):276        if self.pdf.get_y() > 240:277            self.pdf.add_page()278        self._kicker("Groupe KA · " + self.site.get("wordmark", ""))279        self.pdf.set_font("helvetica", "B", 15)280        self.pdf.set_text_color(*INK)281        self.pdf.set_x(self.pdf.l_margin)282        self.pdf.cell(0, 8, title)283        self.toc.append((title, self.pdf.page_no()))284        self.pdf.ln(11)285286    def _chart_title(self, title):287        p = self.pdf288        p.set_font("helvetica", "B", 10)289        p.set_text_color(*INK)290        p.set_x(p.l_margin)291        p.cell(0, 6, title)292        p.ln(7)293294    # ---------- pages ----------295    def _cover(self):296        p = self.pdf297        p.cover_mode = True298        p.set_auto_page_break(False)299        p.add_page()300        p.set_fill_color(*PAPER)301        p.rect(0, 0, 210, 297, style="F")302        p.set_draw_color(*INK)303        p.set_line_width(1.0)304        p.rect(10, 10, 190, 277)305        p.set_font("helvetica", "B", 10)306        p.set_text_color(*GREEN)307        p.set_xy(24, 34)308        p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")309        wm = self.site.get("wordmark", "")310        left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)311        p.set_xy(24, 70)312        p.set_font("helvetica", "B", 40)313        p.set_text_color(*INK)314        p.cell(p.get_string_width(left) + 2, 20, left)315        if boxed:316            bw = p.get_string_width(boxed) + 12317            x = p.get_x() + 2318            p.set_fill_color(*INK)319            p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)320            p.set_text_color(*self.accent)321            p.set_xy(x + 6, 70)322            p.cell(bw - 12, 18, boxed)323        p.set_xy(24, 100)324        p.set_font("helvetica", "", 13)325        p.set_text_color(*INK2)326        p.multi_cell(150, 7, f"{self.mode_label} — {wm}")327        now = datetime.now(ZoneInfo("America/Toronto"))328        per = self.d.get("period", {}) or {}329        p.set_xy(24, 125)330        p.set_font("helvetica", "", 10.5)331        rows = [332            ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),333            ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),334            ("Plateforme", "https://" + self.site.get("domain", "")),335            ("Type de rapport", self.mode_label),336        ]337        y = 128338        for k, v in rows:339            p.set_xy(24, y)340            p.set_text_color(*INK3)341            p.cell(40, 6, k)342            p.set_text_color(*INK)343            p.set_font("helvetica", "B", 10.5)344            p.cell(0, 6, str(v))345            p.set_font("helvetica", "", 10.5)346            y += 8347        p.set_fill_color(*INK)348        p.rect(10, 262, 190, 25, style="F")349        p.set_xy(24, 270)350        p.set_font("helvetica", "B", 12)351        p.set_text_color(*WHITE)352        p.cell(60, 8, "par Groupe ")353        p.set_text_color(*self.accent)354        p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)355        p.cell(20, 8, "KA")356        p.set_font("helvetica", "B", 10)357        p.set_xy(24, 270)358        p.set_text_color(*self.accent)359        p.cell(162, 8, "groupe-ka.com", align="R")360        p.set_auto_page_break(True, margin=22)361        p.cover_mode = False362363    def _kpis(self):364        kpis = self.d.get("kpis") or []365        if not kpis:366            return367        self._section_title("Synthèse des indicateurs")368        p = self.pdf369        cols, gw, gh, gap = 3, 56, 26, 3370        x0, y = p.l_margin, p.get_y()371        for i, k in enumerate(kpis[:12]):372            x = x0 + (i % cols) * (gw + gap)373            if i and i % cols == 0:374                y += gh + gap375            if y > 250:376                p.add_page(); y = p.get_y()377            self._card(x, y, gw, gh)378            p.set_xy(x + 4, y + 4)379            p.set_font("helvetica", "B", 14)380            p.set_text_color(*INK)381            val = k.get("value")382            p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))383            p.set_xy(x + 4, y + 12)384            p.set_font("helvetica", "", 7.6)385            p.set_text_color(*INK2)386            p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])387            if k.get("delta_pct") is not None:388                up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"389                p.set_xy(x + 4, y + gh - 6.5)390                p.set_font("helvetica", "B", 8)391                p.set_text_color(*(GREEN if up else DANGER))392                arrow = "+" if k["delta_pct"] >= 0 else ""393                dv = round(float(k["delta_pct"]), 1)394                dv = int(dv) if float(dv).is_integer() else dv395                p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")396        p.set_y(y + gh + 8)397398    def _gauges(self):399        gs = self.d.get("gauges") or []400        gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]401        if not gs:402            return403        self._section_title("Taux & couvertures")404        p = self.pdf405        cols, gw, gh, gap = 3, 56, 34, 3406        x0, y = p.l_margin, p.get_y()407        for i, g in enumerate(gs[:9]):408            x = x0 + (i % cols) * (gw + gap)409            if i and i % cols == 0:410                y += gh + gap411            if y > 240:412                p.add_page(); y = p.get_y()413            self._card(x, y, gw, gh)414            frac = max(0.0, min(1.0, g["value"] / g["max"]))415            cx, cy, r = x + gw / 2, y + 20, 14416            # arc de fond + arc de valeur (demi-cercle en petits segments)417            for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):418                p.set_draw_color(*pass_col)419                p.set_line_width(lw)420                steps = max(2, int(60 * pass_frac))421                last = None422                for st in range(steps + 1):423                    a = math.pi + math.pi * pass_frac * st / steps424                    pt = (cx + r * math.cos(a), cy + r * math.sin(a))425                    if last:426                        p.line(last[0], last[1], pt[0], pt[1])427                    last = pt428            p.set_font("helvetica", "B", 11)429            p.set_text_color(*INK)430            p.set_xy(x + 4, cy - 5)431            p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")432            p.set_font("helvetica", "", 6.6)433            p.set_text_color(*INK3)434            p.set_xy(x + 4, cy + 1.5)435            p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")436            p.set_xy(x + 3, y + gh - 7)437            p.set_font("helvetica", "", 7)438            p.set_text_color(*INK2)439            p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")440        p.set_y(y + gh + 8)441442    def _serie_stats_row(self, s):443        """Ligne min/max/moyenne/médiane sous un graphique de série."""444        p = self.pdf445        vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]446        if len(vs) < 2:447            return448        sv = sorted(vs)449        mean = sum(vs) / len(vs)450        med = sv[len(sv) // 2]451        sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))452        p.set_font("helvetica", "", 6.8)453        p.set_text_color(*INK3)454        p.cell(0, 4, f"min {_fr(sv[0])} · max {_fr(sv[-1])} · moyenne {_fr(round(mean, 2))} · médiane {_fr(med)} · écart-type {_fr(round(sd, 2))}")455        p.ln(5.5)456457    def _line_chart(self, s, with_stats=False):458        p = self.pdf459        pts = s.get("points") or []460        if len(pts) < 2:461            return462        if s.get("kind") == "bar":463            self._vbars(s)464            return465        if p.get_y() > 200:466            p.add_page()467        self._chart_title(s.get("title", ""))468        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52469        self._card(x0, y0, w, h, fill=WHITE)470        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16471        vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]472        vmax = max(vals) or 1473        vmin = min(0, min(vals))474        rng = (vmax - vmin) or 1475        p.set_font("helvetica", "", 6.3)476        p.set_text_color(*INK3)477        p.set_draw_color(200, 200, 195)478        p.set_line_width(0.15)479        for g in range(5):480            gy = cy + ch - ch * g / 4481            p.line(cx, gy, cx + cw, gy)482            p.set_xy(x0 + 1, gy - 1.6)483            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")484485        def xy(i, n, v):486            return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))487488        # aire sous la courbe (kind=area) : petits trapèzes accent pâle489        if s.get("kind") == "area":490            fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))491            p.set_fill_color(*fill)492            p.set_draw_color(*fill)493            n = len(pts)494            for i in range(n - 1):495                x1, y1 = xy(i, n, pts[i]["v"])496                x2, y2 = xy(i + 1, n, pts[i + 1]["v"])497                p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")498499        def draw(series, color, width, dash=None):500            n = len(series)501            p.set_draw_color(*color)502            p.set_line_width(width)503            if dash:504                p.set_dash_pattern(dash=1.2, gap=1.2)505            last = None506            for i, pt in enumerate(series):507                px, py = xy(i, n, pt["v"])508                if last:509                    p.line(last[0], last[1], px, py)510                last = (px, py)511            p.set_dash_pattern()512513        if s.get("compare"):514            draw(s["compare"], INK3, 0.35, dash=True)515        draw(pts, self.accent, 0.7)516        p.set_text_color(*INK3)517        for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):518            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)519            p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")520        p.set_y(y0 + h + 4)521        if s.get("compare"):522            p.set_font("helvetica", "", 6.8)523            p.set_text_color(*INK3)524            p.cell(0, 4, "— période courante (accent) · ---- période comparée")525            p.ln(5.5)526        if with_stats:527            self._serie_stats_row(s)528        p.ln(1.5)529530    def _vbars(self, s):531        """Barres verticales : série kind=bar ou distribution (bins)."""532        p = self.pdf533        pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]534        pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]535        if not pts:536            return537        if p.get_y() > 205:538            p.add_page()539        self._chart_title(s.get("title", ""))540        x0, y0, w, h = p.l_margin, p.get_y(), 174, 48541        self._card(x0, y0, w, h, fill=WHITE)542        cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14543        vmax = max(pt["v"] for pt in pts) or 1544        p.set_font("helvetica", "", 6.3)545        p.set_text_color(*INK3)546        p.set_draw_color(200, 200, 195)547        p.set_line_width(0.15)548        for g in range(5):549            gy = cy + ch - ch * g / 4550            p.line(cx, gy, cx + cw, gy)551            p.set_xy(x0 + 1, gy - 1.6)552            p.cell(10, 3, _fr(vmax * g / 4), align="R")553        n = len(pts)554        bw = max(0.8, cw / n - 0.6)555        p.set_fill_color(*self.accent)556        p.set_draw_color(*INK)557        p.set_line_width(0.15)558        for i, pt in enumerate(pts):559            bh = ch * (pt["v"] / vmax)560            p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")561        p.set_text_color(*INK3)562        for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):563            p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)564            p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")565        p.set_y(y0 + h + 5)566567    def _multiline(self, ms):568        """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /569        gris pointillé — l'identité passe par le motif, pas la couleur seule."""570        p = self.pdf571        series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]572        if not series:573            return574        if p.get_y() > 195:575            p.add_page()576        self._chart_title(ms.get("title", ""))577        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52578        self._card(x0, y0, w, h, fill=WHITE)579        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16580        vals = [pt["v"] for s in series for pt in s["points"]]581        vmax = max(vals) or 1582        vmin = min(0, min(vals))583        rng = (vmax - vmin) or 1584        p.set_font("helvetica", "", 6.3)585        p.set_text_color(*INK3)586        p.set_draw_color(200, 200, 195)587        p.set_line_width(0.15)588        for g in range(5):589            gy = cy + ch - ch * g / 4590            p.line(cx, gy, cx + cw, gy)591            p.set_xy(x0 + 1, gy - 1.6)592            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")593        styles = [594            (self.accent, 0.7, None),595            (INK, 0.45, None),596            (self.accent, 0.55, True),597            (INK3, 0.5, True),598        ]599        for si, s in enumerate(series):600            col, lw, dash = styles[si]601            p.set_draw_color(*col)602            p.set_line_width(lw)603            if dash:604                p.set_dash_pattern(dash=1.4, gap=1.2)605            n = len(s["points"])606            last = None607            for i, pt in enumerate(s["points"]):608                px = cx + cw * (i / (n - 1))609                py = cy + ch - ch * ((pt["v"] - vmin) / rng)610                if last:611                    p.line(last[0], last[1], px, py)612                last = (px, py)613            p.set_dash_pattern()614        ref = series[0]["points"]615        p.set_text_color(*INK3)616        for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):617            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)618            p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")619        p.set_y(y0 + h + 4)620        p.set_font("helvetica", "", 6.8)621        p.set_text_color(*INK3)622        marks = ["—", "—", "----", "----"]623        leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))624        p.cell(0, 4, leg[:120])625        p.ln(6)626627    def _stacked(self, st):628        p = self.pdf629        keys = (st.get("keys") or [])[:6]630        pts = st.get("points") or []631        if not keys or not pts:632            return633        if p.get_y() > 195:634            p.add_page()635        self._chart_title(st.get("title", ""))636        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52637        self._card(x0, y0, w, h, fill=WHITE)638        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16639        totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]640        vmax = max(totals) or 1641        p.set_font("helvetica", "", 6.3)642        p.set_text_color(*INK3)643        p.set_draw_color(200, 200, 195)644        p.set_line_width(0.15)645        for g in range(5):646            gy = cy + ch - ch * g / 4647            p.line(cx, gy, cx + cw, gy)648            p.set_xy(x0 + 1, gy - 1.6)649            p.cell(10, 3, _fr(vmax * g / 4), align="R")650        n = len(pts)651        bw = max(0.8, cw / n - 0.6)652        p.set_draw_color(*WHITE)653        p.set_line_width(0.12)654        for i, pt in enumerate(pts):655            yacc = cy + ch656            for j, k in enumerate(keys):657                v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0658                if not v:659                    continue660                bh = ch * (v / vmax)661                yacc -= bh662                p.set_fill_color(*self._shade(j))663                p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")664        p.set_text_color(*INK3)665        for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):666            p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)667            p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")668        p.set_y(y0 + h + 4)669        # légende670        p.set_font("helvetica", "", 6.8)671        lx = p.l_margin672        for j, k in enumerate(keys):673            p.set_fill_color(*self._shade(j))674            p.set_draw_color(*INK)675            p.set_line_width(0.2)676            p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")677            p.set_xy(lx + 4, p.get_y())678            p.set_text_color(*INK2)679            txt = str(k)[:22]680            p.cell(p.get_string_width(txt) + 3, 4, txt)681            lx = p.get_x() + 3682            if lx > 165:683                break684        p.ln(7)685686    def _bars(self, title, items, unit=""):687        p = self.pdf688        items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]689        if not items:690            return691        need = 10 + len(items) * 7692        if p.get_y() + need > 265:693            p.add_page()694        self._chart_title(title)695        p.ln(1)696        vmax = max(it["value"] for it in items) or 1697        for it in items:698            y = p.get_y()699            p.set_font("helvetica", "", 7.6)700            p.set_text_color(*INK)701            p.set_x(p.l_margin)702            p.cell(46, 5, str(it["label"])[:34])703            bw = 86 * (it["value"] / vmax)704            p.set_fill_color(*self.accent)705            p.set_draw_color(*INK)706            p.set_line_width(0.25)707            p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")708            p.set_xy(p.l_margin + 136, y)709            p.set_font("helvetica", "B", 7.6)710            p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")711            if it.get("delta_pct") is not None:712                up = it["delta_pct"] >= 0713                p.set_font("helvetica", "B", 6.6)714                p.set_text_color(*(GREEN if up else DANGER))715                p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")716            p.ln(6.4)717        p.ln(3)718719    def _donut(self, b):720        p = self.pdf721        items = [it for it in (b.get("items") or []) if it.get("value")][:8]722        total = sum(it["value"] for it in items)723        if not items or not total:724            return725        if p.get_y() > 210:726            p.add_page()727        self._chart_title(b.get("title", ""))728        p.ln(1)729        cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20730        start = -90.0731        for i, it in enumerate(items):732            frac = it["value"] / total733            col = self._shade(i)734            steps = max(2, int(72 * frac))735            p.set_fill_color(*col)736            p.set_draw_color(*col)737            for st in range(steps):738                a0 = math.radians(start + 360 * frac * st / steps)739                a1 = math.radians(start + 360 * frac * (st + 1) / steps)740                p.polygon(741                    [(cx, cy),742                     (cx + r * math.cos(a0), cy + r * math.sin(a0)),743                     (cx + r * math.cos(a1), cy + r * math.sin(a1))],744                    style="DF",745                )746            start += 360 * frac747        p.set_fill_color(*WHITE)748        p.set_draw_color(*INK)749        p.set_line_width(0.4)750        p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")751        p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")752        ly = cy - 22753        for i, it in enumerate(items):754            col = self._shade(i)755            p.set_fill_color(*col)756            p.set_draw_color(*INK)757            p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")758            p.set_xy(p.l_margin + 66, ly)759            p.set_font("helvetica", "", 7.6)760            p.set_text_color(*INK)761            pct = 100 * it["value"] / total762            p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))763            ly += 5.6764        p.set_y(max(cy + r, ly) + 6)765766    def _hourly(self):767        hh = self.d.get("hourly") or {}768        cells = hh.get("cells") or []769        if not cells:770            return771        p = self.pdf772        if p.get_y() > 190:773            p.add_page()774        self._chart_title(hh.get("title", "Activité par jour et heure"))775        x0, y0 = p.l_margin, p.get_y()776        cw, chh, lx, ly = 6.4, 6.4, 12, 5777        vmax = max((c.get("value") or 0) for c in cells) or 1778        grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}779        dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]780        p.set_font("helvetica", "", 5.8)781        p.set_text_color(*INK3)782        for h in (0, 6, 12, 18, 23):783            p.set_xy(x0 + lx + h * cw, y0)784            p.cell(cw, 3, f"{h}h", align="C")785        for d in range(7):786            p.set_xy(x0, y0 + ly + d * chh + 1.5)787            p.cell(lx - 1, 3, dows[d], align="R")788            for h in range(24):789                v = grid.get((d, h), 0)790                f = 0.1 + 0.9 * (v / vmax) if v else 0.0791                col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)792                p.set_fill_color(*col)793                p.set_draw_color(215, 213, 207)794                p.set_line_width(0.1)795                p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")796        p.set_y(y0 + ly + 7 * chh + 5)797798    def _calheat(self, hm):799        """v3 — calendrier de chaleur 26 semaines (équivalent PDF du800        CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""801        from datetime import date as _date, timedelta as _td802        cells = hm.get("cells") or []803        vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}804        if not vals:805            return806        p = self.pdf807        if p.get_y() > 215:808            p.add_page()809        self._chart_title(hm.get("title", "Calendrier d'activité"))810        try:811            end = _date.fromisoformat(max(vals))812        except ValueError:813            return814        weeks = 26815        start = end - _td(days=weeks * 7 - 1)816        start -= _td(days=start.weekday())  # lundi817        vmax = max(vals.values()) or 1818        x0, y0 = p.l_margin, p.get_y()819        cw, lx, ly = 6.3, 10, 4820        dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]821        p.set_font("helvetica", "", 5.8)822        p.set_text_color(*INK3)823        for d in range(7):824            if dows[d]:825                p.set_xy(x0, y0 + ly + d * cw + 1.2)826                p.cell(lx - 1, 3, dows[d], align="R")827        for w in range(weeks):828            monday = start + _td(days=7 * w)829            if monday.day <= 7:  # étiquette de mois à la 1re semaine du mois830                p.set_xy(x0 + lx + w * cw, y0)831                p.cell(cw * 4, 3, monday.strftime("%m"))832            for d in range(7):833                day = monday + _td(days=d)834                v = vals.get(day.isoformat(), 0)835                f = 0.15 + 0.85 * (v / vmax) if v else 0.0836                col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)837                             for j in range(3)) if v else (235, 233, 228))838                p.set_fill_color(*col)839                p.set_draw_color(215, 213, 207)840                p.set_line_width(0.1)841                p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,842                       style="DF")843        p.set_y(y0 + ly + 7 * cw + 5)844845    # ---------- v3 : conversions bloc → tableau ----------846    @staticmethod847    def _serie_as_table(s):848        unit = s.get("unit") or "Valeur"849        cols = ["Date", unit.capitalize()]850        cmp_ = s.get("compare") or []851        if cmp_:852            cols.append("Période comparée")853        rows = []854        for i, pt in enumerate(s.get("points") or []):855            row = [str(pt.get("t", "")), pt.get("v", "")]856            if cmp_:857                row.append(cmp_[i]["v"] if i < len(cmp_) else "")858            rows.append(row)859        return {"id": s.get("id"), "title": s.get("title", ""),860                "columns": cols, "rows": rows}861862    @staticmethod863    def _multi_as_table(ms):864        labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]865        by_t: dict[str, dict] = {}866        for s in (ms.get("series") or [])[:4]:867            for pt in s.get("points") or []:868                by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")869        rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]870                for t in sorted(by_t)]871        return {"id": ms.get("id"), "title": ms.get("title", ""),872                "columns": ["Date"] + labels, "rows": rows}873874    @staticmethod875    def _stacked_as_table(st):876        keys = (st.get("keys") or [])[:6]877        rows = []878        for pt in st.get("points") or []:879            vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0880                  for j in range(len(keys))]881            rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])882        return {"id": st.get("id"), "title": st.get("title", ""),883                "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}884885    @staticmethod886    def _items_as_table(id_, title, items, label_col="Libellé"):887        items = items or []888        with_delta = any(it.get("delta_pct") is not None for it in items)889        cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])890        rows = []891        for it in items:892            row = [str(it.get("label", "")), it.get("value", "")]893            if with_delta:894                d = it.get("delta_pct")895                row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")896            rows.append(row)897        return {"id": id_, "title": title, "columns": cols, "rows": rows}898899    def _kpis_as_table(self):900        rows = []901        for k in self.d.get("kpis") or []:902            v = k.get("value")903            val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \904                  ((" " + k["unit"]) if k.get("unit") else "")905            d = k.get("delta_pct")906            rows.append([str(k.get("label", "")), val,907                         "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])908        return {"id": "kpis", "title": "Indicateurs clés",909                "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}910911    def _gauges_as_table(self):912        rows = [[str(g.get("label", "")),913                 f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",914                 _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]915                for g in self.d.get("gauges") or []916                if isinstance(g.get("value"), (int, float)) and g.get("max")]917        return {"id": "gauges", "title": "Taux & couvertures",918                "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}919920    def _records_as_table(self):921        rows = [[str(r.get("label", "")), str(r.get("value", "")),922                 str(r.get("date", "") or "")]923                for r in self.d.get("records") or []]924        return {"id": "records", "title": "Records & faits marquants",925                "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}926927    @staticmethod928    def _heatmap_as_table(hm, title):929        cells = sorted((hm.get("cells") or []),930                       key=lambda c: -(c.get("value") or 0))[:40]931        return {"id": "heatmap", "title": title + " — jours les plus chargés",932                "columns": ["Date", "Valeur"],933                "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}934935    @staticmethod936    def _hourly_as_table(hr, title):937        days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",938                "Dimanche"]939        cells = sorted((hr.get("cells") or []),940                       key=lambda c: -(c.get("value") or 0))[:40]941        return {"id": "hourly", "title": title + " — créneaux les plus actifs",942                "columns": ["Jour", "Heure", "Valeur"],943                "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",944                          f"{c.get('hour', '?')} h", c.get("value") or 0]945                         for c in cells]}946947    # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------948    def _find(self, coll: str, id_: str):949        for it in self.d.get(coll) or []:950            if str(it.get("id")) == id_:951                return it952        return None953954    def _toc_mark(self, title: str):955        """Blocs graphiques du mode personnalisé : entrée de sommaire sans956        _section_title (le graphique porte déjà son titre)."""957        if self.pdf.get_y() > 235:958            self.pdf.add_page()959        self.toc.append((title, self.pdf.page_no()))960961    def _render_block(self, key: str, render: str):962        section, _, id_ = key.partition(":")963        if section == "kpis":964            self._table(self._kpis_as_table()) if render == "table" else self._kpis()965        elif section == "gauges":966            self._table(self._gauges_as_table()) if render == "table" else self._gauges()967        elif section == "records":968            self._table(self._records_as_table()) if render == "table" else self._records()969        elif section == "series":970            s = self._find("series", id_)971            if not s:972                return973            if render == "table":974                self._table(self._serie_as_table(s), max_rows=400)975            else:976                s2 = dict(s)977                if render in ("line", "area", "bar"):978                    s2["kind"] = render979                self._toc_mark(s2.get("title", ""))980                if s2.get("kind") == "bar":981                    self._vbars(s2)982                else:983                    self._line_chart(s2, with_stats=True)984        elif section == "multiseries":985            ms = self._find("multiseries", id_)986            if not ms:987                return988            if render == "table":989                self._table(self._multi_as_table(ms), max_rows=400)990            else:991                self._toc_mark(ms.get("title", ""))992                self._multiline(ms)993        elif section == "stacked":994            st = self._find("stacked", id_)995            if not st:996                return997            if render == "table":998                self._table(self._stacked_as_table(st), max_rows=400)999            else:1000                self._toc_mark(st.get("title", ""))1001                self._stacked(st)1002        elif section == "breakdowns":1003            b = self._find("breakdowns", id_)1004            if not b:1005                return1006            if render == "table":1007                self._table(self._items_as_table(id_, b.get("title", ""),1008                                                 b.get("items")), max_rows=400)1009            else:1010                self._toc_mark(b.get("title", ""))1011                if render == "donut":1012                    self._donut(b)1013                else:1014                    self._bars(b.get("title", ""), b.get("items"))1015        elif section == "distributions":1016            d = self._find("distributions", id_)1017            if not d:1018                return1019            if render == "table":1020                bins = [{"label": bn.get("label"), "value": bn.get("value")}1021                        for bn in d.get("bins") or []]1022                self._table(self._items_as_table(id_, d.get("title", ""), bins,1023                                                 label_col="Tranche"))1024            else:1025                self._toc_mark(d.get("title", ""))1026                self._vbars(d)1027        elif section == "geo":1028            geo = self.d.get("geo") or {}1029            if not geo.get("items"):1030                return1031            title = geo.get("title", "Répartition géographique")1032            if render == "table":1033                self._table(self._items_as_table("geo", title, geo["items"],1034                                                 label_col="Zone"), max_rows=400)1035            else:1036                self._toc_mark(title)1037                self._bars(title, geo["items"])1038        elif section == "heatmap":1039            hm = self.d.get("heatmap") or {}1040            if not hm.get("cells"):1041                return1042            title = hm.get("title", "Calendrier d'activité")1043            if render == "table":1044                self._table(self._heatmap_as_table(hm, title))1045            else:1046                self._toc_mark(title)1047                self._calheat(hm)1048        elif section == "hourly":1049            hr = self.d.get("hourly") or {}1050            if not hr.get("cells"):1051                return1052            title = hr.get("title", "Activité par jour et heure")1053            if render == "table":1054                self._table(self._hourly_as_table(hr, title))1055            else:1056                self._toc_mark(title)1057                self._hourly()1058        elif section == "tables":1059            t = self._find("tables", id_)1060            if t:1061                self._table(t, max_rows=400)10621063    def _table(self, t, max_rows=200):1064        p = self.pdf1065        cols = t.get("columns") or []1066        rows = t.get("rows") or []1067        if not cols or not rows:1068            return1069        self._section_title(t.get("title", "Tableau"))1070        w = 174 / len(cols)1071        def head():1072            p.set_font("helvetica", "B", 7.6)1073            p.set_fill_color(*INK)1074            p.set_text_color(*WHITE)1075            for c in cols:1076                p.cell(w, 6, " " + str(c)[:30], fill=True)1077            p.ln(6)1078        head()1079        p.set_text_color(*INK)1080        for i, row in enumerate(rows[:max_rows]):1081            if p.get_y() > 262:1082                p.add_page()1083                head()1084                p.set_text_color(*INK)1085            p.set_font("helvetica", "", 7.4)1086            p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))1087            for cell in row:1088                txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)1089                p.cell(w, 5.4, " " + txt[:34], fill=True)1090            p.ln(5.4)1091        if len(rows) > max_rows:1092            p.set_font("helvetica", "", 7)1093            p.set_text_color(*INK3)1094            p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")1095        p.ln(6)10961097    def _records(self):1098        recs = self.d.get("records") or []1099        if not recs:1100            return1101        self._section_title("Records & faits marquants")1102        p = self.pdf1103        for r in recs[:14]:1104            if p.get_y() > 258:1105                p.add_page()1106            y = p.get_y()1107            self._card(p.l_margin, y, 174, 11, fill=SURFACE2)1108            p.set_xy(p.l_margin + 4, y + 2)1109            p.set_font("helvetica", "", 8.6)1110            p.set_text_color(*INK2)1111            p.cell(96, 7, str(r.get("label", ""))[:70])1112            p.set_font("helvetica", "B", 9)1113            p.set_text_color(*INK)1114            p.cell(52, 7, str(r.get("value", ""))[:36], align="R")1115            p.set_font("helvetica", "", 7.6)1116            p.set_text_color(*INK3)1117            p.cell(20, 7, str(r.get("date", "") or ""), align="R")1118            p.set_y(y + 13.5)1119        p.ln(4)11201121    def _final_page(self):1122        p = self.pdf1123        p.add_page()1124        self._kicker("Groupe KA · contact")1125        p.set_font("helvetica", "B", 15)1126        p.set_text_color(*INK)1127        p.cell(0, 8, "Coordonnées du Groupe KA")1128        p.ln(12)1129        for email, role in EMAILS:1130            p.set_font("helvetica", "B", 10.5)1131            p.set_text_color(*INK)1132            p.cell(0, 6, email)1133            p.ln(5.5)1134            p.set_font("helvetica", "", 8.6)1135            p.set_text_color(*INK3)1136            p.cell(0, 5, role)1137            p.ln(8)1138        p.ln(2)1139        p.set_font("helvetica", "B", 10)1140        p.set_text_color(*GREEN)1141        p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")1142        p.ln(10)1143        p.set_draw_color(*self.accent)1144        p.set_line_width(0.8)1145        p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())1146        p.ln(4)1147        p.set_font("helvetica", "", 8.6)1148        p.set_text_color(*INK2)1149        p.multi_cell(160, 4.6, DISCLAIMER)1150        p.ln(4)1151        p.set_font("helvetica", "", 7.6)1152        p.set_text_color(*INK3)1153        p.multi_cell(1154            160, 4.2,1155            "Mentions : rapport généré automatiquement à partir des données réelles de la "1156            "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "1157            "de confidentialité et protection des renseignements personnels (Loi 25) : "1158            "groupe-ka.com/conditions · /confidentialite · /loi-25.",1159        )11601161    # ---------- groupes de sections ----------1162    def _all_series(self, with_stats=True):1163        for s in self.d.get("series") or []:1164            self._line_chart(s, with_stats=with_stats)1165        for ms in self.d.get("multiseries") or []:1166            self._multiline(ms)1167        for st in self.d.get("stacked") or []:1168            self._stacked(st)11691170    def _all_breakdowns(self):1171        for b in self.d.get("breakdowns") or []:1172            if b.get("kind") == "donut":1173                self._donut(b)1174            else:1175                self._bars(b.get("title", ""), b.get("items"))1176        for dist in self.d.get("distributions") or []:1177            self._vbars(dist)1178        geo = self.d.get("geo")1179        if geo:1180            self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))1181        self._hourly()11821183    def build(self) -> bytes:1184        p = self.pdf1185        p.alias_nb_pages()1186        self._cover()1187        with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)1188        toc_page_no = None1189        if self.mode == "synthese":1190            p.add_page()1191            self._kpis()1192            self._gauges()1193            self._records()1194            self._final_page()1195        elif self.mode == "tendances":1196            p.add_page()1197            self._kpis()1198            self._section_title("Évolution & tendances")1199            self._all_series(with_stats=True)1200            self._records()1201            self._final_page()1202        elif self.mode == "repartitions":1203            p.add_page()1204            self._section_title("Répartitions, distributions & géographie")1205            self._all_breakdowns()1206            self._final_page()1207        elif self.mode == "donnees":1208            p.add_page()1209            toc_page_no = p.page_no()1210            for t in self.d.get("tables") or []:1211                self._table(t, max_rows=400)1212            self._final_page()1213        elif self.mode == CUSTOM_MODE:1214            p.add_page()1215            toc_page_no = p.page_no()1216            p.add_page()1217            known = {b["key"]: b for b in catalog(self.d)}1218            for blk in self.spec.get("blocks") or []:1219                key = str(blk.get("key", ""))1220                b = known.get(key)1221                if not b:1222                    continue1223                render = str(blk.get("render") or "")1224                if render not in b["renders"]:1225                    render = b["default_render"]1226                self._render_block(key, render)1227            self._final_page()1228        else:  # complet1229            p.add_page()1230            toc_page_no = p.page_no()1231            p.add_page()1232            self._kpis()1233            self._gauges()1234            if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):1235                self._section_title("Évolution & tendances")1236                self._all_series(with_stats=True)1237            if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):1238                self._section_title("Répartitions, distributions & géographie")1239                self._all_breakdowns()1240            for t in self.d.get("tables") or []:1241                self._table(t)1242            self._records()1243            self._final_page()1244        # sommaire écrit sur la page réservée1245        if toc_page_no is not None:1246            last_page = p.page1247            p.page = toc_page_no1248            p.set_y(22)1249            p.set_font("helvetica", "B", 15)1250            p.set_text_color(*INK)1251            p.cell(0, 8, "Sommaire")1252            p.ln(12)1253            p.set_font("helvetica", "", 9.5)1254            for title, page_no in self.toc:1255                p.set_text_color(*INK)1256                p.cell(140, 6.5, title[:80])1257                p.set_text_color(*INK3)1258                p.cell(0, 6.5, str(page_no), align="R")1259                p.ln(6.5)1260            p.page = last_page1261        return bytes(p.output())126212631264def filename(platform_id: str, period: str, mode: str = "complet") -> str:1265    today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")1266    suffix = "" if mode in ("", "complet") else f"_{mode}"1267    return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"1268