API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
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, title: str = "Synthèse des indicateurs"):236 kpis = self.d.get("kpis") or []237 if not kpis:238 return239 self._section_title(title)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