SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
50.8 KB · 1,276 lines python
Raw Blame History
1# ============================================2# Projet   : API-KA3# Fichier  : src/api/kapdf.py4# Node     : m3u96b5# Author   : Simon-Pierre Boucher6# Contact  : contact@spboucher.ai7# Date     : 2026-08-198# ============================================9# Auteur : Simon-Pierre Boucher — contact@spboucher.ai10# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v311# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit12# les rapports estampillés Groupe-KA. 5 modes fixes :13#   complet      — toutes les sections (KPI, jauges, séries + stats, multi-14#                  séries, empilées, distributions, répartitions, géo,15#                  heatmap horaire, tableaux, records)16#   synthese     — couverture + KPI + records (2-3 pages)17#   tendances    — KPI + toutes les séries temporelles + stats de séries18#   repartitions — breakdowns, distributions, géo, activité horaire19#   donnees      — tous les tableaux en version longue (400 lignes max)20# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par21# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/22# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport23# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",24# "render": "bar"}, …]} et respecte l'ordre demandé.25# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.26# Usage :27#   from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename28#   pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",29#       "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,30#       mode="complet").build()31#   pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",32#       spec={"title": "Mon rapport", "blocks": [...]}).build()33# Dépendance : pip install fpdf2  (aucune autre)34from __future__ import annotations3536import math37from datetime import datetime38from zoneinfo import ZoneInfo3940from fpdf import FPDF4142INK = (20, 24, 20)43INK2 = (77, 85, 81)44INK3 = (139, 146, 140)45PAPER = (245, 243, 238)46SURFACE2 = (250, 249, 245)47GREEN = (28, 92, 65)48DANGER = (179, 66, 58)49WHITE = (255, 255, 255)5051REPORT_MODES = {52    "complet": "Rapport complet",53    "synthese": "Synthèse exécutive",54    "tendances": "Tendances & évolution",55    "repartitions": "Répartitions & géographie",56    "donnees": "Données détaillées",57}58# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)59CUSTOM_MODE = "personnalise"60CUSTOM_LABEL = "Rapport personnalisé"6162# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;63# « table » est toujours offert : toute donnée a un équivalent tableau)64RENDER_LABELS = {65    "line": "Courbe", "area": "Aire", "bar": "Barres verticales",66    "bars": "Barres horizontales", "donut": "Anneau",67    "lines": "Multi-courbes", "stacked": "Barres empilées",68    "histogram": "Histogramme", "heatmap": "Heatmap",69    "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",70}71SECTION_LABELS = {72    "kpis": "Indicateurs", "gauges": "Taux & couvertures",73    "series": "Évolution", "multiseries": "Comparaisons",74    "stacked": "Compositions", "breakdowns": "Répartitions",75    "distributions": "Distributions", "geo": "Géographie",76    "heatmap": "Calendrier", "hourly": "Activité horaire",77    "tables": "Tableaux", "records": "Records",78}798081def catalog(dash: dict) -> list[dict]:82    """v3 — blocs composables d'un dashboard : ce que le constructeur de83    rapports personnalisés peut inclure, avec les rendus compatibles.84    key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""85    out: list[dict] = []8687    def add(key, title, renders, default=None, count=None):88        b = {"key": key, "section": key.split(":")[0], "title": title,89             "renders": renders, "default_render": default or renders[0]}90        if count is not None:91            b["count"] = count92        out.append(b)9394    if dash.get("kpis"):95        add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],96            count=len(dash["kpis"]))97    gs = [g for g in (dash.get("gauges") or [])98          if isinstance(g.get("value"), (int, float)) and g.get("max")]99    if gs:100        add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],101            count=len(gs))102    for s in dash.get("series") or []:103        if len(s.get("points") or []) < 2:104            continue105        kind = s.get("kind") or "line"106        default = kind if kind in ("line", "area", "bar") else "line"107        add(f"series:{s.get('id')}", s.get("title", ""),108            ["line", "area", "bar", "table"], default,109            len(s.get("points") or []))110    for ms in dash.get("multiseries") or []:111        if not (ms.get("series") or []):112            continue113        add(f"multiseries:{ms.get('id')}", ms.get("title", ""),114            ["lines", "table"], count=len(ms["series"]))115    for st in dash.get("stacked") or []:116        if not (st.get("points") or []):117            continue118        add(f"stacked:{st.get('id')}", st.get("title", ""),119            ["stacked", "table"], count=len(st.get("keys") or []))120    for b in dash.get("breakdowns") or []:121        if not (b.get("items") or []):122            continue123        default = "donut" if b.get("kind") == "donut" else "bars"124        add(f"breakdowns:{b.get('id')}", b.get("title", ""),125            ["donut", "bars", "table"], default, len(b["items"]))126    for d in dash.get("distributions") or []:127        if not (d.get("bins") or []):128            continue129        add(f"distributions:{d.get('id')}", d.get("title", ""),130            ["histogram", "table"], count=len(d["bins"]))131    geo = dash.get("geo") or {}132    if geo.get("items"):133        add("geo", geo.get("title", "Répartition géographique"),134            ["bars", "table"], count=len(geo["items"]))135    hm = dash.get("heatmap") or {}136    if hm.get("cells"):137        add("heatmap", hm.get("title", "Calendrier d'activité"),138            ["heatmap", "table"])139    hr = dash.get("hourly") or {}140    if hr.get("cells"):141        add("hourly", hr.get("title", "Activité par jour et heure"),142            ["heatmap", "table"])143    for t in dash.get("tables") or []:144        if not (t.get("rows") or []):145            continue146        add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],147            count=len(t["rows"]))148    if dash.get("records"):149        add("records", "Records & faits marquants", ["cards", "table"],150            count=len(dash["records"]))151    return out152153EMAILS = [154    ("contact@groupe-ka.com", "Projets, partenariats & données"),155    ("info@groupe-ka.com", "Médias & questions générales"),156    ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),157]158DISCLAIMER = (159    "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "160    "rien et ne sommes partie à aucune transaction. Données lues à la source, "161    "rien d'inventé, tout est traçable."162)163164165def _hex(c: str) -> tuple[int, int, int]:166    c = c.lstrip("#")167    return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4))  # type: ignore168169170def _fr(n) -> str:171    if isinstance(n, float) and not n.is_integer():172        return f"{n:,.2f}".replace(",", " ").replace(".", ",")173    return f"{int(n):,}".replace(",", " ")174175176_SUBST = {177    "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",178    "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',179    "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",180    "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",181}182183184def _latin1(s: str) -> str:185    for k, v in _SUBST.items():186        s = s.replace(k, v)187    return s.encode("latin-1", "replace").decode("latin-1")188189190class _PDF(FPDF):191    """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).192    Les polices core sont latin-1 : normalize_text sanitise en amont."""193194    def normalize_text(self, text):195        return super().normalize_text(_latin1(text))196197    def __init__(self, brand: str, accent: tuple, period_label: str):198        super().__init__(orientation="P", unit="mm", format="A4")199        self.brand = brand200        self.accent = accent201        self.period_label = period_label202        self.cover_mode = False203        self.set_margins(18, 20, 18)204        self.set_auto_page_break(True, margin=22)205206    def header(self):207        if self.cover_mode or self.page_no() == 1:208            return209        self.set_font("helvetica", "B", 8.5)210        self.set_text_color(*INK)211        self.set_xy(18, 9)212        self.cell(0, 5, f"Groupe KA · {self.brand}")213        self.set_font("helvetica", "", 8)214        self.set_text_color(*INK3)215        self.set_xy(18, 9)216        self.cell(0, 5, "Rapport statistique", align="R")217        self.set_draw_color(*INK)218        self.set_line_width(0.5)219        self.line(18, 15.5, 192, 15.5)220        self.set_y(20)221222    def footer(self):223        # page 1 = couverture (le flag cover_mode est déjà retombé quand224        # add_page() clôt la page 1 → tester aussi le numéro de page)225        if self.cover_mode or self.page_no() == 1:226            return227        self.set_y(-15)228        self.set_draw_color(*INK3)229        self.set_line_width(0.2)230        self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)231        self.set_font("helvetica", "", 7.5)232        self.set_text_color(*INK3)233        year = datetime.now(ZoneInfo("America/Toronto")).year234        self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")235        self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")236237238class GroupeKAReport:239    def __init__(self, site: dict, dashboard: dict, mode: str = "complet",240                 spec: dict | None = None):241        self.site = site242        self.d = dashboard243        self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"244        self.spec = spec or {}245        self.accent = _hex(site.get("accent", "#d9f26b"))246        period = dashboard.get("period", {}) or {}247        self.period_label = period.get("label") or "toute la période"248        self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)249        self.toc: list[tuple[str, int]] = []250251    @property252    def mode_label(self) -> str:253        if self.mode == CUSTOM_MODE:254            t = str(self.spec.get("title") or "").strip()255            return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL256        return REPORT_MODES[self.mode]257258    # ---------- primitives ----------259    def _card(self, x, y, w, h, fill=WHITE):260        p = self.pdf261        p.set_draw_color(*INK)262        p.set_line_width(0.45)263        p.set_fill_color(*fill)264        p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)265266    def _shade(self, i, n=8):267        shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]268        f = shades[i % len(shades)]269        return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))270271    def _kicker(self, text):272        p = self.pdf273        p.set_font("helvetica", "B", 8)274        p.set_text_color(*GREEN)275        p.set_draw_color(*GREEN)276        p.set_line_width(0.6)277        y = p.get_y() + 2278        p.line(p.l_margin, y, p.l_margin + 7, y)279        p.set_xy(p.l_margin + 9, y - 2.5)280        p.cell(0, 5, text.upper())281        p.ln(8)282283    def _section_title(self, title):284        if self.pdf.get_y() > 240:285            self.pdf.add_page()286        self._kicker("Groupe KA · " + self.site.get("wordmark", ""))287        self.pdf.set_font("helvetica", "B", 15)288        self.pdf.set_text_color(*INK)289        self.pdf.set_x(self.pdf.l_margin)290        self.pdf.cell(0, 8, title)291        self.toc.append((title, self.pdf.page_no()))292        self.pdf.ln(11)293294    def _chart_title(self, title):295        p = self.pdf296        p.set_font("helvetica", "B", 10)297        p.set_text_color(*INK)298        p.set_x(p.l_margin)299        p.cell(0, 6, title)300        p.ln(7)301302    # ---------- pages ----------303    def _cover(self):304        p = self.pdf305        p.cover_mode = True306        p.set_auto_page_break(False)307        p.add_page()308        p.set_fill_color(*PAPER)309        p.rect(0, 0, 210, 297, style="F")310        p.set_draw_color(*INK)311        p.set_line_width(1.0)312        p.rect(10, 10, 190, 277)313        p.set_font("helvetica", "B", 10)314        p.set_text_color(*GREEN)315        p.set_xy(24, 34)316        p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")317        wm = self.site.get("wordmark", "")318        left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)319        p.set_xy(24, 70)320        p.set_font("helvetica", "B", 40)321        p.set_text_color(*INK)322        p.cell(p.get_string_width(left) + 2, 20, left)323        if boxed:324            bw = p.get_string_width(boxed) + 12325            x = p.get_x() + 2326            p.set_fill_color(*INK)327            p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)328            p.set_text_color(*self.accent)329            p.set_xy(x + 6, 70)330            p.cell(bw - 12, 18, boxed)331        p.set_xy(24, 100)332        p.set_font("helvetica", "", 13)333        p.set_text_color(*INK2)334        p.multi_cell(150, 7, f"{self.mode_label} — {wm}")335        now = datetime.now(ZoneInfo("America/Toronto"))336        per = self.d.get("period", {}) or {}337        p.set_xy(24, 125)338        p.set_font("helvetica", "", 10.5)339        rows = [340            ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),341            ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),342            ("Plateforme", "https://" + self.site.get("domain", "")),343            ("Type de rapport", self.mode_label),344        ]345        y = 128346        for k, v in rows:347            p.set_xy(24, y)348            p.set_text_color(*INK3)349            p.cell(40, 6, k)350            p.set_text_color(*INK)351            p.set_font("helvetica", "B", 10.5)352            p.cell(0, 6, str(v))353            p.set_font("helvetica", "", 10.5)354            y += 8355        p.set_fill_color(*INK)356        p.rect(10, 262, 190, 25, style="F")357        p.set_xy(24, 270)358        p.set_font("helvetica", "B", 12)359        p.set_text_color(*WHITE)360        p.cell(60, 8, "par Groupe ")361        p.set_text_color(*self.accent)362        p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)363        p.cell(20, 8, "KA")364        p.set_font("helvetica", "B", 10)365        p.set_xy(24, 270)366        p.set_text_color(*self.accent)367        p.cell(162, 8, "groupe-ka.com", align="R")368        p.set_auto_page_break(True, margin=22)369        p.cover_mode = False370371    def _kpis(self):372        kpis = self.d.get("kpis") or []373        if not kpis:374            return375        self._section_title("Synthèse des indicateurs")376        p = self.pdf377        cols, gw, gh, gap = 3, 56, 26, 3378        x0, y = p.l_margin, p.get_y()379        for i, k in enumerate(kpis[:12]):380            x = x0 + (i % cols) * (gw + gap)381            if i and i % cols == 0:382                y += gh + gap383            if y > 250:384                p.add_page(); y = p.get_y()385            self._card(x, y, gw, gh)386            p.set_xy(x + 4, y + 4)387            p.set_font("helvetica", "B", 14)388            p.set_text_color(*INK)389            val = k.get("value")390            p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))391            p.set_xy(x + 4, y + 12)392            p.set_font("helvetica", "", 7.6)393            p.set_text_color(*INK2)394            p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])395            if k.get("delta_pct") is not None:396                up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"397                p.set_xy(x + 4, y + gh - 6.5)398                p.set_font("helvetica", "B", 8)399                p.set_text_color(*(GREEN if up else DANGER))400                arrow = "+" if k["delta_pct"] >= 0 else ""401                dv = round(float(k["delta_pct"]), 1)402                dv = int(dv) if float(dv).is_integer() else dv403                p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")404        p.set_y(y + gh + 8)405406    def _gauges(self):407        gs = self.d.get("gauges") or []408        gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]409        if not gs:410            return411        self._section_title("Taux & couvertures")412        p = self.pdf413        cols, gw, gh, gap = 3, 56, 34, 3414        x0, y = p.l_margin, p.get_y()415        for i, g in enumerate(gs[:9]):416            x = x0 + (i % cols) * (gw + gap)417            if i and i % cols == 0:418                y += gh + gap419            if y > 240:420                p.add_page(); y = p.get_y()421            self._card(x, y, gw, gh)422            frac = max(0.0, min(1.0, g["value"] / g["max"]))423            cx, cy, r = x + gw / 2, y + 20, 14424            # arc de fond + arc de valeur (demi-cercle en petits segments)425            for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):426                p.set_draw_color(*pass_col)427                p.set_line_width(lw)428                steps = max(2, int(60 * pass_frac))429                last = None430                for st in range(steps + 1):431                    a = math.pi + math.pi * pass_frac * st / steps432                    pt = (cx + r * math.cos(a), cy + r * math.sin(a))433                    if last:434                        p.line(last[0], last[1], pt[0], pt[1])435                    last = pt436            p.set_font("helvetica", "B", 11)437            p.set_text_color(*INK)438            p.set_xy(x + 4, cy - 5)439            p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")440            p.set_font("helvetica", "", 6.6)441            p.set_text_color(*INK3)442            p.set_xy(x + 4, cy + 1.5)443            p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")444            p.set_xy(x + 3, y + gh - 7)445            p.set_font("helvetica", "", 7)446            p.set_text_color(*INK2)447            p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")448        p.set_y(y + gh + 8)449450    def _serie_stats_row(self, s):451        """Ligne min/max/moyenne/médiane sous un graphique de série."""452        p = self.pdf453        vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]454        if len(vs) < 2:455            return456        sv = sorted(vs)457        mean = sum(vs) / len(vs)458        med = sv[len(sv) // 2]459        sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))460        p.set_font("helvetica", "", 6.8)461        p.set_text_color(*INK3)462        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))}")463        p.ln(5.5)464465    def _line_chart(self, s, with_stats=False):466        p = self.pdf467        pts = s.get("points") or []468        if len(pts) < 2:469            return470        if s.get("kind") == "bar":471            self._vbars(s)472            return473        if p.get_y() > 200:474            p.add_page()475        self._chart_title(s.get("title", ""))476        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52477        self._card(x0, y0, w, h, fill=WHITE)478        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16479        vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]480        vmax = max(vals) or 1481        vmin = min(0, min(vals))482        rng = (vmax - vmin) or 1483        p.set_font("helvetica", "", 6.3)484        p.set_text_color(*INK3)485        p.set_draw_color(200, 200, 195)486        p.set_line_width(0.15)487        for g in range(5):488            gy = cy + ch - ch * g / 4489            p.line(cx, gy, cx + cw, gy)490            p.set_xy(x0 + 1, gy - 1.6)491            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")492493        def xy(i, n, v):494            return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))495496        # aire sous la courbe (kind=area) : petits trapèzes accent pâle497        if s.get("kind") == "area":498            fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))499            p.set_fill_color(*fill)500            p.set_draw_color(*fill)501            n = len(pts)502            for i in range(n - 1):503                x1, y1 = xy(i, n, pts[i]["v"])504                x2, y2 = xy(i + 1, n, pts[i + 1]["v"])505                p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")506507        def draw(series, color, width, dash=None):508            n = len(series)509            p.set_draw_color(*color)510            p.set_line_width(width)511            if dash:512                p.set_dash_pattern(dash=1.2, gap=1.2)513            last = None514            for i, pt in enumerate(series):515                px, py = xy(i, n, pt["v"])516                if last:517                    p.line(last[0], last[1], px, py)518                last = (px, py)519            p.set_dash_pattern()520521        if s.get("compare"):522            draw(s["compare"], INK3, 0.35, dash=True)523        draw(pts, self.accent, 0.7)524        p.set_text_color(*INK3)525        for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):526            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)527            p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")528        p.set_y(y0 + h + 4)529        if s.get("compare"):530            p.set_font("helvetica", "", 6.8)531            p.set_text_color(*INK3)532            p.cell(0, 4, "— période courante (accent) · ---- période comparée")533            p.ln(5.5)534        if with_stats:535            self._serie_stats_row(s)536        p.ln(1.5)537538    def _vbars(self, s):539        """Barres verticales : série kind=bar ou distribution (bins)."""540        p = self.pdf541        pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]542        pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]543        if not pts:544            return545        if p.get_y() > 205:546            p.add_page()547        self._chart_title(s.get("title", ""))548        x0, y0, w, h = p.l_margin, p.get_y(), 174, 48549        self._card(x0, y0, w, h, fill=WHITE)550        cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14551        vmax = max(pt["v"] for pt in pts) or 1552        p.set_font("helvetica", "", 6.3)553        p.set_text_color(*INK3)554        p.set_draw_color(200, 200, 195)555        p.set_line_width(0.15)556        for g in range(5):557            gy = cy + ch - ch * g / 4558            p.line(cx, gy, cx + cw, gy)559            p.set_xy(x0 + 1, gy - 1.6)560            p.cell(10, 3, _fr(vmax * g / 4), align="R")561        n = len(pts)562        bw = max(0.8, cw / n - 0.6)563        p.set_fill_color(*self.accent)564        p.set_draw_color(*INK)565        p.set_line_width(0.15)566        for i, pt in enumerate(pts):567            bh = ch * (pt["v"] / vmax)568            p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")569        p.set_text_color(*INK3)570        for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):571            p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)572            p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")573        p.set_y(y0 + h + 5)574575    def _multiline(self, ms):576        """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /577        gris pointillé — l'identité passe par le motif, pas la couleur seule."""578        p = self.pdf579        series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]580        if not series:581            return582        if p.get_y() > 195:583            p.add_page()584        self._chart_title(ms.get("title", ""))585        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52586        self._card(x0, y0, w, h, fill=WHITE)587        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16588        vals = [pt["v"] for s in series for pt in s["points"]]589        vmax = max(vals) or 1590        vmin = min(0, min(vals))591        rng = (vmax - vmin) or 1592        p.set_font("helvetica", "", 6.3)593        p.set_text_color(*INK3)594        p.set_draw_color(200, 200, 195)595        p.set_line_width(0.15)596        for g in range(5):597            gy = cy + ch - ch * g / 4598            p.line(cx, gy, cx + cw, gy)599            p.set_xy(x0 + 1, gy - 1.6)600            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")601        styles = [602            (self.accent, 0.7, None),603            (INK, 0.45, None),604            (self.accent, 0.55, True),605            (INK3, 0.5, True),606        ]607        for si, s in enumerate(series):608            col, lw, dash = styles[si]609            p.set_draw_color(*col)610            p.set_line_width(lw)611            if dash:612                p.set_dash_pattern(dash=1.4, gap=1.2)613            n = len(s["points"])614            last = None615            for i, pt in enumerate(s["points"]):616                px = cx + cw * (i / (n - 1))617                py = cy + ch - ch * ((pt["v"] - vmin) / rng)618                if last:619                    p.line(last[0], last[1], px, py)620                last = (px, py)621            p.set_dash_pattern()622        ref = series[0]["points"]623        p.set_text_color(*INK3)624        for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):625            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)626            p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")627        p.set_y(y0 + h + 4)628        p.set_font("helvetica", "", 6.8)629        p.set_text_color(*INK3)630        marks = ["—", "—", "----", "----"]631        leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))632        p.cell(0, 4, leg[:120])633        p.ln(6)634635    def _stacked(self, st):636        p = self.pdf637        keys = (st.get("keys") or [])[:6]638        pts = st.get("points") or []639        if not keys or not pts:640            return641        if p.get_y() > 195:642            p.add_page()643        self._chart_title(st.get("title", ""))644        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52645        self._card(x0, y0, w, h, fill=WHITE)646        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16647        totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]648        vmax = max(totals) or 1649        p.set_font("helvetica", "", 6.3)650        p.set_text_color(*INK3)651        p.set_draw_color(200, 200, 195)652        p.set_line_width(0.15)653        for g in range(5):654            gy = cy + ch - ch * g / 4655            p.line(cx, gy, cx + cw, gy)656            p.set_xy(x0 + 1, gy - 1.6)657            p.cell(10, 3, _fr(vmax * g / 4), align="R")658        n = len(pts)659        bw = max(0.8, cw / n - 0.6)660        p.set_draw_color(*WHITE)661        p.set_line_width(0.12)662        for i, pt in enumerate(pts):663            yacc = cy + ch664            for j, k in enumerate(keys):665                v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0666                if not v:667                    continue668                bh = ch * (v / vmax)669                yacc -= bh670                p.set_fill_color(*self._shade(j))671                p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")672        p.set_text_color(*INK3)673        for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):674            p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)675            p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")676        p.set_y(y0 + h + 4)677        # légende678        p.set_font("helvetica", "", 6.8)679        lx = p.l_margin680        for j, k in enumerate(keys):681            p.set_fill_color(*self._shade(j))682            p.set_draw_color(*INK)683            p.set_line_width(0.2)684            p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")685            p.set_xy(lx + 4, p.get_y())686            p.set_text_color(*INK2)687            txt = str(k)[:22]688            p.cell(p.get_string_width(txt) + 3, 4, txt)689            lx = p.get_x() + 3690            if lx > 165:691                break692        p.ln(7)693694    def _bars(self, title, items, unit=""):695        p = self.pdf696        items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]697        if not items:698            return699        need = 10 + len(items) * 7700        if p.get_y() + need > 265:701            p.add_page()702        self._chart_title(title)703        p.ln(1)704        vmax = max(it["value"] for it in items) or 1705        for it in items:706            y = p.get_y()707            p.set_font("helvetica", "", 7.6)708            p.set_text_color(*INK)709            p.set_x(p.l_margin)710            p.cell(46, 5, str(it["label"])[:34])711            bw = 86 * (it["value"] / vmax)712            p.set_fill_color(*self.accent)713            p.set_draw_color(*INK)714            p.set_line_width(0.25)715            p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")716            p.set_xy(p.l_margin + 136, y)717            p.set_font("helvetica", "B", 7.6)718            p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")719            if it.get("delta_pct") is not None:720                up = it["delta_pct"] >= 0721                p.set_font("helvetica", "B", 6.6)722                p.set_text_color(*(GREEN if up else DANGER))723                p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")724            p.ln(6.4)725        p.ln(3)726727    def _donut(self, b):728        p = self.pdf729        items = [it for it in (b.get("items") or []) if it.get("value")][:8]730        total = sum(it["value"] for it in items)731        if not items or not total:732            return733        if p.get_y() > 210:734            p.add_page()735        self._chart_title(b.get("title", ""))736        p.ln(1)737        cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20738        start = -90.0739        for i, it in enumerate(items):740            frac = it["value"] / total741            col = self._shade(i)742            steps = max(2, int(72 * frac))743            p.set_fill_color(*col)744            p.set_draw_color(*col)745            for st in range(steps):746                a0 = math.radians(start + 360 * frac * st / steps)747                a1 = math.radians(start + 360 * frac * (st + 1) / steps)748                p.polygon(749                    [(cx, cy),750                     (cx + r * math.cos(a0), cy + r * math.sin(a0)),751                     (cx + r * math.cos(a1), cy + r * math.sin(a1))],752                    style="DF",753                )754            start += 360 * frac755        p.set_fill_color(*WHITE)756        p.set_draw_color(*INK)757        p.set_line_width(0.4)758        p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")759        p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")760        ly = cy - 22761        for i, it in enumerate(items):762            col = self._shade(i)763            p.set_fill_color(*col)764            p.set_draw_color(*INK)765            p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")766            p.set_xy(p.l_margin + 66, ly)767            p.set_font("helvetica", "", 7.6)768            p.set_text_color(*INK)769            pct = 100 * it["value"] / total770            p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))771            ly += 5.6772        p.set_y(max(cy + r, ly) + 6)773774    def _hourly(self):775        hh = self.d.get("hourly") or {}776        cells = hh.get("cells") or []777        if not cells:778            return779        p = self.pdf780        if p.get_y() > 190:781            p.add_page()782        self._chart_title(hh.get("title", "Activité par jour et heure"))783        x0, y0 = p.l_margin, p.get_y()784        cw, chh, lx, ly = 6.4, 6.4, 12, 5785        vmax = max((c.get("value") or 0) for c in cells) or 1786        grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}787        dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]788        p.set_font("helvetica", "", 5.8)789        p.set_text_color(*INK3)790        for h in (0, 6, 12, 18, 23):791            p.set_xy(x0 + lx + h * cw, y0)792            p.cell(cw, 3, f"{h}h", align="C")793        for d in range(7):794            p.set_xy(x0, y0 + ly + d * chh + 1.5)795            p.cell(lx - 1, 3, dows[d], align="R")796            for h in range(24):797                v = grid.get((d, h), 0)798                f = 0.1 + 0.9 * (v / vmax) if v else 0.0799                col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)800                p.set_fill_color(*col)801                p.set_draw_color(215, 213, 207)802                p.set_line_width(0.1)803                p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")804        p.set_y(y0 + ly + 7 * chh + 5)805806    def _calheat(self, hm):807        """v3 — calendrier de chaleur 26 semaines (équivalent PDF du808        CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""809        from datetime import date as _date, timedelta as _td810        cells = hm.get("cells") or []811        vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}812        if not vals:813            return814        p = self.pdf815        if p.get_y() > 215:816            p.add_page()817        self._chart_title(hm.get("title", "Calendrier d'activité"))818        try:819            end = _date.fromisoformat(max(vals))820        except ValueError:821            return822        weeks = 26823        start = end - _td(days=weeks * 7 - 1)824        start -= _td(days=start.weekday())  # lundi825        vmax = max(vals.values()) or 1826        x0, y0 = p.l_margin, p.get_y()827        cw, lx, ly = 6.3, 10, 4828        dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]829        p.set_font("helvetica", "", 5.8)830        p.set_text_color(*INK3)831        for d in range(7):832            if dows[d]:833                p.set_xy(x0, y0 + ly + d * cw + 1.2)834                p.cell(lx - 1, 3, dows[d], align="R")835        for w in range(weeks):836            monday = start + _td(days=7 * w)837            if monday.day <= 7:  # étiquette de mois à la 1re semaine du mois838                p.set_xy(x0 + lx + w * cw, y0)839                p.cell(cw * 4, 3, monday.strftime("%m"))840            for d in range(7):841                day = monday + _td(days=d)842                v = vals.get(day.isoformat(), 0)843                f = 0.15 + 0.85 * (v / vmax) if v else 0.0844                col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)845                             for j in range(3)) if v else (235, 233, 228))846                p.set_fill_color(*col)847                p.set_draw_color(215, 213, 207)848                p.set_line_width(0.1)849                p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,850                       style="DF")851        p.set_y(y0 + ly + 7 * cw + 5)852853    # ---------- v3 : conversions bloc → tableau ----------854    @staticmethod855    def _serie_as_table(s):856        unit = s.get("unit") or "Valeur"857        cols = ["Date", unit.capitalize()]858        cmp_ = s.get("compare") or []859        if cmp_:860            cols.append("Période comparée")861        rows = []862        for i, pt in enumerate(s.get("points") or []):863            row = [str(pt.get("t", "")), pt.get("v", "")]864            if cmp_:865                row.append(cmp_[i]["v"] if i < len(cmp_) else "")866            rows.append(row)867        return {"id": s.get("id"), "title": s.get("title", ""),868                "columns": cols, "rows": rows}869870    @staticmethod871    def _multi_as_table(ms):872        labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]873        by_t: dict[str, dict] = {}874        for s in (ms.get("series") or [])[:4]:875            for pt in s.get("points") or []:876                by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")877        rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]878                for t in sorted(by_t)]879        return {"id": ms.get("id"), "title": ms.get("title", ""),880                "columns": ["Date"] + labels, "rows": rows}881882    @staticmethod883    def _stacked_as_table(st):884        keys = (st.get("keys") or [])[:6]885        rows = []886        for pt in st.get("points") or []:887            vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0888                  for j in range(len(keys))]889            rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])890        return {"id": st.get("id"), "title": st.get("title", ""),891                "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}892893    @staticmethod894    def _items_as_table(id_, title, items, label_col="Libellé"):895        items = items or []896        with_delta = any(it.get("delta_pct") is not None for it in items)897        cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])898        rows = []899        for it in items:900            row = [str(it.get("label", "")), it.get("value", "")]901            if with_delta:902                d = it.get("delta_pct")903                row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")904            rows.append(row)905        return {"id": id_, "title": title, "columns": cols, "rows": rows}906907    def _kpis_as_table(self):908        rows = []909        for k in self.d.get("kpis") or []:910            v = k.get("value")911            val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \912                  ((" " + k["unit"]) if k.get("unit") else "")913            d = k.get("delta_pct")914            rows.append([str(k.get("label", "")), val,915                         "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])916        return {"id": "kpis", "title": "Indicateurs clés",917                "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}918919    def _gauges_as_table(self):920        rows = [[str(g.get("label", "")),921                 f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",922                 _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]923                for g in self.d.get("gauges") or []924                if isinstance(g.get("value"), (int, float)) and g.get("max")]925        return {"id": "gauges", "title": "Taux & couvertures",926                "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}927928    def _records_as_table(self):929        rows = [[str(r.get("label", "")), str(r.get("value", "")),930                 str(r.get("date", "") or "")]931                for r in self.d.get("records") or []]932        return {"id": "records", "title": "Records & faits marquants",933                "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}934935    @staticmethod936    def _heatmap_as_table(hm, title):937        cells = sorted((hm.get("cells") or []),938                       key=lambda c: -(c.get("value") or 0))[:40]939        return {"id": "heatmap", "title": title + " — jours les plus chargés",940                "columns": ["Date", "Valeur"],941                "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}942943    @staticmethod944    def _hourly_as_table(hr, title):945        days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",946                "Dimanche"]947        cells = sorted((hr.get("cells") or []),948                       key=lambda c: -(c.get("value") or 0))[:40]949        return {"id": "hourly", "title": title + " — créneaux les plus actifs",950                "columns": ["Jour", "Heure", "Valeur"],951                "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",952                          f"{c.get('hour', '?')} h", c.get("value") or 0]953                         for c in cells]}954955    # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------956    def _find(self, coll: str, id_: str):957        for it in self.d.get(coll) or []:958            if str(it.get("id")) == id_:959                return it960        return None961962    def _toc_mark(self, title: str):963        """Blocs graphiques du mode personnalisé : entrée de sommaire sans964        _section_title (le graphique porte déjà son titre)."""965        if self.pdf.get_y() > 235:966            self.pdf.add_page()967        self.toc.append((title, self.pdf.page_no()))968969    def _render_block(self, key: str, render: str):970        section, _, id_ = key.partition(":")971        if section == "kpis":972            self._table(self._kpis_as_table()) if render == "table" else self._kpis()973        elif section == "gauges":974            self._table(self._gauges_as_table()) if render == "table" else self._gauges()975        elif section == "records":976            self._table(self._records_as_table()) if render == "table" else self._records()977        elif section == "series":978            s = self._find("series", id_)979            if not s:980                return981            if render == "table":982                self._table(self._serie_as_table(s), max_rows=400)983            else:984                s2 = dict(s)985                if render in ("line", "area", "bar"):986                    s2["kind"] = render987                self._toc_mark(s2.get("title", ""))988                if s2.get("kind") == "bar":989                    self._vbars(s2)990                else:991                    self._line_chart(s2, with_stats=True)992        elif section == "multiseries":993            ms = self._find("multiseries", id_)994            if not ms:995                return996            if render == "table":997                self._table(self._multi_as_table(ms), max_rows=400)998            else:999                self._toc_mark(ms.get("title", ""))1000                self._multiline(ms)1001        elif section == "stacked":1002            st = self._find("stacked", id_)1003            if not st:1004                return1005            if render == "table":1006                self._table(self._stacked_as_table(st), max_rows=400)1007            else:1008                self._toc_mark(st.get("title", ""))1009                self._stacked(st)1010        elif section == "breakdowns":1011            b = self._find("breakdowns", id_)1012            if not b:1013                return1014            if render == "table":1015                self._table(self._items_as_table(id_, b.get("title", ""),1016                                                 b.get("items")), max_rows=400)1017            else:1018                self._toc_mark(b.get("title", ""))1019                if render == "donut":1020                    self._donut(b)1021                else:1022                    self._bars(b.get("title", ""), b.get("items"))1023        elif section == "distributions":1024            d = self._find("distributions", id_)1025            if not d:1026                return1027            if render == "table":1028                bins = [{"label": bn.get("label"), "value": bn.get("value")}1029                        for bn in d.get("bins") or []]1030                self._table(self._items_as_table(id_, d.get("title", ""), bins,1031                                                 label_col="Tranche"))1032            else:1033                self._toc_mark(d.get("title", ""))1034                self._vbars(d)1035        elif section == "geo":1036            geo = self.d.get("geo") or {}1037            if not geo.get("items"):1038                return1039            title = geo.get("title", "Répartition géographique")1040            if render == "table":1041                self._table(self._items_as_table("geo", title, geo["items"],1042                                                 label_col="Zone"), max_rows=400)1043            else:1044                self._toc_mark(title)1045                self._bars(title, geo["items"])1046        elif section == "heatmap":1047            hm = self.d.get("heatmap") or {}1048            if not hm.get("cells"):1049                return1050            title = hm.get("title", "Calendrier d'activité")1051            if render == "table":1052                self._table(self._heatmap_as_table(hm, title))1053            else:1054                self._toc_mark(title)1055                self._calheat(hm)1056        elif section == "hourly":1057            hr = self.d.get("hourly") or {}1058            if not hr.get("cells"):1059                return1060            title = hr.get("title", "Activité par jour et heure")1061            if render == "table":1062                self._table(self._hourly_as_table(hr, title))1063            else:1064                self._toc_mark(title)1065                self._hourly()1066        elif section == "tables":1067            t = self._find("tables", id_)1068            if t:1069                self._table(t, max_rows=400)10701071    def _table(self, t, max_rows=200):1072        p = self.pdf1073        cols = t.get("columns") or []1074        rows = t.get("rows") or []1075        if not cols or not rows:1076            return1077        self._section_title(t.get("title", "Tableau"))1078        w = 174 / len(cols)1079        def head():1080            p.set_font("helvetica", "B", 7.6)1081            p.set_fill_color(*INK)1082            p.set_text_color(*WHITE)1083            for c in cols:1084                p.cell(w, 6, " " + str(c)[:30], fill=True)1085            p.ln(6)1086        head()1087        p.set_text_color(*INK)1088        for i, row in enumerate(rows[:max_rows]):1089            if p.get_y() > 262:1090                p.add_page()1091                head()1092                p.set_text_color(*INK)1093            p.set_font("helvetica", "", 7.4)1094            p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))1095            for cell in row:1096                txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)1097                p.cell(w, 5.4, " " + txt[:34], fill=True)1098            p.ln(5.4)1099        if len(rows) > max_rows:1100            p.set_font("helvetica", "", 7)1101            p.set_text_color(*INK3)1102            p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")1103        p.ln(6)11041105    def _records(self):1106        recs = self.d.get("records") or []1107        if not recs:1108            return1109        self._section_title("Records & faits marquants")1110        p = self.pdf1111        for r in recs[:14]:1112            if p.get_y() > 258:1113                p.add_page()1114            y = p.get_y()1115            self._card(p.l_margin, y, 174, 11, fill=SURFACE2)1116            p.set_xy(p.l_margin + 4, y + 2)1117            p.set_font("helvetica", "", 8.6)1118            p.set_text_color(*INK2)1119            p.cell(96, 7, str(r.get("label", ""))[:70])1120            p.set_font("helvetica", "B", 9)1121            p.set_text_color(*INK)1122            p.cell(52, 7, str(r.get("value", ""))[:36], align="R")1123            p.set_font("helvetica", "", 7.6)1124            p.set_text_color(*INK3)1125            p.cell(20, 7, str(r.get("date", "") or ""), align="R")1126            p.set_y(y + 13.5)1127        p.ln(4)11281129    def _final_page(self):1130        p = self.pdf1131        p.add_page()1132        self._kicker("Groupe KA · contact")1133        p.set_font("helvetica", "B", 15)1134        p.set_text_color(*INK)1135        p.cell(0, 8, "Coordonnées du Groupe KA")1136        p.ln(12)1137        for email, role in EMAILS:1138            p.set_font("helvetica", "B", 10.5)1139            p.set_text_color(*INK)1140            p.cell(0, 6, email)1141            p.ln(5.5)1142            p.set_font("helvetica", "", 8.6)1143            p.set_text_color(*INK3)1144            p.cell(0, 5, role)1145            p.ln(8)1146        p.ln(2)1147        p.set_font("helvetica", "B", 10)1148        p.set_text_color(*GREEN)1149        p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")1150        p.ln(10)1151        p.set_draw_color(*self.accent)1152        p.set_line_width(0.8)1153        p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())1154        p.ln(4)1155        p.set_font("helvetica", "", 8.6)1156        p.set_text_color(*INK2)1157        p.multi_cell(160, 4.6, DISCLAIMER)1158        p.ln(4)1159        p.set_font("helvetica", "", 7.6)1160        p.set_text_color(*INK3)1161        p.multi_cell(1162            160, 4.2,1163            "Mentions : rapport généré automatiquement à partir des données réelles de la "1164            "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "1165            "de confidentialité et protection des renseignements personnels (Loi 25) : "1166            "groupe-ka.com/conditions · /confidentialite · /loi-25.",1167        )11681169    # ---------- groupes de sections ----------1170    def _all_series(self, with_stats=True):1171        for s in self.d.get("series") or []:1172            self._line_chart(s, with_stats=with_stats)1173        for ms in self.d.get("multiseries") or []:1174            self._multiline(ms)1175        for st in self.d.get("stacked") or []:1176            self._stacked(st)11771178    def _all_breakdowns(self):1179        for b in self.d.get("breakdowns") or []:1180            if b.get("kind") == "donut":1181                self._donut(b)1182            else:1183                self._bars(b.get("title", ""), b.get("items"))1184        for dist in self.d.get("distributions") or []:1185            self._vbars(dist)1186        geo = self.d.get("geo")1187        if geo:1188            self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))1189        self._hourly()11901191    def build(self) -> bytes:1192        p = self.pdf1193        p.alias_nb_pages()1194        self._cover()1195        with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)1196        toc_page_no = None1197        if self.mode == "synthese":1198            p.add_page()1199            self._kpis()1200            self._gauges()1201            self._records()1202            self._final_page()1203        elif self.mode == "tendances":1204            p.add_page()1205            self._kpis()1206            self._section_title("Évolution & tendances")1207            self._all_series(with_stats=True)1208            self._records()1209            self._final_page()1210        elif self.mode == "repartitions":1211            p.add_page()1212            self._section_title("Répartitions, distributions & géographie")1213            self._all_breakdowns()1214            self._final_page()1215        elif self.mode == "donnees":1216            p.add_page()1217            toc_page_no = p.page_no()1218            for t in self.d.get("tables") or []:1219                self._table(t, max_rows=400)1220            self._final_page()1221        elif self.mode == CUSTOM_MODE:1222            p.add_page()1223            toc_page_no = p.page_no()1224            p.add_page()1225            known = {b["key"]: b for b in catalog(self.d)}1226            for blk in self.spec.get("blocks") or []:1227                key = str(blk.get("key", ""))1228                b = known.get(key)1229                if not b:1230                    continue1231                render = str(blk.get("render") or "")1232                if render not in b["renders"]:1233                    render = b["default_render"]1234                self._render_block(key, render)1235            self._final_page()1236        else:  # complet1237            p.add_page()1238            toc_page_no = p.page_no()1239            p.add_page()1240            self._kpis()1241            self._gauges()1242            if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):1243                self._section_title("Évolution & tendances")1244                self._all_series(with_stats=True)1245            if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):1246                self._section_title("Répartitions, distributions & géographie")1247                self._all_breakdowns()1248            for t in self.d.get("tables") or []:1249                self._table(t)1250            self._records()1251            self._final_page()1252        # sommaire écrit sur la page réservée1253        if toc_page_no is not None:1254            last_page = p.page1255            p.page = toc_page_no1256            p.set_y(22)1257            p.set_font("helvetica", "B", 15)1258            p.set_text_color(*INK)1259            p.cell(0, 8, "Sommaire")1260            p.ln(12)1261            p.set_font("helvetica", "", 9.5)1262            for title, page_no in self.toc:1263                p.set_text_color(*INK)1264                p.cell(140, 6.5, title[:80])1265                p.set_text_color(*INK3)1266                p.cell(0, 6.5, str(page_no), align="R")1267                p.ln(6.5)1268            p.page = last_page1269        return bytes(p.output())127012711272def filename(platform_id: str, period: str, mode: str = "complet") -> str:1273    today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")1274    suffix = "" if mode in ("", "complet") else f"_{mode}"1275    return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"1276