Repository Public
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