Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1# Auteur : Simon-Pierre Boucher — contact@spboucher.ai2# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v33# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit4# les rapports estampillés Groupe-KA. 5 modes fixes :5# complet — toutes les sections (KPI, jauges, séries + stats, multi-6# séries, empilées, distributions, répartitions, géo,7# heatmap horaire, tableaux, records)8# synthese — couverture + KPI + records (2-3 pages)9# tendances — KPI + toutes les séries temporelles + stats de séries10# repartitions — breakdowns, distributions, géo, activité horaire11# donnees — tous les tableaux en version longue (400 lignes max)12# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par13# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/14# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport15# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",16# "render": "bar"}, …]} et respecte l'ordre demandé.17# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.18# Usage :19# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename20# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",21# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,22# mode="complet").build()23# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",24# spec={"title": "Mon rapport", "blocks": [...]}).build()25# Dépendance : pip install fpdf2 (aucune autre)26from __future__ import annotations2728import math29from datetime import datetime30from zoneinfo import ZoneInfo3132from fpdf import FPDF3334INK = (20, 24, 20)35INK2 = (77, 85, 81)36INK3 = (139, 146, 140)37PAPER = (245, 243, 238)38SURFACE2 = (250, 249, 245)39GREEN = (28, 92, 65)40DANGER = (179, 66, 58)41WHITE = (255, 255, 255)4243REPORT_MODES = {44 "complet": "Rapport complet",45 "synthese": "Synthèse exécutive",46 "tendances": "Tendances & évolution",47 "repartitions": "Répartitions & géographie",48 "donnees": "Données détaillées",49}50# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)51CUSTOM_MODE = "personnalise"52CUSTOM_LABEL = "Rapport personnalisé"5354# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;55# « table » est toujours offert : toute donnée a un équivalent tableau)56RENDER_LABELS = {57 "line": "Courbe", "area": "Aire", "bar": "Barres verticales",58 "bars": "Barres horizontales", "donut": "Anneau",59 "lines": "Multi-courbes", "stacked": "Barres empilées",60 "histogram": "Histogramme", "heatmap": "Heatmap",61 "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",62}63SECTION_LABELS = {64 "kpis": "Indicateurs", "gauges": "Taux & couvertures",65 "series": "Évolution", "multiseries": "Comparaisons",66 "stacked": "Compositions", "breakdowns": "Répartitions",67 "distributions": "Distributions", "geo": "Géographie",68 "heatmap": "Calendrier", "hourly": "Activité horaire",69 "tables": "Tableaux", "records": "Records",70}717273def catalog(dash: dict) -> list[dict]:74 """v3 — blocs composables d'un dashboard : ce que le constructeur de75 rapports personnalisés peut inclure, avec les rendus compatibles.76 key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""77 out: list[dict] = []7879 def add(key, title, renders, default=None, count=None):80 b = {"key": key, "section": key.split(":")[0], "title": title,81 "renders": renders, "default_render": default or renders[0]}82 if count is not None:83 b["count"] = count84 out.append(b)8586 if dash.get("kpis"):87 add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],88 count=len(dash["kpis"]))89 gs = [g for g in (dash.get("gauges") or [])90 if isinstance(g.get("value"), (int, float)) and g.get("max")]91 if gs:92 add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],93 count=len(gs))94 for s in dash.get("series") or []:95 if len(s.get("points") or []) < 2:96 continue97 kind = s.get("kind") or "line"98 default = kind if kind in ("line", "area", "bar") else "line"99 add(f"series:{s.get('id')}", s.get("title", ""),100 ["line", "area", "bar", "table"], default,101 len(s.get("points") or []))102 for ms in dash.get("multiseries") or []:103 if not (ms.get("series") or []):104 continue105 add(f"multiseries:{ms.get('id')}", ms.get("title", ""),106 ["lines", "table"], count=len(ms["series"]))107 for st in dash.get("stacked") or []:108 if not (st.get("points") or []):109 continue110 add(f"stacked:{st.get('id')}", st.get("title", ""),111 ["stacked", "table"], count=len(st.get("keys") or []))112 for b in dash.get("breakdowns") or []:113 if not (b.get("items") or []):114 continue115 default = "donut" if b.get("kind") == "donut" else "bars"116 add(f"breakdowns:{b.get('id')}", b.get("title", ""),117 ["donut", "bars", "table"], default, len(b["items"]))118 for d in dash.get("distributions") or []:119 if not (d.get("bins") or []):120 continue121 add(f"distributions:{d.get('id')}", d.get("title", ""),122 ["histogram", "table"], count=len(d["bins"]))123 geo = dash.get("geo") or {}124 if geo.get("items"):125 add("geo", geo.get("title", "Répartition géographique"),126 ["bars", "table"], count=len(geo["items"]))127 hm = dash.get("heatmap") or {}128 if hm.get("cells"):129 add("heatmap", hm.get("title", "Calendrier d'activité"),130 ["heatmap", "table"])131 hr = dash.get("hourly") or {}132 if hr.get("cells"):133 add("hourly", hr.get("title", "Activité par jour et heure"),134 ["heatmap", "table"])135 for t in dash.get("tables") or []:136 if not (t.get("rows") or []):137 continue138 add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],139 count=len(t["rows"]))140 if dash.get("records"):141 add("records", "Records & faits marquants", ["cards", "table"],142 count=len(dash["records"]))143 return out144145EMAILS = [146 ("contact@groupe-ka.com", "Projets, partenariats & données"),147 ("info@groupe-ka.com", "Médias & questions générales"),148 ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),149]150DISCLAIMER = (151 "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "152 "rien et ne sommes partie à aucune transaction. Données lues à la source, "153 "rien d'inventé, tout est traçable."154)155156157def _hex(c: str) -> tuple[int, int, int]:158 c = c.lstrip("#")159 return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore160161162def _fr(n) -> str:163 if isinstance(n, float) and not n.is_integer():164 return f"{n:,.2f}".replace(",", " ").replace(".", ",")165 return f"{int(n):,}".replace(",", " ")166167168_SUBST = {169 "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",170 "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',171 "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",172 "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",173}174175176def _latin1(s: str) -> str:177 for k, v in _SUBST.items():178 s = s.replace(k, v)179 return s.encode("latin-1", "replace").decode("latin-1")180181182class _PDF(FPDF):183 """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).184 Les polices core sont latin-1 : normalize_text sanitise en amont."""185186 def normalize_text(self, text):187 return super().normalize_text(_latin1(text))188189 def __init__(self, brand: str, accent: tuple, period_label: str):190 super().__init__(orientation="P", unit="mm", format="A4")191 self.brand = brand192 self.accent = accent193 self.period_label = period_label194 self.cover_mode = False195 self.set_margins(18, 20, 18)196 self.set_auto_page_break(True, margin=22)197198 def header(self):199 if self.cover_mode or self.page_no() == 1:200 return201 self.set_font("helvetica", "B", 8.5)202 self.set_text_color(*INK)203 self.set_xy(18, 9)204 self.cell(0, 5, f"Groupe KA · {self.brand}")205 self.set_font("helvetica", "", 8)206 self.set_text_color(*INK3)207 self.set_xy(18, 9)208 self.cell(0, 5, "Rapport statistique", align="R")209 self.set_draw_color(*INK)210 self.set_line_width(0.5)211 self.line(18, 15.5, 192, 15.5)212 self.set_y(20)213214 def footer(self):215 # page 1 = couverture (le flag cover_mode est déjà retombé quand216 # add_page() clôt la page 1 → tester aussi le numéro de page)217 if self.cover_mode or self.page_no() == 1:218 return219 self.set_y(-15)220 self.set_draw_color(*INK3)221 self.set_line_width(0.2)222 self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)223 self.set_font("helvetica", "", 7.5)224 self.set_text_color(*INK3)225 year = datetime.now(ZoneInfo("America/Toronto")).year226 self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")227 self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")228229230class GroupeKAReport:231 def __init__(self, site: dict, dashboard: dict, mode: str = "complet",232 spec: dict | None = None):233 self.site = site234 self.d = dashboard235 self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"236 self.spec = spec or {}237 self.accent = _hex(site.get("accent", "#d9f26b"))238 period = dashboard.get("period", {}) or {}239 self.period_label = period.get("label") or "toute la période"240 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)241 self.toc: list[tuple[str, int]] = []242243 @property244 def mode_label(self) -> str:245 if self.mode == CUSTOM_MODE:246 t = str(self.spec.get("title") or "").strip()247 return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL248 return REPORT_MODES[self.mode]249250 # ---------- primitives ----------251 def _card(self, x, y, w, h, fill=WHITE):252 p = self.pdf253 p.set_draw_color(*INK)254 p.set_line_width(0.45)255 p.set_fill_color(*fill)256 p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)257258 def _shade(self, i, n=8):259 shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]260 f = shades[i % len(shades)]261 return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))262263 def _kicker(self, text):264 p = self.pdf265 p.set_font("helvetica", "B", 8)266 p.set_text_color(*GREEN)267 p.set_draw_color(*GREEN)268 p.set_line_width(0.6)269 y = p.get_y() + 2270 p.line(p.l_margin, y, p.l_margin + 7, y)271 p.set_xy(p.l_margin + 9, y - 2.5)272 p.cell(0, 5, text.upper())273 p.ln(8)274275 def _section_title(self, title):276 if self.pdf.get_y() > 240:277 self.pdf.add_page()278 self._kicker("Groupe KA · " + self.site.get("wordmark", ""))279 self.pdf.set_font("helvetica", "B", 15)280 self.pdf.set_text_color(*INK)281 self.pdf.set_x(self.pdf.l_margin)282 self.pdf.cell(0, 8, title)283 self.toc.append((title, self.pdf.page_no()))284 self.pdf.ln(11)285286 def _chart_title(self, title):287 p = self.pdf288 p.set_font("helvetica", "B", 10)289 p.set_text_color(*INK)290 p.set_x(p.l_margin)291 p.cell(0, 6, title)292 p.ln(7)293294 # ---------- pages ----------295 def _cover(self):296 p = self.pdf297 p.cover_mode = True298 p.set_auto_page_break(False)299 p.add_page()300 p.set_fill_color(*PAPER)301 p.rect(0, 0, 210, 297, style="F")302 p.set_draw_color(*INK)303 p.set_line_width(1.0)304 p.rect(10, 10, 190, 277)305 p.set_font("helvetica", "B", 10)306 p.set_text_color(*GREEN)307 p.set_xy(24, 34)308 p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")309 wm = self.site.get("wordmark", "")310 left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)311 p.set_xy(24, 70)312 p.set_font("helvetica", "B", 40)313 p.set_text_color(*INK)314 p.cell(p.get_string_width(left) + 2, 20, left)315 if boxed:316 bw = p.get_string_width(boxed) + 12317 x = p.get_x() + 2318 p.set_fill_color(*INK)319 p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)320 p.set_text_color(*self.accent)321 p.set_xy(x + 6, 70)322 p.cell(bw - 12, 18, boxed)323 p.set_xy(24, 100)324 p.set_font("helvetica", "", 13)325 p.set_text_color(*INK2)326 p.multi_cell(150, 7, f"{self.mode_label} — {wm}")327 now = datetime.now(ZoneInfo("America/Toronto"))328 per = self.d.get("period", {}) or {}329 p.set_xy(24, 125)330 p.set_font("helvetica", "", 10.5)331 rows = [332 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),333 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),334 ("Plateforme", "https://" + self.site.get("domain", "")),335 ("Type de rapport", self.mode_label),336 ]337 y = 128338 for k, v in rows:339 p.set_xy(24, y)340 p.set_text_color(*INK3)341 p.cell(40, 6, k)342 p.set_text_color(*INK)343 p.set_font("helvetica", "B", 10.5)344 p.cell(0, 6, str(v))345 p.set_font("helvetica", "", 10.5)346 y += 8347 p.set_fill_color(*INK)348 p.rect(10, 262, 190, 25, style="F")349 p.set_xy(24, 270)350 p.set_font("helvetica", "B", 12)351 p.set_text_color(*WHITE)352 p.cell(60, 8, "par Groupe ")353 p.set_text_color(*self.accent)354 p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)355 p.cell(20, 8, "KA")356 p.set_font("helvetica", "B", 10)357 p.set_xy(24, 270)358 p.set_text_color(*self.accent)359 p.cell(162, 8, "groupe-ka.com", align="R")360 p.set_auto_page_break(True, margin=22)361 p.cover_mode = False362363 def _kpis(self):364 kpis = self.d.get("kpis") or []365 if not kpis:366 return367 self._section_title("Synthèse des indicateurs")368 p = self.pdf369 cols, gw, gh, gap = 3, 56, 26, 3370 x0, y = p.l_margin, p.get_y()371 for i, k in enumerate(kpis[:12]):372 x = x0 + (i % cols) * (gw + gap)373 if i and i % cols == 0:374 y += gh + gap375 if y > 250:376 p.add_page(); y = p.get_y()377 self._card(x, y, gw, gh)378 p.set_xy(x + 4, y + 4)379 p.set_font("helvetica", "B", 14)380 p.set_text_color(*INK)381 val = k.get("value")382 p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))383 p.set_xy(x + 4, y + 12)384 p.set_font("helvetica", "", 7.6)385 p.set_text_color(*INK2)386 p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])387 if k.get("delta_pct") is not None:388 up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"389 p.set_xy(x + 4, y + gh - 6.5)390 p.set_font("helvetica", "B", 8)391 p.set_text_color(*(GREEN if up else DANGER))392 arrow = "+" if k["delta_pct"] >= 0 else ""393 dv = round(float(k["delta_pct"]), 1)394 dv = int(dv) if float(dv).is_integer() else dv395 p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")396 p.set_y(y + gh + 8)397398 def _gauges(self):399 gs = self.d.get("gauges") or []400 gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]401 if not gs:402 return403 self._section_title("Taux & couvertures")404 p = self.pdf405 cols, gw, gh, gap = 3, 56, 34, 3406 x0, y = p.l_margin, p.get_y()407 for i, g in enumerate(gs[:9]):408 x = x0 + (i % cols) * (gw + gap)409 if i and i % cols == 0:410 y += gh + gap411 if y > 240:412 p.add_page(); y = p.get_y()413 self._card(x, y, gw, gh)414 frac = max(0.0, min(1.0, g["value"] / g["max"]))415 cx, cy, r = x + gw / 2, y + 20, 14416 # arc de fond + arc de valeur (demi-cercle en petits segments)417 for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):418 p.set_draw_color(*pass_col)419 p.set_line_width(lw)420 steps = max(2, int(60 * pass_frac))421 last = None422 for st in range(steps + 1):423 a = math.pi + math.pi * pass_frac * st / steps424 pt = (cx + r * math.cos(a), cy + r * math.sin(a))425 if last:426 p.line(last[0], last[1], pt[0], pt[1])427 last = pt428 p.set_font("helvetica", "B", 11)429 p.set_text_color(*INK)430 p.set_xy(x + 4, cy - 5)431 p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")432 p.set_font("helvetica", "", 6.6)433 p.set_text_color(*INK3)434 p.set_xy(x + 4, cy + 1.5)435 p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")436 p.set_xy(x + 3, y + gh - 7)437 p.set_font("helvetica", "", 7)438 p.set_text_color(*INK2)439 p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")440 p.set_y(y + gh + 8)441442 def _serie_stats_row(self, s):443 """Ligne min/max/moyenne/médiane sous un graphique de série."""444 p = self.pdf445 vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]446 if len(vs) < 2:447 return448 sv = sorted(vs)449 mean = sum(vs) / len(vs)450 med = sv[len(sv) // 2]451 sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))452 p.set_font("helvetica", "", 6.8)453 p.set_text_color(*INK3)454 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))}")455 p.ln(5.5)456457 def _line_chart(self, s, with_stats=False):458 p = self.pdf459 pts = s.get("points") or []460 if len(pts) < 2:461 return462 if s.get("kind") == "bar":463 self._vbars(s)464 return465 if p.get_y() > 200:466 p.add_page()467 self._chart_title(s.get("title", ""))468 x0, y0, w, h = p.l_margin, p.get_y(), 174, 52469 self._card(x0, y0, w, h, fill=WHITE)470 cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16471 vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]472 vmax = max(vals) or 1473 vmin = min(0, min(vals))474 rng = (vmax - vmin) or 1475 p.set_font("helvetica", "", 6.3)476 p.set_text_color(*INK3)477 p.set_draw_color(200, 200, 195)478 p.set_line_width(0.15)479 for g in range(5):480 gy = cy + ch - ch * g / 4481 p.line(cx, gy, cx + cw, gy)482 p.set_xy(x0 + 1, gy - 1.6)483 p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")484485 def xy(i, n, v):486 return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))487488 # aire sous la courbe (kind=area) : petits trapèzes accent pâle489 if s.get("kind") == "area":490 fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))491 p.set_fill_color(*fill)492 p.set_draw_color(*fill)493 n = len(pts)494 for i in range(n - 1):495 x1, y1 = xy(i, n, pts[i]["v"])496 x2, y2 = xy(i + 1, n, pts[i + 1]["v"])497 p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")498499 def draw(series, color, width, dash=None):500 n = len(series)501 p.set_draw_color(*color)502 p.set_line_width(width)503 if dash:504 p.set_dash_pattern(dash=1.2, gap=1.2)505 last = None506 for i, pt in enumerate(series):507 px, py = xy(i, n, pt["v"])508 if last:509 p.line(last[0], last[1], px, py)510 last = (px, py)511 p.set_dash_pattern()512513 if s.get("compare"):514 draw(s["compare"], INK3, 0.35, dash=True)515 draw(pts, self.accent, 0.7)516 p.set_text_color(*INK3)517 for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):518 p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)519 p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")520 p.set_y(y0 + h + 4)521 if s.get("compare"):522 p.set_font("helvetica", "", 6.8)523 p.set_text_color(*INK3)524 p.cell(0, 4, "— période courante (accent) · ---- période comparée")525 p.ln(5.5)526 if with_stats:527 self._serie_stats_row(s)528 p.ln(1.5)529530 def _vbars(self, s):531 """Barres verticales : série kind=bar ou distribution (bins)."""532 p = self.pdf533 pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]534 pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]535 if not pts:536 return537 if p.get_y() > 205:538 p.add_page()539 self._chart_title(s.get("title", ""))540 x0, y0, w, h = p.l_margin, p.get_y(), 174, 48541 self._card(x0, y0, w, h, fill=WHITE)542 cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14543 vmax = max(pt["v"] for pt in pts) or 1544 p.set_font("helvetica", "", 6.3)545 p.set_text_color(*INK3)546 p.set_draw_color(200, 200, 195)547 p.set_line_width(0.15)548 for g in range(5):549 gy = cy + ch - ch * g / 4550 p.line(cx, gy, cx + cw, gy)551 p.set_xy(x0 + 1, gy - 1.6)552 p.cell(10, 3, _fr(vmax * g / 4), align="R")553 n = len(pts)554 bw = max(0.8, cw / n - 0.6)555 p.set_fill_color(*self.accent)556 p.set_draw_color(*INK)557 p.set_line_width(0.15)558 for i, pt in enumerate(pts):559 bh = ch * (pt["v"] / vmax)560 p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")561 p.set_text_color(*INK3)562 for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):563 p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)564 p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")565 p.set_y(y0 + h + 5)566567 def _multiline(self, ms):568 """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /569 gris pointillé — l'identité passe par le motif, pas la couleur seule."""570 p = self.pdf571 series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]572 if not series:573 return574 if p.get_y() > 195:575 p.add_page()576 self._chart_title(ms.get("title", ""))577 x0, y0, w, h = p.l_margin, p.get_y(), 174, 52578 self._card(x0, y0, w, h, fill=WHITE)579 cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16580 vals = [pt["v"] for s in series for pt in s["points"]]581 vmax = max(vals) or 1582 vmin = min(0, min(vals))583 rng = (vmax - vmin) or 1584 p.set_font("helvetica", "", 6.3)585 p.set_text_color(*INK3)586 p.set_draw_color(200, 200, 195)587 p.set_line_width(0.15)588 for g in range(5):589 gy = cy + ch - ch * g / 4590 p.line(cx, gy, cx + cw, gy)591 p.set_xy(x0 + 1, gy - 1.6)592 p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")593 styles = [594 (self.accent, 0.7, None),595 (INK, 0.45, None),596 (self.accent, 0.55, True),597 (INK3, 0.5, True),598 ]599 for si, s in enumerate(series):600 col, lw, dash = styles[si]601 p.set_draw_color(*col)602 p.set_line_width(lw)603 if dash:604 p.set_dash_pattern(dash=1.4, gap=1.2)605 n = len(s["points"])606 last = None607 for i, pt in enumerate(s["points"]):608 px = cx + cw * (i / (n - 1))609 py = cy + ch - ch * ((pt["v"] - vmin) / rng)610 if last:611 p.line(last[0], last[1], px, py)612 last = (px, py)613 p.set_dash_pattern()614 ref = series[0]["points"]615 p.set_text_color(*INK3)616 for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):617 p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)618 p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")619 p.set_y(y0 + h + 4)620 p.set_font("helvetica", "", 6.8)621 p.set_text_color(*INK3)622 marks = ["—", "—", "----", "----"]623 leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))624 p.cell(0, 4, leg[:120])625 p.ln(6)626627 def _stacked(self, st):628 p = self.pdf629 keys = (st.get("keys") or [])[:6]630 pts = st.get("points") or []631 if not keys or not pts:632 return633 if p.get_y() > 195:634 p.add_page()635 self._chart_title(st.get("title", ""))636 x0, y0, w, h = p.l_margin, p.get_y(), 174, 52637 self._card(x0, y0, w, h, fill=WHITE)638 cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16639 totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]640 vmax = max(totals) or 1641 p.set_font("helvetica", "", 6.3)642 p.set_text_color(*INK3)643 p.set_draw_color(200, 200, 195)644 p.set_line_width(0.15)645 for g in range(5):646 gy = cy + ch - ch * g / 4647 p.line(cx, gy, cx + cw, gy)648 p.set_xy(x0 + 1, gy - 1.6)649 p.cell(10, 3, _fr(vmax * g / 4), align="R")650 n = len(pts)651 bw = max(0.8, cw / n - 0.6)652 p.set_draw_color(*WHITE)653 p.set_line_width(0.12)654 for i, pt in enumerate(pts):655 yacc = cy + ch656 for j, k in enumerate(keys):657 v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0658 if not v:659 continue660 bh = ch * (v / vmax)661 yacc -= bh662 p.set_fill_color(*self._shade(j))663 p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")664 p.set_text_color(*INK3)665 for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):666 p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)667 p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")668 p.set_y(y0 + h + 4)669 # légende670 p.set_font("helvetica", "", 6.8)671 lx = p.l_margin672 for j, k in enumerate(keys):673 p.set_fill_color(*self._shade(j))674 p.set_draw_color(*INK)675 p.set_line_width(0.2)676 p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")677 p.set_xy(lx + 4, p.get_y())678 p.set_text_color(*INK2)679 txt = str(k)[:22]680 p.cell(p.get_string_width(txt) + 3, 4, txt)681 lx = p.get_x() + 3682 if lx > 165:683 break684 p.ln(7)685686 def _bars(self, title, items, unit=""):687 p = self.pdf688 items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]689 if not items:690 return691 need = 10 + len(items) * 7692 if p.get_y() + need > 265:693 p.add_page()694 self._chart_title(title)695 p.ln(1)696 vmax = max(it["value"] for it in items) or 1697 for it in items:698 y = p.get_y()699 p.set_font("helvetica", "", 7.6)700 p.set_text_color(*INK)701 p.set_x(p.l_margin)702 p.cell(46, 5, str(it["label"])[:34])703 bw = 86 * (it["value"] / vmax)704 p.set_fill_color(*self.accent)705 p.set_draw_color(*INK)706 p.set_line_width(0.25)707 p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")708 p.set_xy(p.l_margin + 136, y)709 p.set_font("helvetica", "B", 7.6)710 p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")711 if it.get("delta_pct") is not None:712 up = it["delta_pct"] >= 0713 p.set_font("helvetica", "B", 6.6)714 p.set_text_color(*(GREEN if up else DANGER))715 p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")716 p.ln(6.4)717 p.ln(3)718719 def _donut(self, b):720 p = self.pdf721 items = [it for it in (b.get("items") or []) if it.get("value")][:8]722 total = sum(it["value"] for it in items)723 if not items or not total:724 return725 if p.get_y() > 210:726 p.add_page()727 self._chart_title(b.get("title", ""))728 p.ln(1)729 cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20730 start = -90.0731 for i, it in enumerate(items):732 frac = it["value"] / total733 col = self._shade(i)734 steps = max(2, int(72 * frac))735 p.set_fill_color(*col)736 p.set_draw_color(*col)737 for st in range(steps):738 a0 = math.radians(start + 360 * frac * st / steps)739 a1 = math.radians(start + 360 * frac * (st + 1) / steps)740 p.polygon(741 [(cx, cy),742 (cx + r * math.cos(a0), cy + r * math.sin(a0)),743 (cx + r * math.cos(a1), cy + r * math.sin(a1))],744 style="DF",745 )746 start += 360 * frac747 p.set_fill_color(*WHITE)748 p.set_draw_color(*INK)749 p.set_line_width(0.4)750 p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")751 p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")752 ly = cy - 22753 for i, it in enumerate(items):754 col = self._shade(i)755 p.set_fill_color(*col)756 p.set_draw_color(*INK)757 p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")758 p.set_xy(p.l_margin + 66, ly)759 p.set_font("helvetica", "", 7.6)760 p.set_text_color(*INK)761 pct = 100 * it["value"] / total762 p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))763 ly += 5.6764 p.set_y(max(cy + r, ly) + 6)765766 def _hourly(self):767 hh = self.d.get("hourly") or {}768 cells = hh.get("cells") or []769 if not cells:770 return771 p = self.pdf772 if p.get_y() > 190:773 p.add_page()774 self._chart_title(hh.get("title", "Activité par jour et heure"))775 x0, y0 = p.l_margin, p.get_y()776 cw, chh, lx, ly = 6.4, 6.4, 12, 5777 vmax = max((c.get("value") or 0) for c in cells) or 1778 grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}779 dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]780 p.set_font("helvetica", "", 5.8)781 p.set_text_color(*INK3)782 for h in (0, 6, 12, 18, 23):783 p.set_xy(x0 + lx + h * cw, y0)784 p.cell(cw, 3, f"{h}h", align="C")785 for d in range(7):786 p.set_xy(x0, y0 + ly + d * chh + 1.5)787 p.cell(lx - 1, 3, dows[d], align="R")788 for h in range(24):789 v = grid.get((d, h), 0)790 f = 0.1 + 0.9 * (v / vmax) if v else 0.0791 col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)792 p.set_fill_color(*col)793 p.set_draw_color(215, 213, 207)794 p.set_line_width(0.1)795 p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")796 p.set_y(y0 + ly + 7 * chh + 5)797798 def _calheat(self, hm):799 """v3 — calendrier de chaleur 26 semaines (équivalent PDF du800 CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""801 from datetime import date as _date, timedelta as _td802 cells = hm.get("cells") or []803 vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}804 if not vals:805 return806 p = self.pdf807 if p.get_y() > 215:808 p.add_page()809 self._chart_title(hm.get("title", "Calendrier d'activité"))810 try:811 end = _date.fromisoformat(max(vals))812 except ValueError:813 return814 weeks = 26815 start = end - _td(days=weeks * 7 - 1)816 start -= _td(days=start.weekday()) # lundi817 vmax = max(vals.values()) or 1818 x0, y0 = p.l_margin, p.get_y()819 cw, lx, ly = 6.3, 10, 4820 dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]821 p.set_font("helvetica", "", 5.8)822 p.set_text_color(*INK3)823 for d in range(7):824 if dows[d]:825 p.set_xy(x0, y0 + ly + d * cw + 1.2)826 p.cell(lx - 1, 3, dows[d], align="R")827 for w in range(weeks):828 monday = start + _td(days=7 * w)829 if monday.day <= 7: # étiquette de mois à la 1re semaine du mois830 p.set_xy(x0 + lx + w * cw, y0)831 p.cell(cw * 4, 3, monday.strftime("%m"))832 for d in range(7):833 day = monday + _td(days=d)834 v = vals.get(day.isoformat(), 0)835 f = 0.15 + 0.85 * (v / vmax) if v else 0.0836 col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)837 for j in range(3)) if v else (235, 233, 228))838 p.set_fill_color(*col)839 p.set_draw_color(215, 213, 207)840 p.set_line_width(0.1)841 p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,842 style="DF")843 p.set_y(y0 + ly + 7 * cw + 5)844845 # ---------- v3 : conversions bloc → tableau ----------846 @staticmethod847 def _serie_as_table(s):848 unit = s.get("unit") or "Valeur"849 cols = ["Date", unit.capitalize()]850 cmp_ = s.get("compare") or []851 if cmp_:852 cols.append("Période comparée")853 rows = []854 for i, pt in enumerate(s.get("points") or []):855 row = [str(pt.get("t", "")), pt.get("v", "")]856 if cmp_:857 row.append(cmp_[i]["v"] if i < len(cmp_) else "")858 rows.append(row)859 return {"id": s.get("id"), "title": s.get("title", ""),860 "columns": cols, "rows": rows}861862 @staticmethod863 def _multi_as_table(ms):864 labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]865 by_t: dict[str, dict] = {}866 for s in (ms.get("series") or [])[:4]:867 for pt in s.get("points") or []:868 by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")869 rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]870 for t in sorted(by_t)]871 return {"id": ms.get("id"), "title": ms.get("title", ""),872 "columns": ["Date"] + labels, "rows": rows}873874 @staticmethod875 def _stacked_as_table(st):876 keys = (st.get("keys") or [])[:6]877 rows = []878 for pt in st.get("points") or []:879 vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0880 for j in range(len(keys))]881 rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])882 return {"id": st.get("id"), "title": st.get("title", ""),883 "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}884885 @staticmethod886 def _items_as_table(id_, title, items, label_col="Libellé"):887 items = items or []888 with_delta = any(it.get("delta_pct") is not None for it in items)889 cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])890 rows = []891 for it in items:892 row = [str(it.get("label", "")), it.get("value", "")]893 if with_delta:894 d = it.get("delta_pct")895 row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")896 rows.append(row)897 return {"id": id_, "title": title, "columns": cols, "rows": rows}898899 def _kpis_as_table(self):900 rows = []901 for k in self.d.get("kpis") or []:902 v = k.get("value")903 val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \904 ((" " + k["unit"]) if k.get("unit") else "")905 d = k.get("delta_pct")906 rows.append([str(k.get("label", "")), val,907 "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])908 return {"id": "kpis", "title": "Indicateurs clés",909 "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}910911 def _gauges_as_table(self):912 rows = [[str(g.get("label", "")),913 f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",914 _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]915 for g in self.d.get("gauges") or []916 if isinstance(g.get("value"), (int, float)) and g.get("max")]917 return {"id": "gauges", "title": "Taux & couvertures",918 "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}919920 def _records_as_table(self):921 rows = [[str(r.get("label", "")), str(r.get("value", "")),922 str(r.get("date", "") or "")]923 for r in self.d.get("records") or []]924 return {"id": "records", "title": "Records & faits marquants",925 "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}926927 @staticmethod928 def _heatmap_as_table(hm, title):929 cells = sorted((hm.get("cells") or []),930 key=lambda c: -(c.get("value") or 0))[:40]931 return {"id": "heatmap", "title": title + " — jours les plus chargés",932 "columns": ["Date", "Valeur"],933 "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}934935 @staticmethod936 def _hourly_as_table(hr, title):937 days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",938 "Dimanche"]939 cells = sorted((hr.get("cells") or []),940 key=lambda c: -(c.get("value") or 0))[:40]941 return {"id": "hourly", "title": title + " — créneaux les plus actifs",942 "columns": ["Jour", "Heure", "Valeur"],943 "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",944 f"{c.get('hour', '?')} h", c.get("value") or 0]945 for c in cells]}946947 # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------948 def _find(self, coll: str, id_: str):949 for it in self.d.get(coll) or []:950 if str(it.get("id")) == id_:951 return it952 return None953954 def _toc_mark(self, title: str):955 """Blocs graphiques du mode personnalisé : entrée de sommaire sans956 _section_title (le graphique porte déjà son titre)."""957 if self.pdf.get_y() > 235:958 self.pdf.add_page()959 self.toc.append((title, self.pdf.page_no()))960961 def _render_block(self, key: str, render: str):962 section, _, id_ = key.partition(":")963 if section == "kpis":964 self._table(self._kpis_as_table()) if render == "table" else self._kpis()965 elif section == "gauges":966 self._table(self._gauges_as_table()) if render == "table" else self._gauges()967 elif section == "records":968 self._table(self._records_as_table()) if render == "table" else self._records()969 elif section == "series":970 s = self._find("series", id_)971 if not s:972 return973 if render == "table":974 self._table(self._serie_as_table(s), max_rows=400)975 else:976 s2 = dict(s)977 if render in ("line", "area", "bar"):978 s2["kind"] = render979 self._toc_mark(s2.get("title", ""))980 if s2.get("kind") == "bar":981 self._vbars(s2)982 else:983 self._line_chart(s2, with_stats=True)984 elif section == "multiseries":985 ms = self._find("multiseries", id_)986 if not ms:987 return988 if render == "table":989 self._table(self._multi_as_table(ms), max_rows=400)990 else:991 self._toc_mark(ms.get("title", ""))992 self._multiline(ms)993 elif section == "stacked":994 st = self._find("stacked", id_)995 if not st:996 return997 if render == "table":998 self._table(self._stacked_as_table(st), max_rows=400)999 else:1000 self._toc_mark(st.get("title", ""))1001 self._stacked(st)1002 elif section == "breakdowns":1003 b = self._find("breakdowns", id_)1004 if not b:1005 return1006 if render == "table":1007 self._table(self._items_as_table(id_, b.get("title", ""),1008 b.get("items")), max_rows=400)1009 else:1010 self._toc_mark(b.get("title", ""))1011 if render == "donut":1012 self._donut(b)1013 else:1014 self._bars(b.get("title", ""), b.get("items"))1015 elif section == "distributions":1016 d = self._find("distributions", id_)1017 if not d:1018 return1019 if render == "table":1020 bins = [{"label": bn.get("label"), "value": bn.get("value")}1021 for bn in d.get("bins") or []]1022 self._table(self._items_as_table(id_, d.get("title", ""), bins,1023 label_col="Tranche"))1024 else:1025 self._toc_mark(d.get("title", ""))1026 self._vbars(d)1027 elif section == "geo":1028 geo = self.d.get("geo") or {}1029 if not geo.get("items"):1030 return1031 title = geo.get("title", "Répartition géographique")1032 if render == "table":1033 self._table(self._items_as_table("geo", title, geo["items"],1034 label_col="Zone"), max_rows=400)1035 else:1036 self._toc_mark(title)1037 self._bars(title, geo["items"])1038 elif section == "heatmap":1039 hm = self.d.get("heatmap") or {}1040 if not hm.get("cells"):1041 return1042 title = hm.get("title", "Calendrier d'activité")1043 if render == "table":1044 self._table(self._heatmap_as_table(hm, title))1045 else:1046 self._toc_mark(title)1047 self._calheat(hm)1048 elif section == "hourly":1049 hr = self.d.get("hourly") or {}1050 if not hr.get("cells"):1051 return1052 title = hr.get("title", "Activité par jour et heure")1053 if render == "table":1054 self._table(self._hourly_as_table(hr, title))1055 else:1056 self._toc_mark(title)1057 self._hourly()1058 elif section == "tables":1059 t = self._find("tables", id_)1060 if t:1061 self._table(t, max_rows=400)10621063 def _table(self, t, max_rows=200):1064 p = self.pdf1065 cols = t.get("columns") or []1066 rows = t.get("rows") or []1067 if not cols or not rows:1068 return1069 self._section_title(t.get("title", "Tableau"))1070 w = 174 / len(cols)1071 def head():1072 p.set_font("helvetica", "B", 7.6)1073 p.set_fill_color(*INK)1074 p.set_text_color(*WHITE)1075 for c in cols:1076 p.cell(w, 6, " " + str(c)[:30], fill=True)1077 p.ln(6)1078 head()1079 p.set_text_color(*INK)1080 for i, row in enumerate(rows[:max_rows]):1081 if p.get_y() > 262:1082 p.add_page()1083 head()1084 p.set_text_color(*INK)1085 p.set_font("helvetica", "", 7.4)1086 p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))1087 for cell in row:1088 txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)1089 p.cell(w, 5.4, " " + txt[:34], fill=True)1090 p.ln(5.4)1091 if len(rows) > max_rows:1092 p.set_font("helvetica", "", 7)1093 p.set_text_color(*INK3)1094 p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")1095 p.ln(6)10961097 def _records(self):1098 recs = self.d.get("records") or []1099 if not recs:1100 return1101 self._section_title("Records & faits marquants")1102 p = self.pdf1103 for r in recs[:14]:1104 if p.get_y() > 258:1105 p.add_page()1106 y = p.get_y()1107 self._card(p.l_margin, y, 174, 11, fill=SURFACE2)1108 p.set_xy(p.l_margin + 4, y + 2)1109 p.set_font("helvetica", "", 8.6)1110 p.set_text_color(*INK2)1111 p.cell(96, 7, str(r.get("label", ""))[:70])1112 p.set_font("helvetica", "B", 9)1113 p.set_text_color(*INK)1114 p.cell(52, 7, str(r.get("value", ""))[:36], align="R")1115 p.set_font("helvetica", "", 7.6)1116 p.set_text_color(*INK3)1117 p.cell(20, 7, str(r.get("date", "") or ""), align="R")1118 p.set_y(y + 13.5)1119 p.ln(4)11201121 def _final_page(self):1122 p = self.pdf1123 p.add_page()1124 self._kicker("Groupe KA · contact")1125 p.set_font("helvetica", "B", 15)1126 p.set_text_color(*INK)1127 p.cell(0, 8, "Coordonnées du Groupe KA")1128 p.ln(12)1129 for email, role in EMAILS:1130 p.set_font("helvetica", "B", 10.5)1131 p.set_text_color(*INK)1132 p.cell(0, 6, email)1133 p.ln(5.5)1134 p.set_font("helvetica", "", 8.6)1135 p.set_text_color(*INK3)1136 p.cell(0, 5, role)1137 p.ln(8)1138 p.ln(2)1139 p.set_font("helvetica", "B", 10)1140 p.set_text_color(*GREEN)1141 p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")1142 p.ln(10)1143 p.set_draw_color(*self.accent)1144 p.set_line_width(0.8)1145 p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())1146 p.ln(4)1147 p.set_font("helvetica", "", 8.6)1148 p.set_text_color(*INK2)1149 p.multi_cell(160, 4.6, DISCLAIMER)1150 p.ln(4)1151 p.set_font("helvetica", "", 7.6)1152 p.set_text_color(*INK3)1153 p.multi_cell(1154 160, 4.2,1155 "Mentions : rapport généré automatiquement à partir des données réelles de la "1156 "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "1157 "de confidentialité et protection des renseignements personnels (Loi 25) : "1158 "groupe-ka.com/conditions · /confidentialite · /loi-25.",1159 )11601161 # ---------- groupes de sections ----------1162 def _all_series(self, with_stats=True):1163 for s in self.d.get("series") or []:1164 self._line_chart(s, with_stats=with_stats)1165 for ms in self.d.get("multiseries") or []:1166 self._multiline(ms)1167 for st in self.d.get("stacked") or []:1168 self._stacked(st)11691170 def _all_breakdowns(self):1171 for b in self.d.get("breakdowns") or []:1172 if b.get("kind") == "donut":1173 self._donut(b)1174 else:1175 self._bars(b.get("title", ""), b.get("items"))1176 for dist in self.d.get("distributions") or []:1177 self._vbars(dist)1178 geo = self.d.get("geo")1179 if geo:1180 self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))1181 self._hourly()11821183 def build(self) -> bytes:1184 p = self.pdf1185 p.alias_nb_pages()1186 self._cover()1187 with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)1188 toc_page_no = None1189 if self.mode == "synthese":1190 p.add_page()1191 self._kpis()1192 self._gauges()1193 self._records()1194 self._final_page()1195 elif self.mode == "tendances":1196 p.add_page()1197 self._kpis()1198 self._section_title("Évolution & tendances")1199 self._all_series(with_stats=True)1200 self._records()1201 self._final_page()1202 elif self.mode == "repartitions":1203 p.add_page()1204 self._section_title("Répartitions, distributions & géographie")1205 self._all_breakdowns()1206 self._final_page()1207 elif self.mode == "donnees":1208 p.add_page()1209 toc_page_no = p.page_no()1210 for t in self.d.get("tables") or []:1211 self._table(t, max_rows=400)1212 self._final_page()1213 elif self.mode == CUSTOM_MODE:1214 p.add_page()1215 toc_page_no = p.page_no()1216 p.add_page()1217 known = {b["key"]: b for b in catalog(self.d)}1218 for blk in self.spec.get("blocks") or []:1219 key = str(blk.get("key", ""))1220 b = known.get(key)1221 if not b:1222 continue1223 render = str(blk.get("render") or "")1224 if render not in b["renders"]:1225 render = b["default_render"]1226 self._render_block(key, render)1227 self._final_page()1228 else: # complet1229 p.add_page()1230 toc_page_no = p.page_no()1231 p.add_page()1232 self._kpis()1233 self._gauges()1234 if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):1235 self._section_title("Évolution & tendances")1236 self._all_series(with_stats=True)1237 if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):1238 self._section_title("Répartitions, distributions & géographie")1239 self._all_breakdowns()1240 for t in self.d.get("tables") or []:1241 self._table(t)1242 self._records()1243 self._final_page()1244 # sommaire écrit sur la page réservée1245 if toc_page_no is not None:1246 last_page = p.page1247 p.page = toc_page_no1248 p.set_y(22)1249 p.set_font("helvetica", "B", 15)1250 p.set_text_color(*INK)1251 p.cell(0, 8, "Sommaire")1252 p.ln(12)1253 p.set_font("helvetica", "", 9.5)1254 for title, page_no in self.toc:1255 p.set_text_color(*INK)1256 p.cell(140, 6.5, title[:80])1257 p.set_text_color(*INK3)1258 p.cell(0, 6.5, str(page_no), align="R")1259 p.ln(6.5)1260 p.page = last_page1261 return bytes(p.output())126212631264def filename(platform_id: str, period: str, mode: str = "complet") -> str:1265 today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")1266 suffix = "" if mode in ("", "complet") else f"_{mode}"1267 return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"1268