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%
33.8 KB · 878 lines python
Raw Blame History
1# Auteur : Simon-Pierre Boucher — contact@spboucher.ai2# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v23# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit4# les rapports estampillés Groupe-KA. 5 modes :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# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.13# Usage :14#   from kapdf import GroupeKAReport, REPORT_MODES, filename15#   pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",16#       "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,17#       mode="complet").build()18# Dépendance : pip install fpdf2  (aucune autre)19from __future__ import annotations2021import math22from datetime import datetime23from zoneinfo import ZoneInfo2425from fpdf import FPDF2627INK = (20, 24, 20)28INK2 = (77, 85, 81)29INK3 = (139, 146, 140)30PAPER = (245, 243, 238)31SURFACE2 = (250, 249, 245)32GREEN = (28, 92, 65)33DANGER = (179, 66, 58)34WHITE = (255, 255, 255)3536REPORT_MODES = {37    "complet": "Rapport complet",38    "synthese": "Synthèse exécutive",39    "tendances": "Tendances & évolution",40    "repartitions": "Répartitions & géographie",41    "donnees": "Données détaillées",42}4344EMAILS = [45    ("contact@groupe-ka.com", "Projets, partenariats & données"),46    ("info@groupe-ka.com", "Médias & questions générales"),47    ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),48]49DISCLAIMER = (50    "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "51    "rien et ne sommes partie à aucune transaction. Données lues à la source, "52    "rien d'inventé, tout est traçable."53)545556def _hex(c: str) -> tuple[int, int, int]:57    c = c.lstrip("#")58    return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4))  # type: ignore596061def _fr(n) -> str:62    if isinstance(n, float) and not n.is_integer():63        return f"{n:,.2f}".replace(",", " ").replace(".", ",")64    return f"{int(n):,}".replace(",", " ")656667_SUBST = {68    "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",69    "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',70    "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",71    "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",72}737475def _latin1(s: str) -> str:76    for k, v in _SUBST.items():77        s = s.replace(k, v)78    return s.encode("latin-1", "replace").decode("latin-1")798081class _PDF(FPDF):82    """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).83    Les polices core sont latin-1 : normalize_text sanitise en amont."""8485    def normalize_text(self, text):86        return super().normalize_text(_latin1(text))8788    def __init__(self, brand: str, accent: tuple, period_label: str):89        super().__init__(orientation="P", unit="mm", format="A4")90        self.brand = brand91        self.accent = accent92        self.period_label = period_label93        self.cover_mode = False94        self.set_margins(18, 20, 18)95        self.set_auto_page_break(True, margin=22)9697    def header(self):98        if self.cover_mode or self.page_no() == 1:99            return100        self.set_font("helvetica", "B", 8.5)101        self.set_text_color(*INK)102        self.set_xy(18, 9)103        self.cell(0, 5, f"Groupe KA · {self.brand}")104        self.set_font("helvetica", "", 8)105        self.set_text_color(*INK3)106        self.set_xy(18, 9)107        self.cell(0, 5, "Rapport statistique", align="R")108        self.set_draw_color(*INK)109        self.set_line_width(0.5)110        self.line(18, 15.5, 192, 15.5)111        self.set_y(20)112113    def footer(self):114        # page 1 = couverture (le flag cover_mode est déjà retombé quand115        # add_page() clôt la page 1 → tester aussi le numéro de page)116        if self.cover_mode or self.page_no() == 1:117            return118        self.set_y(-15)119        self.set_draw_color(*INK3)120        self.set_line_width(0.2)121        self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)122        self.set_font("helvetica", "", 7.5)123        self.set_text_color(*INK3)124        year = datetime.now(ZoneInfo("America/Toronto")).year125        self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")126        self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")127128129class GroupeKAReport:130    def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):131        self.site = site132        self.d = dashboard133        self.mode = mode if mode in REPORT_MODES else "complet"134        self.accent = _hex(site.get("accent", "#d9f26b"))135        period = dashboard.get("period", {}) or {}136        self.period_label = period.get("label") or "toute la période"137        self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)138        self.toc: list[tuple[str, int]] = []139140    # ---------- primitives ----------141    def _card(self, x, y, w, h, fill=WHITE):142        p = self.pdf143        p.set_draw_color(*INK)144        p.set_line_width(0.45)145        p.set_fill_color(*fill)146        p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)147148    def _shade(self, i, n=8):149        shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]150        f = shades[i % len(shades)]151        return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))152153    def _kicker(self, text):154        p = self.pdf155        p.set_font("helvetica", "B", 8)156        p.set_text_color(*GREEN)157        p.set_draw_color(*GREEN)158        p.set_line_width(0.6)159        y = p.get_y() + 2160        p.line(p.l_margin, y, p.l_margin + 7, y)161        p.set_xy(p.l_margin + 9, y - 2.5)162        p.cell(0, 5, text.upper())163        p.ln(8)164165    def _section_title(self, title):166        if self.pdf.get_y() > 240:167            self.pdf.add_page()168        self._kicker("Groupe KA · " + self.site.get("wordmark", ""))169        self.pdf.set_font("helvetica", "B", 15)170        self.pdf.set_text_color(*INK)171        self.pdf.set_x(self.pdf.l_margin)172        self.pdf.cell(0, 8, title)173        self.toc.append((title, self.pdf.page_no()))174        self.pdf.ln(11)175176    def _chart_title(self, title):177        p = self.pdf178        p.set_font("helvetica", "B", 10)179        p.set_text_color(*INK)180        p.set_x(p.l_margin)181        p.cell(0, 6, title)182        p.ln(7)183184    # ---------- pages ----------185    def _cover(self):186        p = self.pdf187        p.cover_mode = True188        p.set_auto_page_break(False)189        p.add_page()190        p.set_fill_color(*PAPER)191        p.rect(0, 0, 210, 297, style="F")192        p.set_draw_color(*INK)193        p.set_line_width(1.0)194        p.rect(10, 10, 190, 277)195        p.set_font("helvetica", "B", 10)196        p.set_text_color(*GREEN)197        p.set_xy(24, 34)198        p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")199        wm = self.site.get("wordmark", "")200        left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)201        p.set_xy(24, 70)202        p.set_font("helvetica", "B", 40)203        p.set_text_color(*INK)204        p.cell(p.get_string_width(left) + 2, 20, left)205        if boxed:206            bw = p.get_string_width(boxed) + 12207            x = p.get_x() + 2208            p.set_fill_color(*INK)209            p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)210            p.set_text_color(*self.accent)211            p.set_xy(x + 6, 70)212            p.cell(bw - 12, 18, boxed)213        p.set_xy(24, 100)214        p.set_font("helvetica", "", 13)215        p.set_text_color(*INK2)216        p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}")217        now = datetime.now(ZoneInfo("America/Toronto"))218        per = self.d.get("period", {}) or {}219        p.set_xy(24, 125)220        p.set_font("helvetica", "", 10.5)221        rows = [222            ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),223            ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),224            ("Plateforme", "https://" + self.site.get("domain", "")),225            ("Type de rapport", REPORT_MODES[self.mode]),226        ]227        y = 128228        for k, v in rows:229            p.set_xy(24, y)230            p.set_text_color(*INK3)231            p.cell(40, 6, k)232            p.set_text_color(*INK)233            p.set_font("helvetica", "B", 10.5)234            p.cell(0, 6, str(v))235            p.set_font("helvetica", "", 10.5)236            y += 8237        p.set_fill_color(*INK)238        p.rect(10, 262, 190, 25, style="F")239        p.set_xy(24, 270)240        p.set_font("helvetica", "B", 12)241        p.set_text_color(*WHITE)242        p.cell(60, 8, "par Groupe ")243        p.set_text_color(*self.accent)244        p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)245        p.cell(20, 8, "KA")246        p.set_font("helvetica", "B", 10)247        p.set_xy(24, 270)248        p.set_text_color(*self.accent)249        p.cell(162, 8, "groupe-ka.com", align="R")250        p.set_auto_page_break(True, margin=22)251        p.cover_mode = False252253    def _kpis(self):254        kpis = self.d.get("kpis") or []255        if not kpis:256            return257        self._section_title("Synthèse des indicateurs")258        p = self.pdf259        cols, gw, gh, gap = 3, 56, 26, 3260        x0, y = p.l_margin, p.get_y()261        for i, k in enumerate(kpis[:12]):262            x = x0 + (i % cols) * (gw + gap)263            if i and i % cols == 0:264                y += gh + gap265            if y > 250:266                p.add_page(); y = p.get_y()267            self._card(x, y, gw, gh)268            p.set_xy(x + 4, y + 4)269            p.set_font("helvetica", "B", 14)270            p.set_text_color(*INK)271            val = k.get("value")272            p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))273            p.set_xy(x + 4, y + 12)274            p.set_font("helvetica", "", 7.6)275            p.set_text_color(*INK2)276            p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])277            if k.get("delta_pct") is not None:278                up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"279                p.set_xy(x + 4, y + gh - 6.5)280                p.set_font("helvetica", "B", 8)281                p.set_text_color(*(GREEN if up else DANGER))282                arrow = "+" if k["delta_pct"] >= 0 else ""283                dv = round(float(k["delta_pct"]), 1)284                dv = int(dv) if float(dv).is_integer() else dv285                p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")286        p.set_y(y + gh + 8)287288    def _gauges(self):289        gs = self.d.get("gauges") or []290        gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]291        if not gs:292            return293        self._section_title("Taux & couvertures")294        p = self.pdf295        cols, gw, gh, gap = 3, 56, 34, 3296        x0, y = p.l_margin, p.get_y()297        for i, g in enumerate(gs[:9]):298            x = x0 + (i % cols) * (gw + gap)299            if i and i % cols == 0:300                y += gh + gap301            if y > 240:302                p.add_page(); y = p.get_y()303            self._card(x, y, gw, gh)304            frac = max(0.0, min(1.0, g["value"] / g["max"]))305            cx, cy, r = x + gw / 2, y + 20, 14306            # arc de fond + arc de valeur (demi-cercle en petits segments)307            for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):308                p.set_draw_color(*pass_col)309                p.set_line_width(lw)310                steps = max(2, int(60 * pass_frac))311                last = None312                for st in range(steps + 1):313                    a = math.pi + math.pi * pass_frac * st / steps314                    pt = (cx + r * math.cos(a), cy + r * math.sin(a))315                    if last:316                        p.line(last[0], last[1], pt[0], pt[1])317                    last = pt318            p.set_font("helvetica", "B", 11)319            p.set_text_color(*INK)320            p.set_xy(x + 4, cy - 5)321            p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")322            p.set_font("helvetica", "", 6.6)323            p.set_text_color(*INK3)324            p.set_xy(x + 4, cy + 1.5)325            p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")326            p.set_xy(x + 3, y + gh - 7)327            p.set_font("helvetica", "", 7)328            p.set_text_color(*INK2)329            p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")330        p.set_y(y + gh + 8)331332    def _serie_stats_row(self, s):333        """Ligne min/max/moyenne/médiane sous un graphique de série."""334        p = self.pdf335        vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]336        if len(vs) < 2:337            return338        sv = sorted(vs)339        mean = sum(vs) / len(vs)340        med = sv[len(sv) // 2]341        sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))342        p.set_font("helvetica", "", 6.8)343        p.set_text_color(*INK3)344        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))}")345        p.ln(5.5)346347    def _line_chart(self, s, with_stats=False):348        p = self.pdf349        pts = s.get("points") or []350        if len(pts) < 2:351            return352        if s.get("kind") == "bar":353            self._vbars(s)354            return355        if p.get_y() > 200:356            p.add_page()357        self._chart_title(s.get("title", ""))358        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52359        self._card(x0, y0, w, h, fill=WHITE)360        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16361        vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]362        vmax = max(vals) or 1363        vmin = min(0, min(vals))364        rng = (vmax - vmin) or 1365        p.set_font("helvetica", "", 6.3)366        p.set_text_color(*INK3)367        p.set_draw_color(200, 200, 195)368        p.set_line_width(0.15)369        for g in range(5):370            gy = cy + ch - ch * g / 4371            p.line(cx, gy, cx + cw, gy)372            p.set_xy(x0 + 1, gy - 1.6)373            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")374375        def xy(i, n, v):376            return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))377378        # aire sous la courbe (kind=area) : petits trapèzes accent pâle379        if s.get("kind") == "area":380            fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))381            p.set_fill_color(*fill)382            p.set_draw_color(*fill)383            n = len(pts)384            for i in range(n - 1):385                x1, y1 = xy(i, n, pts[i]["v"])386                x2, y2 = xy(i + 1, n, pts[i + 1]["v"])387                p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")388389        def draw(series, color, width, dash=None):390            n = len(series)391            p.set_draw_color(*color)392            p.set_line_width(width)393            if dash:394                p.set_dash_pattern(dash=1.2, gap=1.2)395            last = None396            for i, pt in enumerate(series):397                px, py = xy(i, n, pt["v"])398                if last:399                    p.line(last[0], last[1], px, py)400                last = (px, py)401            p.set_dash_pattern()402403        if s.get("compare"):404            draw(s["compare"], INK3, 0.35, dash=True)405        draw(pts, self.accent, 0.7)406        p.set_text_color(*INK3)407        for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):408            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)409            p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")410        p.set_y(y0 + h + 4)411        if s.get("compare"):412            p.set_font("helvetica", "", 6.8)413            p.set_text_color(*INK3)414            p.cell(0, 4, "— période courante (accent) · ---- période comparée")415            p.ln(5.5)416        if with_stats:417            self._serie_stats_row(s)418        p.ln(1.5)419420    def _vbars(self, s):421        """Barres verticales : série kind=bar ou distribution (bins)."""422        p = self.pdf423        pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]424        pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]425        if not pts:426            return427        if p.get_y() > 205:428            p.add_page()429        self._chart_title(s.get("title", ""))430        x0, y0, w, h = p.l_margin, p.get_y(), 174, 48431        self._card(x0, y0, w, h, fill=WHITE)432        cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14433        vmax = max(pt["v"] for pt in pts) or 1434        p.set_font("helvetica", "", 6.3)435        p.set_text_color(*INK3)436        p.set_draw_color(200, 200, 195)437        p.set_line_width(0.15)438        for g in range(5):439            gy = cy + ch - ch * g / 4440            p.line(cx, gy, cx + cw, gy)441            p.set_xy(x0 + 1, gy - 1.6)442            p.cell(10, 3, _fr(vmax * g / 4), align="R")443        n = len(pts)444        bw = max(0.8, cw / n - 0.6)445        p.set_fill_color(*self.accent)446        p.set_draw_color(*INK)447        p.set_line_width(0.15)448        for i, pt in enumerate(pts):449            bh = ch * (pt["v"] / vmax)450            p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")451        p.set_text_color(*INK3)452        for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):453            p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)454            p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")455        p.set_y(y0 + h + 5)456457    def _multiline(self, ms):458        """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /459        gris pointillé — l'identité passe par le motif, pas la couleur seule."""460        p = self.pdf461        series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]462        if not series:463            return464        if p.get_y() > 195:465            p.add_page()466        self._chart_title(ms.get("title", ""))467        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52468        self._card(x0, y0, w, h, fill=WHITE)469        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16470        vals = [pt["v"] for s in series for pt in s["points"]]471        vmax = max(vals) or 1472        vmin = min(0, min(vals))473        rng = (vmax - vmin) or 1474        p.set_font("helvetica", "", 6.3)475        p.set_text_color(*INK3)476        p.set_draw_color(200, 200, 195)477        p.set_line_width(0.15)478        for g in range(5):479            gy = cy + ch - ch * g / 4480            p.line(cx, gy, cx + cw, gy)481            p.set_xy(x0 + 1, gy - 1.6)482            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")483        styles = [484            (self.accent, 0.7, None),485            (INK, 0.45, None),486            (self.accent, 0.55, True),487            (INK3, 0.5, True),488        ]489        for si, s in enumerate(series):490            col, lw, dash = styles[si]491            p.set_draw_color(*col)492            p.set_line_width(lw)493            if dash:494                p.set_dash_pattern(dash=1.4, gap=1.2)495            n = len(s["points"])496            last = None497            for i, pt in enumerate(s["points"]):498                px = cx + cw * (i / (n - 1))499                py = cy + ch - ch * ((pt["v"] - vmin) / rng)500                if last:501                    p.line(last[0], last[1], px, py)502                last = (px, py)503            p.set_dash_pattern()504        ref = series[0]["points"]505        p.set_text_color(*INK3)506        for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):507            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)508            p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")509        p.set_y(y0 + h + 4)510        p.set_font("helvetica", "", 6.8)511        p.set_text_color(*INK3)512        marks = ["—", "—", "----", "----"]513        leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))514        p.cell(0, 4, leg[:120])515        p.ln(6)516517    def _stacked(self, st):518        p = self.pdf519        keys = (st.get("keys") or [])[:6]520        pts = st.get("points") or []521        if not keys or not pts:522            return523        if p.get_y() > 195:524            p.add_page()525        self._chart_title(st.get("title", ""))526        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52527        self._card(x0, y0, w, h, fill=WHITE)528        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16529        totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]530        vmax = max(totals) or 1531        p.set_font("helvetica", "", 6.3)532        p.set_text_color(*INK3)533        p.set_draw_color(200, 200, 195)534        p.set_line_width(0.15)535        for g in range(5):536            gy = cy + ch - ch * g / 4537            p.line(cx, gy, cx + cw, gy)538            p.set_xy(x0 + 1, gy - 1.6)539            p.cell(10, 3, _fr(vmax * g / 4), align="R")540        n = len(pts)541        bw = max(0.8, cw / n - 0.6)542        p.set_draw_color(*WHITE)543        p.set_line_width(0.12)544        for i, pt in enumerate(pts):545            yacc = cy + ch546            for j, k in enumerate(keys):547                v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0548                if not v:549                    continue550                bh = ch * (v / vmax)551                yacc -= bh552                p.set_fill_color(*self._shade(j))553                p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")554        p.set_text_color(*INK3)555        for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):556            p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)557            p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")558        p.set_y(y0 + h + 4)559        # légende560        p.set_font("helvetica", "", 6.8)561        lx = p.l_margin562        for j, k in enumerate(keys):563            p.set_fill_color(*self._shade(j))564            p.set_draw_color(*INK)565            p.set_line_width(0.2)566            p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")567            p.set_xy(lx + 4, p.get_y())568            p.set_text_color(*INK2)569            txt = str(k)[:22]570            p.cell(p.get_string_width(txt) + 3, 4, txt)571            lx = p.get_x() + 3572            if lx > 165:573                break574        p.ln(7)575576    def _bars(self, title, items, unit=""):577        p = self.pdf578        items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]579        if not items:580            return581        need = 10 + len(items) * 7582        if p.get_y() + need > 265:583            p.add_page()584        self._chart_title(title)585        p.ln(1)586        vmax = max(it["value"] for it in items) or 1587        for it in items:588            y = p.get_y()589            p.set_font("helvetica", "", 7.6)590            p.set_text_color(*INK)591            p.set_x(p.l_margin)592            p.cell(46, 5, str(it["label"])[:34])593            bw = 86 * (it["value"] / vmax)594            p.set_fill_color(*self.accent)595            p.set_draw_color(*INK)596            p.set_line_width(0.25)597            p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")598            p.set_xy(p.l_margin + 136, y)599            p.set_font("helvetica", "B", 7.6)600            p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")601            if it.get("delta_pct") is not None:602                up = it["delta_pct"] >= 0603                p.set_font("helvetica", "B", 6.6)604                p.set_text_color(*(GREEN if up else DANGER))605                p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")606            p.ln(6.4)607        p.ln(3)608609    def _donut(self, b):610        p = self.pdf611        items = [it for it in (b.get("items") or []) if it.get("value")][:8]612        total = sum(it["value"] for it in items)613        if not items or not total:614            return615        if p.get_y() > 210:616            p.add_page()617        self._chart_title(b.get("title", ""))618        p.ln(1)619        cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20620        start = -90.0621        for i, it in enumerate(items):622            frac = it["value"] / total623            col = self._shade(i)624            steps = max(2, int(72 * frac))625            p.set_fill_color(*col)626            p.set_draw_color(*col)627            for st in range(steps):628                a0 = math.radians(start + 360 * frac * st / steps)629                a1 = math.radians(start + 360 * frac * (st + 1) / steps)630                p.polygon(631                    [(cx, cy),632                     (cx + r * math.cos(a0), cy + r * math.sin(a0)),633                     (cx + r * math.cos(a1), cy + r * math.sin(a1))],634                    style="DF",635                )636            start += 360 * frac637        p.set_fill_color(*WHITE)638        p.set_draw_color(*INK)639        p.set_line_width(0.4)640        p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")641        p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")642        ly = cy - 22643        for i, it in enumerate(items):644            col = self._shade(i)645            p.set_fill_color(*col)646            p.set_draw_color(*INK)647            p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")648            p.set_xy(p.l_margin + 66, ly)649            p.set_font("helvetica", "", 7.6)650            p.set_text_color(*INK)651            pct = 100 * it["value"] / total652            p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))653            ly += 5.6654        p.set_y(max(cy + r, ly) + 6)655656    def _hourly(self):657        hh = self.d.get("hourly") or {}658        cells = hh.get("cells") or []659        if not cells:660            return661        p = self.pdf662        if p.get_y() > 190:663            p.add_page()664        self._chart_title(hh.get("title", "Activité par jour et heure"))665        x0, y0 = p.l_margin, p.get_y()666        cw, chh, lx, ly = 6.4, 6.4, 12, 5667        vmax = max((c.get("value") or 0) for c in cells) or 1668        grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}669        dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]670        p.set_font("helvetica", "", 5.8)671        p.set_text_color(*INK3)672        for h in (0, 6, 12, 18, 23):673            p.set_xy(x0 + lx + h * cw, y0)674            p.cell(cw, 3, f"{h}h", align="C")675        for d in range(7):676            p.set_xy(x0, y0 + ly + d * chh + 1.5)677            p.cell(lx - 1, 3, dows[d], align="R")678            for h in range(24):679                v = grid.get((d, h), 0)680                f = 0.1 + 0.9 * (v / vmax) if v else 0.0681                col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)682                p.set_fill_color(*col)683                p.set_draw_color(215, 213, 207)684                p.set_line_width(0.1)685                p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")686        p.set_y(y0 + ly + 7 * chh + 5)687688    def _table(self, t, max_rows=200):689        p = self.pdf690        cols = t.get("columns") or []691        rows = t.get("rows") or []692        if not cols or not rows:693            return694        self._section_title(t.get("title", "Tableau"))695        w = 174 / len(cols)696        def head():697            p.set_font("helvetica", "B", 7.6)698            p.set_fill_color(*INK)699            p.set_text_color(*WHITE)700            for c in cols:701                p.cell(w, 6, " " + str(c)[:30], fill=True)702            p.ln(6)703        head()704        p.set_text_color(*INK)705        for i, row in enumerate(rows[:max_rows]):706            if p.get_y() > 262:707                p.add_page()708                head()709                p.set_text_color(*INK)710            p.set_font("helvetica", "", 7.4)711            p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))712            for cell in row:713                txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)714                p.cell(w, 5.4, " " + txt[:34], fill=True)715            p.ln(5.4)716        if len(rows) > max_rows:717            p.set_font("helvetica", "", 7)718            p.set_text_color(*INK3)719            p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")720        p.ln(6)721722    def _records(self):723        recs = self.d.get("records") or []724        if not recs:725            return726        self._section_title("Records & faits marquants")727        p = self.pdf728        for r in recs[:14]:729            if p.get_y() > 258:730                p.add_page()731            y = p.get_y()732            self._card(p.l_margin, y, 174, 11, fill=SURFACE2)733            p.set_xy(p.l_margin + 4, y + 2)734            p.set_font("helvetica", "", 8.6)735            p.set_text_color(*INK2)736            p.cell(96, 7, str(r.get("label", ""))[:70])737            p.set_font("helvetica", "B", 9)738            p.set_text_color(*INK)739            p.cell(52, 7, str(r.get("value", ""))[:36], align="R")740            p.set_font("helvetica", "", 7.6)741            p.set_text_color(*INK3)742            p.cell(20, 7, str(r.get("date", "") or ""), align="R")743            p.set_y(y + 13.5)744        p.ln(4)745746    def _final_page(self):747        p = self.pdf748        p.add_page()749        self._kicker("Groupe KA · contact")750        p.set_font("helvetica", "B", 15)751        p.set_text_color(*INK)752        p.cell(0, 8, "Coordonnées du Groupe KA")753        p.ln(12)754        for email, role in EMAILS:755            p.set_font("helvetica", "B", 10.5)756            p.set_text_color(*INK)757            p.cell(0, 6, email)758            p.ln(5.5)759            p.set_font("helvetica", "", 8.6)760            p.set_text_color(*INK3)761            p.cell(0, 5, role)762            p.ln(8)763        p.ln(2)764        p.set_font("helvetica", "B", 10)765        p.set_text_color(*GREEN)766        p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")767        p.ln(10)768        p.set_draw_color(*self.accent)769        p.set_line_width(0.8)770        p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())771        p.ln(4)772        p.set_font("helvetica", "", 8.6)773        p.set_text_color(*INK2)774        p.multi_cell(160, 4.6, DISCLAIMER)775        p.ln(4)776        p.set_font("helvetica", "", 7.6)777        p.set_text_color(*INK3)778        p.multi_cell(779            160, 4.2,780            "Mentions : rapport généré automatiquement à partir des données réelles de la "781            "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "782            "de confidentialité et protection des renseignements personnels (Loi 25) : "783            "groupe-ka.com/conditions · /confidentialite · /loi-25.",784        )785786    # ---------- groupes de sections ----------787    def _all_series(self, with_stats=True):788        for s in self.d.get("series") or []:789            self._line_chart(s, with_stats=with_stats)790        for ms in self.d.get("multiseries") or []:791            self._multiline(ms)792        for st in self.d.get("stacked") or []:793            self._stacked(st)794795    def _all_breakdowns(self):796        for b in self.d.get("breakdowns") or []:797            if b.get("kind") == "donut":798                self._donut(b)799            else:800                self._bars(b.get("title", ""), b.get("items"))801        for dist in self.d.get("distributions") or []:802            self._vbars(dist)803        geo = self.d.get("geo")804        if geo:805            self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))806        self._hourly()807808    def build(self) -> bytes:809        p = self.pdf810        p.alias_nb_pages()811        self._cover()812        with_toc = self.mode in ("complet", "donnees")813        toc_page_no = None814        if self.mode == "synthese":815            p.add_page()816            self._kpis()817            self._gauges()818            self._records()819            self._final_page()820        elif self.mode == "tendances":821            p.add_page()822            self._kpis()823            self._section_title("Évolution & tendances")824            self._all_series(with_stats=True)825            self._records()826            self._final_page()827        elif self.mode == "repartitions":828            p.add_page()829            self._section_title("Répartitions, distributions & géographie")830            self._all_breakdowns()831            self._final_page()832        elif self.mode == "donnees":833            p.add_page()834            toc_page_no = p.page_no()835            for t in self.d.get("tables") or []:836                self._table(t, max_rows=400)837            self._final_page()838        else:  # complet839            p.add_page()840            toc_page_no = p.page_no()841            p.add_page()842            self._kpis()843            self._gauges()844            if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):845                self._section_title("Évolution & tendances")846                self._all_series(with_stats=True)847            if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):848                self._section_title("Répartitions, distributions & géographie")849                self._all_breakdowns()850            for t in self.d.get("tables") or []:851                self._table(t)852            self._records()853            self._final_page()854        # sommaire écrit sur la page réservée855        if toc_page_no is not None:856            last_page = p.page857            p.page = toc_page_no858            p.set_y(22)859            p.set_font("helvetica", "B", 15)860            p.set_text_color(*INK)861            p.cell(0, 8, "Sommaire")862            p.ln(12)863            p.set_font("helvetica", "", 9.5)864            for title, page_no in self.toc:865                p.set_text_color(*INK)866                p.cell(140, 6.5, title[:80])867                p.set_text_color(*INK3)868                p.cell(0, 6.5, str(page_no), align="R")869                p.ln(6.5)870            p.page = last_page871        return bytes(p.output())872873874def filename(platform_id: str, period: str, mode: str = "complet") -> str:875    today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")876    suffix = "" if mode in ("", "complet") else f"_{mode}"877    return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"878