API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
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). v311# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit12# les rapports estampillés Groupe-KA. 5 modes fixes :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# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par21# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/22# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport23# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",24# "render": "bar"}, …]} et respecte l'ordre demandé.25# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.26# Usage :27# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename28# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",29# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,30# mode="complet").build()31# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",32# spec={"title": "Mon rapport", "blocks": [...]}).build()33# Dépendance : pip install fpdf2 (aucune autre)34from __future__ import annotations3536import math37from datetime import datetime38from zoneinfo import ZoneInfo3940from fpdf import FPDF4142INK = (20, 24, 20)43INK2 = (77, 85, 81)44INK3 = (139, 146, 140)45PAPER = (245, 243, 238)46SURFACE2 = (250, 249, 245)47GREEN = (28, 92, 65)48DANGER = (179, 66, 58)49WHITE = (255, 255, 255)5051REPORT_MODES = {52 "complet": "Rapport complet",53 "synthese": "Synthèse exécutive",54 "tendances": "Tendances & évolution",55 "repartitions": "Répartitions & géographie",56 "donnees": "Données détaillées",57}58# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)59CUSTOM_MODE = "personnalise"60CUSTOM_LABEL = "Rapport personnalisé"6162# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;63# « table » est toujours offert : toute donnée a un équivalent tableau)64RENDER_LABELS = {65 "line": "Courbe", "area": "Aire", "bar": "Barres verticales",66 "bars": "Barres horizontales", "donut": "Anneau",67 "lines": "Multi-courbes", "stacked": "Barres empilées",68 "histogram": "Histogramme", "heatmap": "Heatmap",69 "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",70}71SECTION_LABELS = {72 "kpis": "Indicateurs", "gauges": "Taux & couvertures",73 "series": "Évolution", "multiseries": "Comparaisons",74 "stacked": "Compositions", "breakdowns": "Répartitions",75 "distributions": "Distributions", "geo": "Géographie",76 "heatmap": "Calendrier", "hourly": "Activité horaire",77 "tables": "Tableaux", "records": "Records",78}798081def catalog(dash: dict) -> list[dict]:82 """v3 — blocs composables d'un dashboard : ce que le constructeur de83 rapports personnalisés peut inclure, avec les rendus compatibles.84 key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""85 out: list[dict] = []8687 def add(key, title, renders, default=None, count=None):88 b = {"key": key, "section": key.split(":")[0], "title": title,89 "renders": renders, "default_render": default or renders[0]}90 if count is not None:91 b["count"] = count92 out.append(b)9394 if dash.get("kpis"):95 add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],96 count=len(dash["kpis"]))97 gs = [g for g in (dash.get("gauges") or [])98 if isinstance(g.get("value"), (int, float)) and g.get("max")]99 if gs:100 add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],101 count=len(gs))102 for s in dash.get("series") or []:103 if len(s.get("points") or []) < 2:104 continue105 kind = s.get("kind") or "line"106 default = kind if kind in ("line", "area", "bar") else "line"107 add(f"series:{s.get('id')}", s.get("title", ""),108 ["line", "area", "bar", "table"], default,109 len(s.get("points") or []))110 for ms in dash.get("multiseries") or []:111 if not (ms.get("series") or []):112 continue113 add(f"multiseries:{ms.get('id')}", ms.get("title", ""),114 ["lines", "table"], count=len(ms["series"]))115 for st in dash.get("stacked") or []:116 if not (st.get("points") or []):117 continue118 add(f"stacked:{st.get('id')}", st.get("title", ""),119 ["stacked", "table"], count=len(st.get("keys") or []))120 for b in dash.get("breakdowns") or []:121 if not (b.get("items") or []):122 continue123 default = "donut" if b.get("kind") == "donut" else "bars"124 add(f"breakdowns:{b.get('id')}", b.get("title", ""),125 ["donut", "bars", "table"], default, len(b["items"]))126 for d in dash.get("distributions") or []:127 if not (d.get("bins") or []):128 continue129 add(f"distributions:{d.get('id')}", d.get("title", ""),130 ["histogram", "table"], count=len(d["bins"]))131 geo = dash.get("geo") or {}132 if geo.get("items"):133 add("geo", geo.get("title", "Répartition géographique"),134 ["bars", "table"], count=len(geo["items"]))135 hm = dash.get("heatmap") or {}136 if hm.get("cells"):137 add("heatmap", hm.get("title", "Calendrier d'activité"),138 ["heatmap", "table"])139 hr = dash.get("hourly") or {}140 if hr.get("cells"):141 add("hourly", hr.get("title", "Activité par jour et heure"),142 ["heatmap", "table"])143 for t in dash.get("tables") or []:144 if not (t.get("rows") or []):145 continue146 add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],147 count=len(t["rows"]))148 if dash.get("records"):149 add("records", "Records & faits marquants", ["cards", "table"],150 count=len(dash["records"]))151 return out152153EMAILS = [154 ("contact@groupe-ka.com", "Projets, partenariats & données"),155 ("info@groupe-ka.com", "Médias & questions générales"),156 ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),157]158DISCLAIMER = (159 "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "160 "rien et ne sommes partie à aucune transaction. Données lues à la source, "161 "rien d'inventé, tout est traçable."162)163164165def _hex(c: str) -> tuple[int, int, int]:166 c = c.lstrip("#")167 return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore168169170def _fr(n) -> str:171 if isinstance(n, float) and not n.is_integer():172 return f"{n:,.2f}".replace(",", " ").replace(".", ",")173 return f"{int(n):,}".replace(",", " ")174175176_SUBST = {177 "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",178 "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',179 "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",180 "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",181}182183184def _latin1(s: str) -> str:185 for k, v in _SUBST.items():186 s = s.replace(k, v)187 return s.encode("latin-1", "replace").decode("latin-1")188189190class _PDF(FPDF):191 """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).192 Les polices core sont latin-1 : normalize_text sanitise en amont."""193194 def normalize_text(self, text):195 return super().normalize_text(_latin1(text))196197 def __init__(self, brand: str, accent: tuple, period_label: str):198 super().__init__(orientation="P", unit="mm", format="A4")199 self.brand = brand200 self.accent = accent201 self.period_label = period_label202 self.cover_mode = False203 self.set_margins(18, 20, 18)204 self.set_auto_page_break(True, margin=22)205206 def header(self):207 if self.cover_mode or self.page_no() == 1:208 return209 self.set_font("helvetica", "B", 8.5)210 self.set_text_color(*INK)211 self.set_xy(18, 9)212 self.cell(0, 5, f"Groupe KA · {self.brand}")213 self.set_font("helvetica", "", 8)214 self.set_text_color(*INK3)215 self.set_xy(18, 9)216 self.cell(0, 5, "Rapport statistique", align="R")217 self.set_draw_color(*INK)218 self.set_line_width(0.5)219 self.line(18, 15.5, 192, 15.5)220 self.set_y(20)221222 def footer(self):223 # page 1 = couverture (le flag cover_mode est déjà retombé quand224 # add_page() clôt la page 1 → tester aussi le numéro de page)225 if self.cover_mode or self.page_no() == 1:226 return227 self.set_y(-15)228 self.set_draw_color(*INK3)229 self.set_line_width(0.2)230 self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)231 self.set_font("helvetica", "", 7.5)232 self.set_text_color(*INK3)233 year = datetime.now(ZoneInfo("America/Toronto")).year234 self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")235 self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")236237238class GroupeKAReport:239 def __init__(self, site: dict, dashboard: dict, mode: str = "complet",240 spec: dict | None = None):241 self.site = site242 self.d = dashboard243 self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"244 self.spec = spec or {}245 self.accent = _hex(site.get("accent", "#d9f26b"))246 period = dashboard.get("period", {}) or {}247 self.period_label = period.get("label") or "toute la période"248 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)249 self.toc: list[tuple[str, int]] = []250251 @property252 def mode_label(self) -> str:253 if self.mode == CUSTOM_MODE:254 t = str(self.spec.get("title") or "").strip()255 return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL256 return REPORT_MODES[self.mode]257258 # ---------- primitives ----------259 def _card(self, x, y, w, h, fill=WHITE):260 p = self.pdf261 p.set_draw_color(*INK)262 p.set_line_width(0.45)263 p.set_fill_color(*fill)264 p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)265266 def _shade(self, i, n=8):267 shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]268 f = shades[i % len(shades)]269 return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))270271 def _kicker(self, text):272 p = self.pdf273 p.set_font("helvetica", "B", 8)274 p.set_text_color(*GREEN)275 p.set_draw_color(*GREEN)276 p.set_line_width(0.6)277 y = p.get_y() + 2278 p.line(p.l_margin, y, p.l_margin + 7, y)279 p.set_xy(p.l_margin + 9, y - 2.5)280 p.cell(0, 5, text.upper())281 p.ln(8)282283 def _section_title(self, title):284 if self.pdf.get_y() > 240:285 self.pdf.add_page()286 self._kicker("Groupe KA · " + self.site.get("wordmark", ""))287 self.pdf.set_font("helvetica", "B", 15)288 self.pdf.set_text_color(*INK)289 self.pdf.set_x(self.pdf.l_margin)290 self.pdf.cell(0, 8, title)291 self.toc.append((title, self.pdf.page_no()))292 self.pdf.ln(11)293294 def _chart_title(self, title):295 p = self.pdf296 p.set_font("helvetica", "B", 10)297 p.set_text_color(*INK)298 p.set_x(p.l_margin)299 p.cell(0, 6, title)300 p.ln(7)301302 # ---------- pages ----------303 def _cover(self):304 p = self.pdf305 p.cover_mode = True306 p.set_auto_page_break(False)307 p.add_page()308 p.set_fill_color(*PAPER)309 p.rect(0, 0, 210, 297, style="F")310 p.set_draw_color(*INK)311 p.set_line_width(1.0)312 p.rect(10, 10, 190, 277)313 p.set_font("helvetica", "B", 10)314 p.set_text_color(*GREEN)315 p.set_xy(24, 34)316 p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")317 wm = self.site.get("wordmark", "")318 left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)319 p.set_xy(24, 70)320 p.set_font("helvetica", "B", 40)321 p.set_text_color(*INK)322 p.cell(p.get_string_width(left) + 2, 20, left)323 if boxed:324 bw = p.get_string_width(boxed) + 12325 x = p.get_x() + 2326 p.set_fill_color(*INK)327 p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)328 p.set_text_color(*self.accent)329 p.set_xy(x + 6, 70)330 p.cell(bw - 12, 18, boxed)331 p.set_xy(24, 100)332 p.set_font("helvetica", "", 13)333 p.set_text_color(*INK2)334 p.multi_cell(150, 7, f"{self.mode_label} — {wm}")335 now = datetime.now(ZoneInfo("America/Toronto"))336 per = self.d.get("period", {}) or {}337 p.set_xy(24, 125)338 p.set_font("helvetica", "", 10.5)339 rows = [340 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),341 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),342 ("Plateforme", "https://" + self.site.get("domain", "")),343 ("Type de rapport", self.mode_label),344 ]345 y = 128346 for k, v in rows:347 p.set_xy(24, y)348 p.set_text_color(*INK3)349 p.cell(40, 6, k)350 p.set_text_color(*INK)351 p.set_font("helvetica", "B", 10.5)352 p.cell(0, 6, str(v))353 p.set_font("helvetica", "", 10.5)354 y += 8355 p.set_fill_color(*INK)356 p.rect(10, 262, 190, 25, style="F")357 p.set_xy(24, 270)358 p.set_font("helvetica", "B", 12)359 p.set_text_color(*WHITE)360 p.cell(60, 8, "par Groupe ")361 p.set_text_color(*self.accent)362 p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)363 p.cell(20, 8, "KA")364 p.set_font("helvetica", "B", 10)365 p.set_xy(24, 270)366 p.set_text_color(*self.accent)367 p.cell(162, 8, "groupe-ka.com", align="R")368 p.set_auto_page_break(True, margin=22)369 p.cover_mode = False370371 def _kpis(self):372 kpis = self.d.get("kpis") or []373 if not kpis:374 return375 self._section_title("Synthèse des indicateurs")376 p = self.pdf377 cols, gw, gh, gap = 3, 56, 26, 3378 x0, y = p.l_margin, p.get_y()379 for i, k in enumerate(kpis[:12]):380 x = x0 + (i % cols) * (gw + gap)381 if i and i % cols == 0:382 y += gh + gap383 if y > 250:384 p.add_page(); y = p.get_y()385 self._card(x, y, gw, gh)386 p.set_xy(x + 4, y + 4)387 p.set_font("helvetica", "B", 14)388 p.set_text_color(*INK)389 val = k.get("value")390 p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))391 p.set_xy(x + 4, y + 12)392 p.set_font("helvetica", "", 7.6)393 p.set_text_color(*INK2)394 p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])395 if k.get("delta_pct") is not None:396 up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"397 p.set_xy(x + 4, y + gh - 6.5)398 p.set_font("helvetica", "B", 8)399 p.set_text_color(*(GREEN if up else DANGER))400 arrow = "+" if k["delta_pct"] >= 0 else ""401 dv = round(float(k["delta_pct"]), 1)402 dv = int(dv) if float(dv).is_integer() else dv403 p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")404 p.set_y(y + gh + 8)405406 def _gauges(self):407 gs = self.d.get("gauges") or []408 gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]409 if not gs:410 return411 self._section_title("Taux & couvertures")412 p = self.pdf413 cols, gw, gh, gap = 3, 56, 34, 3414 x0, y = p.l_margin, p.get_y()415 for i, g in enumerate(gs[:9]):416 x = x0 + (i % cols) * (gw + gap)417 if i and i % cols == 0:418 y += gh + gap419 if y > 240:420 p.add_page(); y = p.get_y()421 self._card(x, y, gw, gh)422 frac = max(0.0, min(1.0, g["value"] / g["max"]))423 cx, cy, r = x + gw / 2, y + 20, 14424 # arc de fond + arc de valeur (demi-cercle en petits segments)425 for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):426 p.set_draw_color(*pass_col)427 p.set_line_width(lw)428 steps = max(2, int(60 * pass_frac))429 last = None430 for st in range(steps + 1):431 a = math.pi + math.pi * pass_frac * st / steps432 pt = (cx + r * math.cos(a), cy + r * math.sin(a))433 if last:434 p.line(last[0], last[1], pt[0], pt[1])435 last = pt436 p.set_font("helvetica", "B", 11)437 p.set_text_color(*INK)438 p.set_xy(x + 4, cy - 5)439 p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")440 p.set_font("helvetica", "", 6.6)441 p.set_text_color(*INK3)442 p.set_xy(x + 4, cy + 1.5)443 p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")444 p.set_xy(x + 3, y + gh - 7)445 p.set_font("helvetica", "", 7)446 p.set_text_color(*INK2)447 p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")448 p.set_y(y + gh + 8)449450 def _serie_stats_row(self, s):451 """Ligne min/max/moyenne/médiane sous un graphique de série."""452 p = self.pdf453 vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]454 if len(vs) < 2:455 return456 sv = sorted(vs)457 mean = sum(vs) / len(vs)458 med = sv[len(sv) // 2]459 sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))460 p.set_font("helvetica", "", 6.8)461 p.set_text_color(*INK3)462 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))}")463 p.ln(5.5)464465 def _line_chart(self, s, with_stats=False):466 p = self.pdf467 pts = s.get("points") or []468 if len(pts) < 2:469 return470 if s.get("kind") == "bar":471 self._vbars(s)472 return473 if p.get_y() > 200:474 p.add_page()475 self._chart_title(s.get("title", ""))476 x0, y0, w, h = p.l_margin, p.get_y(), 174, 52477 self._card(x0, y0, w, h, fill=WHITE)478 cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16479 vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]480 vmax = max(vals) or 1481 vmin = min(0, min(vals))482 rng = (vmax - vmin) or 1483 p.set_font("helvetica", "", 6.3)484 p.set_text_color(*INK3)485 p.set_draw_color(200, 200, 195)486 p.set_line_width(0.15)487 for g in range(5):488 gy = cy + ch - ch * g / 4489 p.line(cx, gy, cx + cw, gy)490 p.set_xy(x0 + 1, gy - 1.6)491 p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")492493 def xy(i, n, v):494 return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))495496 # aire sous la courbe (kind=area) : petits trapèzes accent pâle497 if s.get("kind") == "area":498 fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))499 p.set_fill_color(*fill)500 p.set_draw_color(*fill)501 n = len(pts)502 for i in range(n - 1):503 x1, y1 = xy(i, n, pts[i]["v"])504 x2, y2 = xy(i + 1, n, pts[i + 1]["v"])505 p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")506507 def draw(series, color, width, dash=None):508 n = len(series)509 p.set_draw_color(*color)510 p.set_line_width(width)511 if dash:512 p.set_dash_pattern(dash=1.2, gap=1.2)513 last = None514 for i, pt in enumerate(series):515 px, py = xy(i, n, pt["v"])516 if last:517 p.line(last[0], last[1], px, py)518 last = (px, py)519 p.set_dash_pattern()520521 if s.get("compare"):522 draw(s["compare"], INK3, 0.35, dash=True)523 draw(pts, self.accent, 0.7)524 p.set_text_color(*INK3)525 for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):526 p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)527 p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")528 p.set_y(y0 + h + 4)529 if s.get("compare"):530 p.set_font("helvetica", "", 6.8)531 p.set_text_color(*INK3)532 p.cell(0, 4, "— période courante (accent) · ---- période comparée")533 p.ln(5.5)534 if with_stats:535 self._serie_stats_row(s)536 p.ln(1.5)537538 def _vbars(self, s):539 """Barres verticales : série kind=bar ou distribution (bins)."""540 p = self.pdf541 pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]542 pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]543 if not pts:544 return545 if p.get_y() > 205:546 p.add_page()547 self._chart_title(s.get("title", ""))548 x0, y0, w, h = p.l_margin, p.get_y(), 174, 48549 self._card(x0, y0, w, h, fill=WHITE)550 cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14551 vmax = max(pt["v"] for pt in pts) or 1552 p.set_font("helvetica", "", 6.3)553 p.set_text_color(*INK3)554 p.set_draw_color(200, 200, 195)555 p.set_line_width(0.15)556 for g in range(5):557 gy = cy + ch - ch * g / 4558 p.line(cx, gy, cx + cw, gy)559 p.set_xy(x0 + 1, gy - 1.6)560 p.cell(10, 3, _fr(vmax * g / 4), align="R")561 n = len(pts)562 bw = max(0.8, cw / n - 0.6)563 p.set_fill_color(*self.accent)564 p.set_draw_color(*INK)565 p.set_line_width(0.15)566 for i, pt in enumerate(pts):567 bh = ch * (pt["v"] / vmax)568 p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")569 p.set_text_color(*INK3)570 for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):571 p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)572 p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")573 p.set_y(y0 + h + 5)574575 def _multiline(self, ms):576 """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /577 gris pointillé — l'identité passe par le motif, pas la couleur seule."""578 p = self.pdf579 series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]580 if not series:581 return582 if p.get_y() > 195:583 p.add_page()584 self._chart_title(ms.get("title", ""))585 x0, y0, w, h = p.l_margin, p.get_y(), 174, 52586 self._card(x0, y0, w, h, fill=WHITE)587 cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16588 vals = [pt["v"] for s in series for pt in s["points"]]589 vmax = max(vals) or 1590 vmin = min(0, min(vals))591 rng = (vmax - vmin) or 1592 p.set_font("helvetica", "", 6.3)593 p.set_text_color(*INK3)594 p.set_draw_color(200, 200, 195)595 p.set_line_width(0.15)596 for g in range(5):597 gy = cy + ch - ch * g / 4598 p.line(cx, gy, cx + cw, gy)599 p.set_xy(x0 + 1, gy - 1.6)600 p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")601 styles = [602 (self.accent, 0.7, None),603 (INK, 0.45, None),604 (self.accent, 0.55, True),605 (INK3, 0.5, True),606 ]607 for si, s in enumerate(series):608 col, lw, dash = styles[si]609 p.set_draw_color(*col)610 p.set_line_width(lw)611 if dash:612 p.set_dash_pattern(dash=1.4, gap=1.2)613 n = len(s["points"])614 last = None615 for i, pt in enumerate(s["points"]):616 px = cx + cw * (i / (n - 1))617 py = cy + ch - ch * ((pt["v"] - vmin) / rng)618 if last:619 p.line(last[0], last[1], px, py)620 last = (px, py)621 p.set_dash_pattern()622 ref = series[0]["points"]623 p.set_text_color(*INK3)624 for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):625 p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)626 p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")627 p.set_y(y0 + h + 4)628 p.set_font("helvetica", "", 6.8)629 p.set_text_color(*INK3)630 marks = ["—", "—", "----", "----"]631 leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))632 p.cell(0, 4, leg[:120])633 p.ln(6)634635 def _stacked(self, st):636 p = self.pdf637 keys = (st.get("keys") or [])[:6]638 pts = st.get("points") or []639 if not keys or not pts:640 return641 if p.get_y() > 195:642 p.add_page()643 self._chart_title(st.get("title", ""))644 x0, y0, w, h = p.l_margin, p.get_y(), 174, 52645 self._card(x0, y0, w, h, fill=WHITE)646 cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16647 totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]648 vmax = max(totals) or 1649 p.set_font("helvetica", "", 6.3)650 p.set_text_color(*INK3)651 p.set_draw_color(200, 200, 195)652 p.set_line_width(0.15)653 for g in range(5):654 gy = cy + ch - ch * g / 4655 p.line(cx, gy, cx + cw, gy)656 p.set_xy(x0 + 1, gy - 1.6)657 p.cell(10, 3, _fr(vmax * g / 4), align="R")658 n = len(pts)659 bw = max(0.8, cw / n - 0.6)660 p.set_draw_color(*WHITE)661 p.set_line_width(0.12)662 for i, pt in enumerate(pts):663 yacc = cy + ch664 for j, k in enumerate(keys):665 v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0666 if not v:667 continue668 bh = ch * (v / vmax)669 yacc -= bh670 p.set_fill_color(*self._shade(j))671 p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")672 p.set_text_color(*INK3)673 for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):674 p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)675 p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")676 p.set_y(y0 + h + 4)677 # légende678 p.set_font("helvetica", "", 6.8)679 lx = p.l_margin680 for j, k in enumerate(keys):681 p.set_fill_color(*self._shade(j))682 p.set_draw_color(*INK)683 p.set_line_width(0.2)684 p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")685 p.set_xy(lx + 4, p.get_y())686 p.set_text_color(*INK2)687 txt = str(k)[:22]688 p.cell(p.get_string_width(txt) + 3, 4, txt)689 lx = p.get_x() + 3690 if lx > 165:691 break692 p.ln(7)693694 def _bars(self, title, items, unit=""):695 p = self.pdf696 items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]697 if not items:698 return699 need = 10 + len(items) * 7700 if p.get_y() + need > 265:701 p.add_page()702 self._chart_title(title)703 p.ln(1)704 vmax = max(it["value"] for it in items) or 1705 for it in items:706 y = p.get_y()707 p.set_font("helvetica", "", 7.6)708 p.set_text_color(*INK)709 p.set_x(p.l_margin)710 p.cell(46, 5, str(it["label"])[:34])711 bw = 86 * (it["value"] / vmax)712 p.set_fill_color(*self.accent)713 p.set_draw_color(*INK)714 p.set_line_width(0.25)715 p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")716 p.set_xy(p.l_margin + 136, y)717 p.set_font("helvetica", "B", 7.6)718 p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")719 if it.get("delta_pct") is not None:720 up = it["delta_pct"] >= 0721 p.set_font("helvetica", "B", 6.6)722 p.set_text_color(*(GREEN if up else DANGER))723 p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")724 p.ln(6.4)725 p.ln(3)726727 def _donut(self, b):728 p = self.pdf729 items = [it for it in (b.get("items") or []) if it.get("value")][:8]730 total = sum(it["value"] for it in items)731 if not items or not total:732 return733 if p.get_y() > 210:734 p.add_page()735 self._chart_title(b.get("title", ""))736 p.ln(1)737 cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20738 start = -90.0739 for i, it in enumerate(items):740 frac = it["value"] / total741 col = self._shade(i)742 steps = max(2, int(72 * frac))743 p.set_fill_color(*col)744 p.set_draw_color(*col)745 for st in range(steps):746 a0 = math.radians(start + 360 * frac * st / steps)747 a1 = math.radians(start + 360 * frac * (st + 1) / steps)748 p.polygon(749 [(cx, cy),750 (cx + r * math.cos(a0), cy + r * math.sin(a0)),751 (cx + r * math.cos(a1), cy + r * math.sin(a1))],752 style="DF",753 )754 start += 360 * frac755 p.set_fill_color(*WHITE)756 p.set_draw_color(*INK)757 p.set_line_width(0.4)758 p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")759 p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")760 ly = cy - 22761 for i, it in enumerate(items):762 col = self._shade(i)763 p.set_fill_color(*col)764 p.set_draw_color(*INK)765 p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")766 p.set_xy(p.l_margin + 66, ly)767 p.set_font("helvetica", "", 7.6)768 p.set_text_color(*INK)769 pct = 100 * it["value"] / total770 p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))771 ly += 5.6772 p.set_y(max(cy + r, ly) + 6)773774 def _hourly(self):775 hh = self.d.get("hourly") or {}776 cells = hh.get("cells") or []777 if not cells:778 return779 p = self.pdf780 if p.get_y() > 190:781 p.add_page()782 self._chart_title(hh.get("title", "Activité par jour et heure"))783 x0, y0 = p.l_margin, p.get_y()784 cw, chh, lx, ly = 6.4, 6.4, 12, 5785 vmax = max((c.get("value") or 0) for c in cells) or 1786 grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}787 dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]788 p.set_font("helvetica", "", 5.8)789 p.set_text_color(*INK3)790 for h in (0, 6, 12, 18, 23):791 p.set_xy(x0 + lx + h * cw, y0)792 p.cell(cw, 3, f"{h}h", align="C")793 for d in range(7):794 p.set_xy(x0, y0 + ly + d * chh + 1.5)795 p.cell(lx - 1, 3, dows[d], align="R")796 for h in range(24):797 v = grid.get((d, h), 0)798 f = 0.1 + 0.9 * (v / vmax) if v else 0.0799 col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)800 p.set_fill_color(*col)801 p.set_draw_color(215, 213, 207)802 p.set_line_width(0.1)803 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")804 p.set_y(y0 + ly + 7 * chh + 5)805806 def _calheat(self, hm):807 """v3 — calendrier de chaleur 26 semaines (équivalent PDF du808 CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""809 from datetime import date as _date, timedelta as _td810 cells = hm.get("cells") or []811 vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}812 if not vals:813 return814 p = self.pdf815 if p.get_y() > 215:816 p.add_page()817 self._chart_title(hm.get("title", "Calendrier d'activité"))818 try:819 end = _date.fromisoformat(max(vals))820 except ValueError:821 return822 weeks = 26823 start = end - _td(days=weeks * 7 - 1)824 start -= _td(days=start.weekday()) # lundi825 vmax = max(vals.values()) or 1826 x0, y0 = p.l_margin, p.get_y()827 cw, lx, ly = 6.3, 10, 4828 dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]829 p.set_font("helvetica", "", 5.8)830 p.set_text_color(*INK3)831 for d in range(7):832 if dows[d]:833 p.set_xy(x0, y0 + ly + d * cw + 1.2)834 p.cell(lx - 1, 3, dows[d], align="R")835 for w in range(weeks):836 monday = start + _td(days=7 * w)837 if monday.day <= 7: # étiquette de mois à la 1re semaine du mois838 p.set_xy(x0 + lx + w * cw, y0)839 p.cell(cw * 4, 3, monday.strftime("%m"))840 for d in range(7):841 day = monday + _td(days=d)842 v = vals.get(day.isoformat(), 0)843 f = 0.15 + 0.85 * (v / vmax) if v else 0.0844 col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)845 for j in range(3)) if v else (235, 233, 228))846 p.set_fill_color(*col)847 p.set_draw_color(215, 213, 207)848 p.set_line_width(0.1)849 p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,850 style="DF")851 p.set_y(y0 + ly + 7 * cw + 5)852853 # ---------- v3 : conversions bloc → tableau ----------854 @staticmethod855 def _serie_as_table(s):856 unit = s.get("unit") or "Valeur"857 cols = ["Date", unit.capitalize()]858 cmp_ = s.get("compare") or []859 if cmp_:860 cols.append("Période comparée")861 rows = []862 for i, pt in enumerate(s.get("points") or []):863 row = [str(pt.get("t", "")), pt.get("v", "")]864 if cmp_:865 row.append(cmp_[i]["v"] if i < len(cmp_) else "")866 rows.append(row)867 return {"id": s.get("id"), "title": s.get("title", ""),868 "columns": cols, "rows": rows}869870 @staticmethod871 def _multi_as_table(ms):872 labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]873 by_t: dict[str, dict] = {}874 for s in (ms.get("series") or [])[:4]:875 for pt in s.get("points") or []:876 by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")877 rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]878 for t in sorted(by_t)]879 return {"id": ms.get("id"), "title": ms.get("title", ""),880 "columns": ["Date"] + labels, "rows": rows}881882 @staticmethod883 def _stacked_as_table(st):884 keys = (st.get("keys") or [])[:6]885 rows = []886 for pt in st.get("points") or []:887 vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0888 for j in range(len(keys))]889 rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])890 return {"id": st.get("id"), "title": st.get("title", ""),891 "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}892893 @staticmethod894 def _items_as_table(id_, title, items, label_col="Libellé"):895 items = items or []896 with_delta = any(it.get("delta_pct") is not None for it in items)897 cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])898 rows = []899 for it in items:900 row = [str(it.get("label", "")), it.get("value", "")]901 if with_delta:902 d = it.get("delta_pct")903 row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")904 rows.append(row)905 return {"id": id_, "title": title, "columns": cols, "rows": rows}906907 def _kpis_as_table(self):908 rows = []909 for k in self.d.get("kpis") or []:910 v = k.get("value")911 val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \912 ((" " + k["unit"]) if k.get("unit") else "")913 d = k.get("delta_pct")914 rows.append([str(k.get("label", "")), val,915 "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])916 return {"id": "kpis", "title": "Indicateurs clés",917 "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}918919 def _gauges_as_table(self):920 rows = [[str(g.get("label", "")),921 f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",922 _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]923 for g in self.d.get("gauges") or []924 if isinstance(g.get("value"), (int, float)) and g.get("max")]925 return {"id": "gauges", "title": "Taux & couvertures",926 "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}927928 def _records_as_table(self):929 rows = [[str(r.get("label", "")), str(r.get("value", "")),930 str(r.get("date", "") or "")]931 for r in self.d.get("records") or []]932 return {"id": "records", "title": "Records & faits marquants",933 "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}934935 @staticmethod936 def _heatmap_as_table(hm, title):937 cells = sorted((hm.get("cells") or []),938 key=lambda c: -(c.get("value") or 0))[:40]939 return {"id": "heatmap", "title": title + " — jours les plus chargés",940 "columns": ["Date", "Valeur"],941 "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}942943 @staticmethod944 def _hourly_as_table(hr, title):945 days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",946 "Dimanche"]947 cells = sorted((hr.get("cells") or []),948 key=lambda c: -(c.get("value") or 0))[:40]949 return {"id": "hourly", "title": title + " — créneaux les plus actifs",950 "columns": ["Jour", "Heure", "Valeur"],951 "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",952 f"{c.get('hour', '?')} h", c.get("value") or 0]953 for c in cells]}954955 # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------956 def _find(self, coll: str, id_: str):957 for it in self.d.get(coll) or []:958 if str(it.get("id")) == id_:959 return it960 return None961962 def _toc_mark(self, title: str):963 """Blocs graphiques du mode personnalisé : entrée de sommaire sans964 _section_title (le graphique porte déjà son titre)."""965 if self.pdf.get_y() > 235:966 self.pdf.add_page()967 self.toc.append((title, self.pdf.page_no()))968969 def _render_block(self, key: str, render: str):970 section, _, id_ = key.partition(":")971 if section == "kpis":972 self._table(self._kpis_as_table()) if render == "table" else self._kpis()973 elif section == "gauges":974 self._table(self._gauges_as_table()) if render == "table" else self._gauges()975 elif section == "records":976 self._table(self._records_as_table()) if render == "table" else self._records()977 elif section == "series":978 s = self._find("series", id_)979 if not s:980 return981 if render == "table":982 self._table(self._serie_as_table(s), max_rows=400)983 else:984 s2 = dict(s)985 if render in ("line", "area", "bar"):986 s2["kind"] = render987 self._toc_mark(s2.get("title", ""))988 if s2.get("kind") == "bar":989 self._vbars(s2)990 else:991 self._line_chart(s2, with_stats=True)992 elif section == "multiseries":993 ms = self._find("multiseries", id_)994 if not ms:995 return996 if render == "table":997 self._table(self._multi_as_table(ms), max_rows=400)998 else:999 self._toc_mark(ms.get("title", ""))1000 self._multiline(ms)1001 elif section == "stacked":1002 st = self._find("stacked", id_)1003 if not st:1004 return1005 if render == "table":1006 self._table(self._stacked_as_table(st), max_rows=400)1007 else:1008 self._toc_mark(st.get("title", ""))1009 self._stacked(st)1010 elif section == "breakdowns":1011 b = self._find("breakdowns", id_)1012 if not b:1013 return1014 if render == "table":1015 self._table(self._items_as_table(id_, b.get("title", ""),1016 b.get("items")), max_rows=400)1017 else:1018 self._toc_mark(b.get("title", ""))1019 if render == "donut":1020 self._donut(b)1021 else:1022 self._bars(b.get("title", ""), b.get("items"))1023 elif section == "distributions":1024 d = self._find("distributions", id_)1025 if not d:1026 return1027 if render == "table":1028 bins = [{"label": bn.get("label"), "value": bn.get("value")}1029 for bn in d.get("bins") or []]1030 self._table(self._items_as_table(id_, d.get("title", ""), bins,1031 label_col="Tranche"))1032 else:1033 self._toc_mark(d.get("title", ""))1034 self._vbars(d)1035 elif section == "geo":1036 geo = self.d.get("geo") or {}1037 if not geo.get("items"):1038 return1039 title = geo.get("title", "Répartition géographique")1040 if render == "table":1041 self._table(self._items_as_table("geo", title, geo["items"],1042 label_col="Zone"), max_rows=400)1043 else:1044 self._toc_mark(title)1045 self._bars(title, geo["items"])1046 elif section == "heatmap":1047 hm = self.d.get("heatmap") or {}1048 if not hm.get("cells"):1049 return1050 title = hm.get("title", "Calendrier d'activité")1051 if render == "table":1052 self._table(self._heatmap_as_table(hm, title))1053 else:1054 self._toc_mark(title)1055 self._calheat(hm)1056 elif section == "hourly":1057 hr = self.d.get("hourly") or {}1058 if not hr.get("cells"):1059 return1060 title = hr.get("title", "Activité par jour et heure")1061 if render == "table":1062 self._table(self._hourly_as_table(hr, title))1063 else:1064 self._toc_mark(title)1065 self._hourly()1066 elif section == "tables":1067 t = self._find("tables", id_)1068 if t:1069 self._table(t, max_rows=400)10701071 def _table(self, t, max_rows=200):1072 p = self.pdf1073 cols = t.get("columns") or []1074 rows = t.get("rows") or []1075 if not cols or not rows:1076 return1077 self._section_title(t.get("title", "Tableau"))1078 w = 174 / len(cols)1079 def head():1080 p.set_font("helvetica", "B", 7.6)1081 p.set_fill_color(*INK)1082 p.set_text_color(*WHITE)1083 for c in cols:1084 p.cell(w, 6, " " + str(c)[:30], fill=True)1085 p.ln(6)1086 head()1087 p.set_text_color(*INK)1088 for i, row in enumerate(rows[:max_rows]):1089 if p.get_y() > 262:1090 p.add_page()1091 head()1092 p.set_text_color(*INK)1093 p.set_font("helvetica", "", 7.4)1094 p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))1095 for cell in row:1096 txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)1097 p.cell(w, 5.4, " " + txt[:34], fill=True)1098 p.ln(5.4)1099 if len(rows) > max_rows:1100 p.set_font("helvetica", "", 7)1101 p.set_text_color(*INK3)1102 p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")1103 p.ln(6)11041105 def _records(self):1106 recs = self.d.get("records") or []1107 if not recs:1108 return1109 self._section_title("Records & faits marquants")1110 p = self.pdf1111 for r in recs[:14]:1112 if p.get_y() > 258:1113 p.add_page()1114 y = p.get_y()1115 self._card(p.l_margin, y, 174, 11, fill=SURFACE2)1116 p.set_xy(p.l_margin + 4, y + 2)1117 p.set_font("helvetica", "", 8.6)1118 p.set_text_color(*INK2)1119 p.cell(96, 7, str(r.get("label", ""))[:70])1120 p.set_font("helvetica", "B", 9)1121 p.set_text_color(*INK)1122 p.cell(52, 7, str(r.get("value", ""))[:36], align="R")1123 p.set_font("helvetica", "", 7.6)1124 p.set_text_color(*INK3)1125 p.cell(20, 7, str(r.get("date", "") or ""), align="R")1126 p.set_y(y + 13.5)1127 p.ln(4)11281129 def _final_page(self):1130 p = self.pdf1131 p.add_page()1132 self._kicker("Groupe KA · contact")1133 p.set_font("helvetica", "B", 15)1134 p.set_text_color(*INK)1135 p.cell(0, 8, "Coordonnées du Groupe KA")1136 p.ln(12)1137 for email, role in EMAILS:1138 p.set_font("helvetica", "B", 10.5)1139 p.set_text_color(*INK)1140 p.cell(0, 6, email)1141 p.ln(5.5)1142 p.set_font("helvetica", "", 8.6)1143 p.set_text_color(*INK3)1144 p.cell(0, 5, role)1145 p.ln(8)1146 p.ln(2)1147 p.set_font("helvetica", "B", 10)1148 p.set_text_color(*GREEN)1149 p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")1150 p.ln(10)1151 p.set_draw_color(*self.accent)1152 p.set_line_width(0.8)1153 p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())1154 p.ln(4)1155 p.set_font("helvetica", "", 8.6)1156 p.set_text_color(*INK2)1157 p.multi_cell(160, 4.6, DISCLAIMER)1158 p.ln(4)1159 p.set_font("helvetica", "", 7.6)1160 p.set_text_color(*INK3)1161 p.multi_cell(1162 160, 4.2,1163 "Mentions : rapport généré automatiquement à partir des données réelles de la "1164 "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "1165 "de confidentialité et protection des renseignements personnels (Loi 25) : "1166 "groupe-ka.com/conditions · /confidentialite · /loi-25.",1167 )11681169 # ---------- groupes de sections ----------1170 def _all_series(self, with_stats=True):1171 for s in self.d.get("series") or []:1172 self._line_chart(s, with_stats=with_stats)1173 for ms in self.d.get("multiseries") or []:1174 self._multiline(ms)1175 for st in self.d.get("stacked") or []:1176 self._stacked(st)11771178 def _all_breakdowns(self):1179 for b in self.d.get("breakdowns") or []:1180 if b.get("kind") == "donut":1181 self._donut(b)1182 else:1183 self._bars(b.get("title", ""), b.get("items"))1184 for dist in self.d.get("distributions") or []:1185 self._vbars(dist)1186 geo = self.d.get("geo")1187 if geo:1188 self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))1189 self._hourly()11901191 def build(self) -> bytes:1192 p = self.pdf1193 p.alias_nb_pages()1194 self._cover()1195 with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)1196 toc_page_no = None1197 if self.mode == "synthese":1198 p.add_page()1199 self._kpis()1200 self._gauges()1201 self._records()1202 self._final_page()1203 elif self.mode == "tendances":1204 p.add_page()1205 self._kpis()1206 self._section_title("Évolution & tendances")1207 self._all_series(with_stats=True)1208 self._records()1209 self._final_page()1210 elif self.mode == "repartitions":1211 p.add_page()1212 self._section_title("Répartitions, distributions & géographie")1213 self._all_breakdowns()1214 self._final_page()1215 elif self.mode == "donnees":1216 p.add_page()1217 toc_page_no = p.page_no()1218 for t in self.d.get("tables") or []:1219 self._table(t, max_rows=400)1220 self._final_page()1221 elif self.mode == CUSTOM_MODE:1222 p.add_page()1223 toc_page_no = p.page_no()1224 p.add_page()1225 known = {b["key"]: b for b in catalog(self.d)}1226 for blk in self.spec.get("blocks") or []:1227 key = str(blk.get("key", ""))1228 b = known.get(key)1229 if not b:1230 continue1231 render = str(blk.get("render") or "")1232 if render not in b["renders"]:1233 render = b["default_render"]1234 self._render_block(key, render)1235 self._final_page()1236 else: # complet1237 p.add_page()1238 toc_page_no = p.page_no()1239 p.add_page()1240 self._kpis()1241 self._gauges()1242 if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):1243 self._section_title("Évolution & tendances")1244 self._all_series(with_stats=True)1245 if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):1246 self._section_title("Répartitions, distributions & géographie")1247 self._all_breakdowns()1248 for t in self.d.get("tables") or []:1249 self._table(t)1250 self._records()1251 self._final_page()1252 # sommaire écrit sur la page réservée1253 if toc_page_no is not None:1254 last_page = p.page1255 p.page = toc_page_no1256 p.set_y(22)1257 p.set_font("helvetica", "B", 15)1258 p.set_text_color(*INK)1259 p.cell(0, 8, "Sommaire")1260 p.ln(12)1261 p.set_font("helvetica", "", 9.5)1262 for title, page_no in self.toc:1263 p.set_text_color(*INK)1264 p.cell(140, 6.5, title[:80])1265 p.set_text_color(*INK3)1266 p.cell(0, 6.5, str(page_no), align="R")1267 p.ln(6.5)1268 p.page = last_page1269 return bytes(p.output())127012711272def filename(platform_id: str, period: str, mode: str = "complet") -> str:1273 today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")1274 suffix = "" if mode in ("", "complet") else f"_{mode}"1275 return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"1276