SPB Git

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

21.0 KB · 569 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-178# ============================================9# Auteur : Simon-Pierre Boucher — contact@spboucher.ai10# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).11# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit12# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques13# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.14# Usage :15#   from kapdf import GroupeKAReport16#   pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",17#       "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,18#       mode="complet").build()19# Dépendance : pip install fpdf2  (aucune autre)20from __future__ import annotations2122import math23from datetime import datetime24from zoneinfo import ZoneInfo2526from fpdf import FPDF2728INK = (20, 24, 20)29INK2 = (77, 85, 81)30INK3 = (139, 146, 140)31PAPER = (245, 243, 238)32SURFACE2 = (250, 249, 245)33GREEN = (28, 92, 65)34DANGER = (179, 66, 58)35WHITE = (255, 255, 255)3637EMAILS = [38    ("contact@groupe-ka.com", "Projets, partenariats & données"),39    ("info@groupe-ka.com", "Médias & questions générales"),40    ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),41]42DISCLAIMER = (43    "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "44    "rien et ne sommes partie à aucune transaction. Données lues à la source, "45    "rien d'inventé, tout est traçable."46)474849def _hex(c: str) -> tuple[int, int, int]:50    c = c.lstrip("#")51    return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4))  # type: ignore525354def _fr(n) -> str:55    if isinstance(n, float) and not n.is_integer():56        return f"{n:,.2f}".replace(",", " ").replace(".", ",")57    return f"{int(n):,}".replace(",", " ")585960_SUBST = {61    "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",62    "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',63    "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",64}656667def _latin1(s: str) -> str:68    for k, v in _SUBST.items():69        s = s.replace(k, v)70    return s.encode("latin-1", "replace").decode("latin-1")717273class _PDF(FPDF):74    """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).75    Les polices core sont latin-1 : normalize_text sanitise en amont."""7677    def normalize_text(self, text):78        return super().normalize_text(_latin1(text))7980    def __init__(self, brand: str, accent: tuple, period_label: str):81        super().__init__(orientation="P", unit="mm", format="A4")82        self.brand = brand83        self.accent = accent84        self.period_label = period_label85        self.cover_mode = False86        self.set_margins(18, 20, 18)87        self.set_auto_page_break(True, margin=22)8889    def header(self):90        if self.cover_mode:91            return92        self.set_font("helvetica", "B", 8.5)93        self.set_text_color(*INK)94        self.set_xy(18, 9)95        self.cell(0, 5, f"Groupe KA · {self.brand}")96        self.set_font("helvetica", "", 8)97        self.set_text_color(*INK3)98        self.set_xy(18, 9)99        self.cell(0, 5, "Rapport statistique", align="R")100        self.set_draw_color(*INK)101        self.set_line_width(0.5)102        self.line(18, 15.5, 192, 15.5)103        self.set_y(20)104105    def footer(self):106        # La page 1 est toujours la couverture : fpdf dessine son pied de page107        # au add_page() suivant, quand cover_mode est deja retombe a False.108        if self.cover_mode or self.page_no() == 1:109            return110        self.set_y(-15)111        self.set_draw_color(*INK3)112        self.set_line_width(0.2)113        self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)114        self.set_font("helvetica", "", 7.5)115        self.set_text_color(*INK3)116        year = datetime.now(ZoneInfo("America/Toronto")).year117        self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")118        self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")119120121class GroupeKAReport:122    def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):123        self.site = site124        self.d = dashboard125        self.mode = mode126        self.accent = _hex(site.get("accent", "#d9f26b"))127        period = dashboard.get("period", {}) or {}128        self.period_label = period.get("label") or "toute la période"129        self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)130        self.toc: list[tuple[str, int]] = []131132    # ---------- primitives ----------133    def _card(self, x, y, w, h, fill=WHITE):134        p = self.pdf135        p.set_draw_color(*INK)136        p.set_line_width(0.45)137        p.set_fill_color(*fill)138        p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)139140    def _kicker(self, text):141        p = self.pdf142        p.set_font("helvetica", "B", 8)143        p.set_text_color(*GREEN)144        p.set_draw_color(*GREEN)145        p.set_line_width(0.6)146        y = p.get_y() + 2147        p.line(p.l_margin, y, p.l_margin + 7, y)148        p.set_xy(p.l_margin + 9, y - 2.5)149        p.cell(0, 5, text.upper())150        p.ln(8)151152    def _section_title(self, title):153        if self.pdf.get_y() > 240:154            self.pdf.add_page()155        self._kicker("Groupe KA · " + self.site.get("wordmark", ""))156        self.pdf.set_font("helvetica", "B", 15)157        self.pdf.set_text_color(*INK)158        self.pdf.set_x(self.pdf.l_margin)159        self.pdf.cell(0, 8, title)160        self.toc.append((title, self.pdf.page_no()))161        self.pdf.ln(11)162163    # ---------- pages ----------164    def _cover(self):165        p = self.pdf166        p.cover_mode = True167        p.set_auto_page_break(False)168        p.add_page()169        p.set_fill_color(*PAPER)170        p.rect(0, 0, 210, 297, style="F")171        p.set_draw_color(*INK)172        p.set_line_width(1.0)173        p.rect(10, 10, 190, 277)174        # kicker175        p.set_font("helvetica", "B", 10)176        p.set_text_color(*GREEN)177        p.set_xy(24, 34)178        p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")179        # wordmark : partie gauche + boîte encre/accent180        wm = self.site.get("wordmark", "")181        left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)182        p.set_xy(24, 70)183        p.set_font("helvetica", "B", 40)184        p.set_text_color(*INK)185        p.cell(p.get_string_width(left) + 2, 20, left)186        if boxed:187            bw = p.get_string_width(boxed) + 12188            x = p.get_x() + 2189            p.set_fill_color(*INK)190            p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)191            p.set_text_color(*self.accent)192            p.set_xy(x + 6, 70)193            p.cell(bw - 12, 18, boxed)194        p.set_xy(24, 100)195        p.set_font("helvetica", "", 13)196        p.set_text_color(*INK2)197        p.multi_cell(150, 7, f"Rapport statistique — {wm}")198        now = datetime.now(ZoneInfo("America/Toronto"))199        per = self.d.get("period", {}) or {}200        p.set_xy(24, 125)201        p.set_font("helvetica", "", 10.5)202        rows = [203            ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),204            ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),205            ("Plateforme", "https://" + self.site.get("domain", "")),206            ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),207        ]208        y = 128209        for k, v in rows:210            p.set_xy(24, y)211            p.set_text_color(*INK3)212            p.cell(40, 6, k)213            p.set_text_color(*INK)214            p.set_font("helvetica", "B", 10.5)215            p.cell(0, 6, str(v))216            p.set_font("helvetica", "", 10.5)217            y += 8218        # bande encre au pied219        p.set_fill_color(*INK)220        p.rect(10, 262, 190, 25, style="F")221        p.set_xy(24, 270)222        p.set_font("helvetica", "B", 12)223        p.set_text_color(*WHITE)224        p.cell(60, 8, "par Groupe ")225        p.set_text_color(*self.accent)226        p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)227        p.cell(20, 8, "KA")228        p.set_font("helvetica", "B", 10)229        p.set_xy(24, 270)230        p.set_text_color(*self.accent)231        p.cell(162, 8, "groupe-ka.com", align="R")232        p.set_auto_page_break(True, margin=22)233        p.cover_mode = False234235    def _kpis(self):236        kpis = self.d.get("kpis") or []237        if not kpis:238            return239        self._section_title("Synthèse des indicateurs")240        p = self.pdf241        cols, gw, gh, gap = 3, 56, 26, 3242        x0, y = p.l_margin, p.get_y()243        for i, k in enumerate(kpis[:9]):244            x = x0 + (i % cols) * (gw + gap)245            if i and i % cols == 0:246                y += gh + gap247            if y > 250:248                p.add_page(); y = p.get_y()249            self._card(x, y, gw, gh)250            p.set_xy(x + 4, y + 4)251            p.set_font("helvetica", "B", 14)252            p.set_text_color(*INK)253            val = k.get("value")254            p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))255            p.set_xy(x + 4, y + 12)256            p.set_font("helvetica", "", 7.6)257            p.set_text_color(*INK2)258            p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])259            if k.get("delta_pct") is not None:260                up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"261                p.set_xy(x + 4, y + gh - 6.5)262                p.set_font("helvetica", "B", 8)263                p.set_text_color(*(GREEN if up else DANGER))264                arrow = "+" if k["delta_pct"] >= 0 else ""265                p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")266        p.set_y(y + gh + 8)267268    def _line_chart(self, s):269        p = self.pdf270        pts = s.get("points") or []271        if len(pts) < 2:272            return273        if p.get_y() > 200:274            p.add_page()275        p.set_font("helvetica", "B", 10)276        p.set_text_color(*INK)277        p.cell(0, 6, s.get("title", ""))278        p.ln(7)279        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52280        self._card(x0, y0, w, h, fill=WHITE)281        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16282        vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]283        vmax = max(vals) or 1284        vmin = min(0, min(vals))285        rng = (vmax - vmin) or 1286        # grille + graduations287        p.set_font("helvetica", "", 6.3)288        p.set_text_color(*INK3)289        p.set_draw_color(200, 200, 195)290        p.set_line_width(0.15)291        for g in range(5):292            gy = cy + ch - ch * g / 4293            p.line(cx, gy, cx + cw, gy)294            p.set_xy(x0 + 1, gy - 1.6)295            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")296297        def draw(series, color, width, dash=None):298            n = len(series)299            p.set_draw_color(*color)300            p.set_line_width(width)301            if dash:302                p.set_dash_pattern(dash=1.2, gap=1.2)303            last = None304            for i, pt in enumerate(series):305                px = cx + cw * (i / (n - 1))306                py = cy + ch - ch * ((pt["v"] - vmin) / rng)307                if last:308                    p.line(last[0], last[1], px, py)309                last = (px, py)310            p.set_dash_pattern()311312        if s.get("compare"):313            draw(s["compare"], INK3, 0.35, dash=True)314        draw(pts, self.accent, 0.7)315        # libellés d'axe X (premier / milieu / dernier)316        p.set_text_color(*INK3)317        for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):318            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)319            p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")320        p.set_y(y0 + h + 4)321        if s.get("compare"):322            p.set_font("helvetica", "", 6.8)323            p.set_text_color(*INK3)324            p.cell(0, 4, "— période courante (accent) · ---- période comparée")325            p.ln(6)326        else:327            p.ln(2)328329    def _bars(self, title, items, unit=""):330        p = self.pdf331        items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]332        if not items:333            return334        need = 10 + len(items) * 7335        if p.get_y() + need > 265:336            p.add_page()337        p.set_font("helvetica", "B", 10)338        p.set_text_color(*INK)339        p.cell(0, 6, title)340        p.ln(8)341        vmax = max(it["value"] for it in items) or 1342        for it in items:343            y = p.get_y()344            p.set_font("helvetica", "", 7.6)345            p.set_text_color(*INK)346            p.set_x(p.l_margin)347            p.cell(46, 5, str(it["label"])[:34])348            bw = 96 * (it["value"] / vmax)349            p.set_fill_color(*self.accent)350            p.set_draw_color(*INK)351            p.set_line_width(0.25)352            p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")353            p.set_xy(p.l_margin + 148, y)354            p.set_font("helvetica", "B", 7.6)355            p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")356            p.ln(6.4)357        p.ln(3)358359    def _donut(self, b):360        # anneau vectoriel simple (arcs) + légende361        p = self.pdf362        items = [it for it in (b.get("items") or []) if it.get("value")][:8]363        total = sum(it["value"] for it in items)364        if not items or not total:365            return366        if p.get_y() > 210:367            p.add_page()368        p.set_font("helvetica", "B", 10)369        p.set_text_color(*INK)370        p.cell(0, 6, b.get("title", ""))371        p.ln(8)372        cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20373        shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]374        start = -90.0375        for i, it in enumerate(items):376            frac = it["value"] / total377            f = shades[i % len(shades)]378            col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))379            steps = max(2, int(72 * frac))380            p.set_fill_color(*col)381            p.set_draw_color(*col)382            for st in range(steps):383                a0 = math.radians(start + 360 * frac * st / steps)384                a1 = math.radians(start + 360 * frac * (st + 1) / steps)385                p.polygon(386                    [(cx, cy),387                     (cx + r * math.cos(a0), cy + r * math.sin(a0)),388                     (cx + r * math.cos(a1), cy + r * math.sin(a1))],389                    style="DF",390                )391            start += 360 * frac392        p.set_fill_color(*WHITE)393        p.set_draw_color(*INK)394        p.set_line_width(0.4)395        p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")396        p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")397        # légende398        ly = cy - 22399        for i, it in enumerate(items):400            f = shades[i % len(shades)]401            col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))402            p.set_fill_color(*col)403            p.set_draw_color(*INK)404            p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")405            p.set_xy(p.l_margin + 66, ly)406            p.set_font("helvetica", "", 7.6)407            p.set_text_color(*INK)408            pct = 100 * it["value"] / total409            p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))410            ly += 5.6411        p.set_y(max(cy + r, ly) + 6)412413    def _table(self, t):414        p = self.pdf415        cols = t.get("columns") or []416        rows = t.get("rows") or []417        if not cols or not rows:418            return419        self._section_title(t.get("title", "Tableau"))420        w = 174 / len(cols)421        def head():422            p.set_font("helvetica", "B", 7.6)423            p.set_fill_color(*INK)424            p.set_text_color(*WHITE)425            for c in cols:426                p.cell(w, 6, " " + str(c)[:30], fill=True)427            p.ln(6)428        head()429        p.set_text_color(*INK)430        for i, row in enumerate(rows[:200]):431            if p.get_y() > 262:432                p.add_page()433                head()434                p.set_text_color(*INK)435            p.set_font("helvetica", "", 7.4)436            p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))437            for cell in row:438                txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)439                p.cell(w, 5.4, " " + txt[:34], fill=True)440            p.ln(5.4)441        if len(rows) > 200:442            p.set_font("helvetica", "", 7)443            p.set_text_color(*INK3)444            p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")445        p.ln(6)446447    def _records(self):448        recs = self.d.get("records") or []449        if not recs:450            return451        self._section_title("Records & faits marquants")452        p = self.pdf453        for r in recs[:10]:454            if p.get_y() > 258:455                p.add_page()456            y = p.get_y()457            self._card(p.l_margin, y, 174, 11, fill=SURFACE2)458            p.set_xy(p.l_margin + 4, y + 2)459            p.set_font("helvetica", "", 8.6)460            p.set_text_color(*INK2)461            p.cell(96, 7, str(r.get("label", ""))[:70])462            p.set_font("helvetica", "B", 9)463            p.set_text_color(*INK)464            p.cell(52, 7, str(r.get("value", ""))[:36], align="R")465            p.set_font("helvetica", "", 7.6)466            p.set_text_color(*INK3)467            p.cell(20, 7, str(r.get("date", "") or ""), align="R")468            p.set_y(y + 13.5)469        p.ln(4)470471    def _final_page(self):472        p = self.pdf473        p.add_page()474        self._kicker("Groupe KA · contact")475        p.set_font("helvetica", "B", 15)476        p.set_text_color(*INK)477        p.cell(0, 8, "Coordonnées du Groupe KA")478        p.ln(12)479        for email, role in EMAILS:480            p.set_font("helvetica", "B", 10.5)481            p.set_text_color(*INK)482            p.cell(0, 6, email)483            p.ln(5.5)484            p.set_font("helvetica", "", 8.6)485            p.set_text_color(*INK3)486            p.cell(0, 5, role)487            p.ln(8)488        p.ln(2)489        p.set_font("helvetica", "B", 10)490        p.set_text_color(*GREEN)491        p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")492        p.ln(10)493        p.set_draw_color(*self.accent)494        p.set_line_width(0.8)495        p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())496        p.ln(4)497        p.set_font("helvetica", "", 8.6)498        p.set_text_color(*INK2)499        p.multi_cell(160, 4.6, DISCLAIMER)500        p.ln(4)501        p.set_font("helvetica", "", 7.6)502        p.set_text_color(*INK3)503        p.multi_cell(504            160, 4.2,505            "Mentions : rapport généré automatiquement à partir des données réelles de la "506            "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "507            "de confidentialité et protection des renseignements personnels (Loi 25) : "508            "groupe-ka.com/conditions · /confidentialite · /loi-25.",509        )510511    def _toc_page(self):512        # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en513        # page 2 en réservant la page lors du build (voir build()).514        pass515516    def build(self) -> bytes:517        p = self.pdf518        p.alias_nb_pages()519        self._cover()520        if self.mode == "synthese":521            p.add_page()522            self._kpis()523            self._records()524            self._final_page()525        else:526            p.add_page()527            toc_page_no = p.page_no()528            p.add_page()529            self._kpis()530            for s in self.d.get("series") or []:531                if s.get("kind") == "bar":532                    self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))533                else:534                    self._line_chart(s)535            for b in self.d.get("breakdowns") or []:536                if b.get("kind") == "donut":537                    self._donut(b)538                else:539                    self._bars(b.get("title", ""), b.get("items"))540            geo = self.d.get("geo")541            if geo:542                self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))543            for t in self.d.get("tables") or []:544                self._table(t)545            self._records()546            self._final_page()547            # sommaire écrit sur la page réservée (page 2)548            last_page = p.page549            p.page = toc_page_no550            p.set_y(22)551            p.set_font("helvetica", "B", 15)552            p.set_text_color(*INK)553            p.cell(0, 8, "Sommaire")554            p.ln(12)555            p.set_font("helvetica", "", 9.5)556            for title, page_no in self.toc:557                p.set_text_color(*INK)558                p.cell(140, 6.5, title[:80])559                p.set_text_color(*INK3)560                p.cell(0, 6.5, str(page_no), align="R")561                p.ln(6.5)562            p.page = last_page563        return bytes(p.output())564565566def filename(platform_id: str, period: str) -> str:567    today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")568    return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"569
Files · 792661b70946e1e0f47211608f696c2270459551
Menu

Theme