SPB Git

Repository Public

kapdf.py 34.0 KB 886 lines python

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). v211# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit12# les rapports estampillés Groupe-KA. 5 modes :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# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.21# Usage :22#   from kapdf import GroupeKAReport, REPORT_MODES, filename23#   pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",24#       "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,25#       mode="complet").build()26# Dépendance : pip install fpdf2  (aucune autre)27from __future__ import annotations2829import math30from datetime import datetime31from zoneinfo import ZoneInfo3233from fpdf import FPDF3435INK = (20, 24, 20)36INK2 = (77, 85, 81)37INK3 = (139, 146, 140)38PAPER = (245, 243, 238)39SURFACE2 = (250, 249, 245)40GREEN = (28, 92, 65)41DANGER = (179, 66, 58)42WHITE = (255, 255, 255)4344REPORT_MODES = {45    "complet": "Rapport complet",46    "synthese": "Synthèse exécutive",47    "tendances": "Tendances & évolution",48    "repartitions": "Répartitions & géographie",49    "donnees": "Données détaillées",50}5152EMAILS = [53    ("contact@groupe-ka.com", "Projets, partenariats & données"),54    ("info@groupe-ka.com", "Médias & questions générales"),55    ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),56]57DISCLAIMER = (58    "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "59    "rien et ne sommes partie à aucune transaction. Données lues à la source, "60    "rien d'inventé, tout est traçable."61)626364def _hex(c: str) -> tuple[int, int, int]:65    c = c.lstrip("#")66    return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4))  # type: ignore676869def _fr(n) -> str:70    if isinstance(n, float) and not n.is_integer():71        return f"{n:,.2f}".replace(",", " ").replace(".", ",")72    return f"{int(n):,}".replace(",", " ")737475_SUBST = {76    "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",77    "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',78    "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",79    "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",80}818283def _latin1(s: str) -> str:84    for k, v in _SUBST.items():85        s = s.replace(k, v)86    return s.encode("latin-1", "replace").decode("latin-1")878889class _PDF(FPDF):90    """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).91    Les polices core sont latin-1 : normalize_text sanitise en amont."""9293    def normalize_text(self, text):94        return super().normalize_text(_latin1(text))9596    def __init__(self, brand: str, accent: tuple, period_label: str):97        super().__init__(orientation="P", unit="mm", format="A4")98        self.brand = brand99        self.accent = accent100        self.period_label = period_label101        self.cover_mode = False102        self.set_margins(18, 20, 18)103        self.set_auto_page_break(True, margin=22)104105    def header(self):106        if self.cover_mode or self.page_no() == 1:107            return108        self.set_font("helvetica", "B", 8.5)109        self.set_text_color(*INK)110        self.set_xy(18, 9)111        self.cell(0, 5, f"Groupe KA · {self.brand}")112        self.set_font("helvetica", "", 8)113        self.set_text_color(*INK3)114        self.set_xy(18, 9)115        self.cell(0, 5, "Rapport statistique", align="R")116        self.set_draw_color(*INK)117        self.set_line_width(0.5)118        self.line(18, 15.5, 192, 15.5)119        self.set_y(20)120121    def footer(self):122        # page 1 = couverture (le flag cover_mode est déjà retombé quand123        # add_page() clôt la page 1 → tester aussi le numéro de page)124        if self.cover_mode or self.page_no() == 1:125            return126        self.set_y(-15)127        self.set_draw_color(*INK3)128        self.set_line_width(0.2)129        self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)130        self.set_font("helvetica", "", 7.5)131        self.set_text_color(*INK3)132        year = datetime.now(ZoneInfo("America/Toronto")).year133        self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")134        self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")135136137class GroupeKAReport:138    def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):139        self.site = site140        self.d = dashboard141        self.mode = mode if mode in REPORT_MODES else "complet"142        self.accent = _hex(site.get("accent", "#d9f26b"))143        period = dashboard.get("period", {}) or {}144        self.period_label = period.get("label") or "toute la période"145        self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)146        self.toc: list[tuple[str, int]] = []147148    # ---------- primitives ----------149    def _card(self, x, y, w, h, fill=WHITE):150        p = self.pdf151        p.set_draw_color(*INK)152        p.set_line_width(0.45)153        p.set_fill_color(*fill)154        p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)155156    def _shade(self, i, n=8):157        shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]158        f = shades[i % len(shades)]159        return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))160161    def _kicker(self, text):162        p = self.pdf163        p.set_font("helvetica", "B", 8)164        p.set_text_color(*GREEN)165        p.set_draw_color(*GREEN)166        p.set_line_width(0.6)167        y = p.get_y() + 2168        p.line(p.l_margin, y, p.l_margin + 7, y)169        p.set_xy(p.l_margin + 9, y - 2.5)170        p.cell(0, 5, text.upper())171        p.ln(8)172173    def _section_title(self, title):174        if self.pdf.get_y() > 240:175            self.pdf.add_page()176        self._kicker("Groupe KA · " + self.site.get("wordmark", ""))177        self.pdf.set_font("helvetica", "B", 15)178        self.pdf.set_text_color(*INK)179        self.pdf.set_x(self.pdf.l_margin)180        self.pdf.cell(0, 8, title)181        self.toc.append((title, self.pdf.page_no()))182        self.pdf.ln(11)183184    def _chart_title(self, title):185        p = self.pdf186        p.set_font("helvetica", "B", 10)187        p.set_text_color(*INK)188        p.set_x(p.l_margin)189        p.cell(0, 6, title)190        p.ln(7)191192    # ---------- pages ----------193    def _cover(self):194        p = self.pdf195        p.cover_mode = True196        p.set_auto_page_break(False)197        p.add_page()198        p.set_fill_color(*PAPER)199        p.rect(0, 0, 210, 297, style="F")200        p.set_draw_color(*INK)201        p.set_line_width(1.0)202        p.rect(10, 10, 190, 277)203        p.set_font("helvetica", "B", 10)204        p.set_text_color(*GREEN)205        p.set_xy(24, 34)206        p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")207        wm = self.site.get("wordmark", "")208        left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)209        p.set_xy(24, 70)210        p.set_font("helvetica", "B", 40)211        p.set_text_color(*INK)212        p.cell(p.get_string_width(left) + 2, 20, left)213        if boxed:214            bw = p.get_string_width(boxed) + 12215            x = p.get_x() + 2216            p.set_fill_color(*INK)217            p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)218            p.set_text_color(*self.accent)219            p.set_xy(x + 6, 70)220            p.cell(bw - 12, 18, boxed)221        p.set_xy(24, 100)222        p.set_font("helvetica", "", 13)223        p.set_text_color(*INK2)224        p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}")225        now = datetime.now(ZoneInfo("America/Toronto"))226        per = self.d.get("period", {}) or {}227        p.set_xy(24, 125)228        p.set_font("helvetica", "", 10.5)229        rows = [230            ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),231            ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),232            ("Plateforme", "https://" + self.site.get("domain", "")),233            ("Type de rapport", REPORT_MODES[self.mode]),234        ]235        y = 128236        for k, v in rows:237            p.set_xy(24, y)238            p.set_text_color(*INK3)239            p.cell(40, 6, k)240            p.set_text_color(*INK)241            p.set_font("helvetica", "B", 10.5)242            p.cell(0, 6, str(v))243            p.set_font("helvetica", "", 10.5)244            y += 8245        p.set_fill_color(*INK)246        p.rect(10, 262, 190, 25, style="F")247        p.set_xy(24, 270)248        p.set_font("helvetica", "B", 12)249        p.set_text_color(*WHITE)250        p.cell(60, 8, "par Groupe ")251        p.set_text_color(*self.accent)252        p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)253        p.cell(20, 8, "KA")254        p.set_font("helvetica", "B", 10)255        p.set_xy(24, 270)256        p.set_text_color(*self.accent)257        p.cell(162, 8, "groupe-ka.com", align="R")258        p.set_auto_page_break(True, margin=22)259        p.cover_mode = False260261    def _kpis(self):262        kpis = self.d.get("kpis") or []263        if not kpis:264            return265        self._section_title("Synthèse des indicateurs")266        p = self.pdf267        cols, gw, gh, gap = 3, 56, 26, 3268        x0, y = p.l_margin, p.get_y()269        for i, k in enumerate(kpis[:12]):270            x = x0 + (i % cols) * (gw + gap)271            if i and i % cols == 0:272                y += gh + gap273            if y > 250:274                p.add_page(); y = p.get_y()275            self._card(x, y, gw, gh)276            p.set_xy(x + 4, y + 4)277            p.set_font("helvetica", "B", 14)278            p.set_text_color(*INK)279            val = k.get("value")280            p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))281            p.set_xy(x + 4, y + 12)282            p.set_font("helvetica", "", 7.6)283            p.set_text_color(*INK2)284            p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])285            if k.get("delta_pct") is not None:286                up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"287                p.set_xy(x + 4, y + gh - 6.5)288                p.set_font("helvetica", "B", 8)289                p.set_text_color(*(GREEN if up else DANGER))290                arrow = "+" if k["delta_pct"] >= 0 else ""291                dv = round(float(k["delta_pct"]), 1)292                dv = int(dv) if float(dv).is_integer() else dv293                p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")294        p.set_y(y + gh + 8)295296    def _gauges(self):297        gs = self.d.get("gauges") or []298        gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]299        if not gs:300            return301        self._section_title("Taux & couvertures")302        p = self.pdf303        cols, gw, gh, gap = 3, 56, 34, 3304        x0, y = p.l_margin, p.get_y()305        for i, g in enumerate(gs[:9]):306            x = x0 + (i % cols) * (gw + gap)307            if i and i % cols == 0:308                y += gh + gap309            if y > 240:310                p.add_page(); y = p.get_y()311            self._card(x, y, gw, gh)312            frac = max(0.0, min(1.0, g["value"] / g["max"]))313            cx, cy, r = x + gw / 2, y + 20, 14314            # arc de fond + arc de valeur (demi-cercle en petits segments)315            for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):316                p.set_draw_color(*pass_col)317                p.set_line_width(lw)318                steps = max(2, int(60 * pass_frac))319                last = None320                for st in range(steps + 1):321                    a = math.pi + math.pi * pass_frac * st / steps322                    pt = (cx + r * math.cos(a), cy + r * math.sin(a))323                    if last:324                        p.line(last[0], last[1], pt[0], pt[1])325                    last = pt326            p.set_font("helvetica", "B", 11)327            p.set_text_color(*INK)328            p.set_xy(x + 4, cy - 5)329            p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")330            p.set_font("helvetica", "", 6.6)331            p.set_text_color(*INK3)332            p.set_xy(x + 4, cy + 1.5)333            p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")334            p.set_xy(x + 3, y + gh - 7)335            p.set_font("helvetica", "", 7)336            p.set_text_color(*INK2)337            p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")338        p.set_y(y + gh + 8)339340    def _serie_stats_row(self, s):341        """Ligne min/max/moyenne/médiane sous un graphique de série."""342        p = self.pdf343        vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]344        if len(vs) < 2:345            return346        sv = sorted(vs)347        mean = sum(vs) / len(vs)348        med = sv[len(sv) // 2]349        sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))350        p.set_font("helvetica", "", 6.8)351        p.set_text_color(*INK3)352        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))}")353        p.ln(5.5)354355    def _line_chart(self, s, with_stats=False):356        p = self.pdf357        pts = s.get("points") or []358        if len(pts) < 2:359            return360        if s.get("kind") == "bar":361            self._vbars(s)362            return363        if p.get_y() > 200:364            p.add_page()365        self._chart_title(s.get("title", ""))366        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52367        self._card(x0, y0, w, h, fill=WHITE)368        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16369        vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]370        vmax = max(vals) or 1371        vmin = min(0, min(vals))372        rng = (vmax - vmin) or 1373        p.set_font("helvetica", "", 6.3)374        p.set_text_color(*INK3)375        p.set_draw_color(200, 200, 195)376        p.set_line_width(0.15)377        for g in range(5):378            gy = cy + ch - ch * g / 4379            p.line(cx, gy, cx + cw, gy)380            p.set_xy(x0 + 1, gy - 1.6)381            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")382383        def xy(i, n, v):384            return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))385386        # aire sous la courbe (kind=area) : petits trapèzes accent pâle387        if s.get("kind") == "area":388            fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))389            p.set_fill_color(*fill)390            p.set_draw_color(*fill)391            n = len(pts)392            for i in range(n - 1):393                x1, y1 = xy(i, n, pts[i]["v"])394                x2, y2 = xy(i + 1, n, pts[i + 1]["v"])395                p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")396397        def draw(series, color, width, dash=None):398            n = len(series)399            p.set_draw_color(*color)400            p.set_line_width(width)401            if dash:402                p.set_dash_pattern(dash=1.2, gap=1.2)403            last = None404            for i, pt in enumerate(series):405                px, py = xy(i, n, pt["v"])406                if last:407                    p.line(last[0], last[1], px, py)408                last = (px, py)409            p.set_dash_pattern()410411        if s.get("compare"):412            draw(s["compare"], INK3, 0.35, dash=True)413        draw(pts, self.accent, 0.7)414        p.set_text_color(*INK3)415        for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):416            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)417            p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")418        p.set_y(y0 + h + 4)419        if s.get("compare"):420            p.set_font("helvetica", "", 6.8)421            p.set_text_color(*INK3)422            p.cell(0, 4, "— période courante (accent) · ---- période comparée")423            p.ln(5.5)424        if with_stats:425            self._serie_stats_row(s)426        p.ln(1.5)427428    def _vbars(self, s):429        """Barres verticales : série kind=bar ou distribution (bins)."""430        p = self.pdf431        pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]432        pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]433        if not pts:434            return435        if p.get_y() > 205:436            p.add_page()437        self._chart_title(s.get("title", ""))438        x0, y0, w, h = p.l_margin, p.get_y(), 174, 48439        self._card(x0, y0, w, h, fill=WHITE)440        cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14441        vmax = max(pt["v"] for pt in pts) or 1442        p.set_font("helvetica", "", 6.3)443        p.set_text_color(*INK3)444        p.set_draw_color(200, 200, 195)445        p.set_line_width(0.15)446        for g in range(5):447            gy = cy + ch - ch * g / 4448            p.line(cx, gy, cx + cw, gy)449            p.set_xy(x0 + 1, gy - 1.6)450            p.cell(10, 3, _fr(vmax * g / 4), align="R")451        n = len(pts)452        bw = max(0.8, cw / n - 0.6)453        p.set_fill_color(*self.accent)454        p.set_draw_color(*INK)455        p.set_line_width(0.15)456        for i, pt in enumerate(pts):457            bh = ch * (pt["v"] / vmax)458            p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")459        p.set_text_color(*INK3)460        for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):461            p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)462            p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")463        p.set_y(y0 + h + 5)464465    def _multiline(self, ms):466        """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /467        gris pointillé — l'identité passe par le motif, pas la couleur seule."""468        p = self.pdf469        series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]470        if not series:471            return472        if p.get_y() > 195:473            p.add_page()474        self._chart_title(ms.get("title", ""))475        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52476        self._card(x0, y0, w, h, fill=WHITE)477        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16478        vals = [pt["v"] for s in series for pt in s["points"]]479        vmax = max(vals) or 1480        vmin = min(0, min(vals))481        rng = (vmax - vmin) or 1482        p.set_font("helvetica", "", 6.3)483        p.set_text_color(*INK3)484        p.set_draw_color(200, 200, 195)485        p.set_line_width(0.15)486        for g in range(5):487            gy = cy + ch - ch * g / 4488            p.line(cx, gy, cx + cw, gy)489            p.set_xy(x0 + 1, gy - 1.6)490            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")491        styles = [492            (self.accent, 0.7, None),493            (INK, 0.45, None),494            (self.accent, 0.55, True),495            (INK3, 0.5, True),496        ]497        for si, s in enumerate(series):498            col, lw, dash = styles[si]499            p.set_draw_color(*col)500            p.set_line_width(lw)501            if dash:502                p.set_dash_pattern(dash=1.4, gap=1.2)503            n = len(s["points"])504            last = None505            for i, pt in enumerate(s["points"]):506                px = cx + cw * (i / (n - 1))507                py = cy + ch - ch * ((pt["v"] - vmin) / rng)508                if last:509                    p.line(last[0], last[1], px, py)510                last = (px, py)511            p.set_dash_pattern()512        ref = series[0]["points"]513        p.set_text_color(*INK3)514        for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):515            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)516            p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")517        p.set_y(y0 + h + 4)518        p.set_font("helvetica", "", 6.8)519        p.set_text_color(*INK3)520        marks = ["—", "—", "----", "----"]521        leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))522        p.cell(0, 4, leg[:120])523        p.ln(6)524525    def _stacked(self, st):526        p = self.pdf527        keys = (st.get("keys") or [])[:6]528        pts = st.get("points") or []529        if not keys or not pts:530            return531        if p.get_y() > 195:532            p.add_page()533        self._chart_title(st.get("title", ""))534        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52535        self._card(x0, y0, w, h, fill=WHITE)536        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16537        totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]538        vmax = max(totals) or 1539        p.set_font("helvetica", "", 6.3)540        p.set_text_color(*INK3)541        p.set_draw_color(200, 200, 195)542        p.set_line_width(0.15)543        for g in range(5):544            gy = cy + ch - ch * g / 4545            p.line(cx, gy, cx + cw, gy)546            p.set_xy(x0 + 1, gy - 1.6)547            p.cell(10, 3, _fr(vmax * g / 4), align="R")548        n = len(pts)549        bw = max(0.8, cw / n - 0.6)550        p.set_draw_color(*WHITE)551        p.set_line_width(0.12)552        for i, pt in enumerate(pts):553            yacc = cy + ch554            for j, k in enumerate(keys):555                v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0556                if not v:557                    continue558                bh = ch * (v / vmax)559                yacc -= bh560                p.set_fill_color(*self._shade(j))561                p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")562        p.set_text_color(*INK3)563        for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):564            p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)565            p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")566        p.set_y(y0 + h + 4)567        # légende568        p.set_font("helvetica", "", 6.8)569        lx = p.l_margin570        for j, k in enumerate(keys):571            p.set_fill_color(*self._shade(j))572            p.set_draw_color(*INK)573            p.set_line_width(0.2)574            p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")575            p.set_xy(lx + 4, p.get_y())576            p.set_text_color(*INK2)577            txt = str(k)[:22]578            p.cell(p.get_string_width(txt) + 3, 4, txt)579            lx = p.get_x() + 3580            if lx > 165:581                break582        p.ln(7)583584    def _bars(self, title, items, unit=""):585        p = self.pdf586        items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]587        if not items:588            return589        need = 10 + len(items) * 7590        if p.get_y() + need > 265:591            p.add_page()592        self._chart_title(title)593        p.ln(1)594        vmax = max(it["value"] for it in items) or 1595        for it in items:596            y = p.get_y()597            p.set_font("helvetica", "", 7.6)598            p.set_text_color(*INK)599            p.set_x(p.l_margin)600            p.cell(46, 5, str(it["label"])[:34])601            bw = 86 * (it["value"] / vmax)602            p.set_fill_color(*self.accent)603            p.set_draw_color(*INK)604            p.set_line_width(0.25)605            p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")606            p.set_xy(p.l_margin + 136, y)607            p.set_font("helvetica", "B", 7.6)608            p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")609            if it.get("delta_pct") is not None:610                up = it["delta_pct"] >= 0611                p.set_font("helvetica", "B", 6.6)612                p.set_text_color(*(GREEN if up else DANGER))613                p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")614            p.ln(6.4)615        p.ln(3)616617    def _donut(self, b):618        p = self.pdf619        items = [it for it in (b.get("items") or []) if it.get("value")][:8]620        total = sum(it["value"] for it in items)621        if not items or not total:622            return623        if p.get_y() > 210:624            p.add_page()625        self._chart_title(b.get("title", ""))626        p.ln(1)627        cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20628        start = -90.0629        for i, it in enumerate(items):630            frac = it["value"] / total631            col = self._shade(i)632            steps = max(2, int(72 * frac))633            p.set_fill_color(*col)634            p.set_draw_color(*col)635            for st in range(steps):636                a0 = math.radians(start + 360 * frac * st / steps)637                a1 = math.radians(start + 360 * frac * (st + 1) / steps)638                p.polygon(639                    [(cx, cy),640                     (cx + r * math.cos(a0), cy + r * math.sin(a0)),641                     (cx + r * math.cos(a1), cy + r * math.sin(a1))],642                    style="DF",643                )644            start += 360 * frac645        p.set_fill_color(*WHITE)646        p.set_draw_color(*INK)647        p.set_line_width(0.4)648        p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")649        p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")650        ly = cy - 22651        for i, it in enumerate(items):652            col = self._shade(i)653            p.set_fill_color(*col)654            p.set_draw_color(*INK)655            p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")656            p.set_xy(p.l_margin + 66, ly)657            p.set_font("helvetica", "", 7.6)658            p.set_text_color(*INK)659            pct = 100 * it["value"] / total660            p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))661            ly += 5.6662        p.set_y(max(cy + r, ly) + 6)663664    def _hourly(self):665        hh = self.d.get("hourly") or {}666        cells = hh.get("cells") or []667        if not cells:668            return669        p = self.pdf670        if p.get_y() > 190:671            p.add_page()672        self._chart_title(hh.get("title", "Activité par jour et heure"))673        x0, y0 = p.l_margin, p.get_y()674        cw, chh, lx, ly = 6.4, 6.4, 12, 5675        vmax = max((c.get("value") or 0) for c in cells) or 1676        grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}677        dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]678        p.set_font("helvetica", "", 5.8)679        p.set_text_color(*INK3)680        for h in (0, 6, 12, 18, 23):681            p.set_xy(x0 + lx + h * cw, y0)682            p.cell(cw, 3, f"{h}h", align="C")683        for d in range(7):684            p.set_xy(x0, y0 + ly + d * chh + 1.5)685            p.cell(lx - 1, 3, dows[d], align="R")686            for h in range(24):687                v = grid.get((d, h), 0)688                f = 0.1 + 0.9 * (v / vmax) if v else 0.0689                col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)690                p.set_fill_color(*col)691                p.set_draw_color(215, 213, 207)692                p.set_line_width(0.1)693                p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")694        p.set_y(y0 + ly + 7 * chh + 5)695696    def _table(self, t, max_rows=200):697        p = self.pdf698        cols = t.get("columns") or []699        rows = t.get("rows") or []700        if not cols or not rows:701            return702        self._section_title(t.get("title", "Tableau"))703        w = 174 / len(cols)704        def head():705            p.set_font("helvetica", "B", 7.6)706            p.set_fill_color(*INK)707            p.set_text_color(*WHITE)708            for c in cols:709                p.cell(w, 6, " " + str(c)[:30], fill=True)710            p.ln(6)711        head()712        p.set_text_color(*INK)713        for i, row in enumerate(rows[:max_rows]):714            if p.get_y() > 262:715                p.add_page()716                head()717                p.set_text_color(*INK)718            p.set_font("helvetica", "", 7.4)719            p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))720            for cell in row:721                txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)722                p.cell(w, 5.4, " " + txt[:34], fill=True)723            p.ln(5.4)724        if len(rows) > max_rows:725            p.set_font("helvetica", "", 7)726            p.set_text_color(*INK3)727            p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")728        p.ln(6)729730    def _records(self):731        recs = self.d.get("records") or []732        if not recs:733            return734        self._section_title("Records & faits marquants")735        p = self.pdf736        for r in recs[:14]:737            if p.get_y() > 258:738                p.add_page()739            y = p.get_y()740            self._card(p.l_margin, y, 174, 11, fill=SURFACE2)741            p.set_xy(p.l_margin + 4, y + 2)742            p.set_font("helvetica", "", 8.6)743            p.set_text_color(*INK2)744            p.cell(96, 7, str(r.get("label", ""))[:70])745            p.set_font("helvetica", "B", 9)746            p.set_text_color(*INK)747            p.cell(52, 7, str(r.get("value", ""))[:36], align="R")748            p.set_font("helvetica", "", 7.6)749            p.set_text_color(*INK3)750            p.cell(20, 7, str(r.get("date", "") or ""), align="R")751            p.set_y(y + 13.5)752        p.ln(4)753754    def _final_page(self):755        p = self.pdf756        p.add_page()757        self._kicker("Groupe KA · contact")758        p.set_font("helvetica", "B", 15)759        p.set_text_color(*INK)760        p.cell(0, 8, "Coordonnées du Groupe KA")761        p.ln(12)762        for email, role in EMAILS:763            p.set_font("helvetica", "B", 10.5)764            p.set_text_color(*INK)765            p.cell(0, 6, email)766            p.ln(5.5)767            p.set_font("helvetica", "", 8.6)768            p.set_text_color(*INK3)769            p.cell(0, 5, role)770            p.ln(8)771        p.ln(2)772        p.set_font("helvetica", "B", 10)773        p.set_text_color(*GREEN)774        p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")775        p.ln(10)776        p.set_draw_color(*self.accent)777        p.set_line_width(0.8)778        p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())779        p.ln(4)780        p.set_font("helvetica", "", 8.6)781        p.set_text_color(*INK2)782        p.multi_cell(160, 4.6, DISCLAIMER)783        p.ln(4)784        p.set_font("helvetica", "", 7.6)785        p.set_text_color(*INK3)786        p.multi_cell(787            160, 4.2,788            "Mentions : rapport généré automatiquement à partir des données réelles de la "789            "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "790            "de confidentialité et protection des renseignements personnels (Loi 25) : "791            "groupe-ka.com/conditions · /confidentialite · /loi-25.",792        )793794    # ---------- groupes de sections ----------795    def _all_series(self, with_stats=True):796        for s in self.d.get("series") or []:797            self._line_chart(s, with_stats=with_stats)798        for ms in self.d.get("multiseries") or []:799            self._multiline(ms)800        for st in self.d.get("stacked") or []:801            self._stacked(st)802803    def _all_breakdowns(self):804        for b in self.d.get("breakdowns") or []:805            if b.get("kind") == "donut":806                self._donut(b)807            else:808                self._bars(b.get("title", ""), b.get("items"))809        for dist in self.d.get("distributions") or []:810            self._vbars(dist)811        geo = self.d.get("geo")812        if geo:813            self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))814        self._hourly()815816    def build(self) -> bytes:817        p = self.pdf818        p.alias_nb_pages()819        self._cover()820        with_toc = self.mode in ("complet", "donnees")821        toc_page_no = None822        if self.mode == "synthese":823            p.add_page()824            self._kpis()825            self._gauges()826            self._records()827            self._final_page()828        elif self.mode == "tendances":829            p.add_page()830            self._kpis()831            self._section_title("Évolution & tendances")832            self._all_series(with_stats=True)833            self._records()834            self._final_page()835        elif self.mode == "repartitions":836            p.add_page()837            self._section_title("Répartitions, distributions & géographie")838            self._all_breakdowns()839            self._final_page()840        elif self.mode == "donnees":841            p.add_page()842            toc_page_no = p.page_no()843            for t in self.d.get("tables") or []:844                self._table(t, max_rows=400)845            self._final_page()846        else:  # complet847            p.add_page()848            toc_page_no = p.page_no()849            p.add_page()850            self._kpis()851            self._gauges()852            if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):853                self._section_title("Évolution & tendances")854                self._all_series(with_stats=True)855            if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):856                self._section_title("Répartitions, distributions & géographie")857                self._all_breakdowns()858            for t in self.d.get("tables") or []:859                self._table(t)860            self._records()861            self._final_page()862        # sommaire écrit sur la page réservée863        if toc_page_no is not None:864            last_page = p.page865            p.page = toc_page_no866            p.set_y(22)867            p.set_font("helvetica", "B", 15)868            p.set_text_color(*INK)869            p.cell(0, 8, "Sommaire")870            p.ln(12)871            p.set_font("helvetica", "", 9.5)872            for title, page_no in self.toc:873                p.set_text_color(*INK)874                p.cell(140, 6.5, title[:80])875                p.set_text_color(*INK3)876                p.cell(0, 6.5, str(page_no), align="R")877                p.ln(6.5)878            p.page = last_page879        return bytes(p.output())880881882def filename(platform_id: str, period: str, mode: str = "complet") -> str:883    today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")884    suffix = "" if mode in ("", "complet") else f"_{mode}"885    return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"886
Files 192c17ff2b60ed923b3df6d92ec820bf45d5c8cf
Menu

Theme