# ============================================ # Projet : API-KA # Fichier : src/api/kapdf.py # Node : m3u96b # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Date : 2026-08-19 # ============================================ # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3 # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit # les rapports estampillés Groupe-KA. 5 modes fixes : # complet — toutes les sections (KPI, jauges, séries + stats, multi- # séries, empilées, distributions, répartitions, géo, # heatmap horaire, tableaux, records) # synthese — couverture + KPI + records (2-3 pages) # tendances — KPI + toutes les séries temporelles + stats de séries # repartitions — breakdowns, distributions, géo, activité horaire # donnees — tous les tableaux en version longue (400 lignes max) # v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par # bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/ # heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport # suit une spec {"title": str, "blocks": [{"key": "series:ajouts", # "render": "bar"}, …]} et respecte l'ordre demandé. # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque. # Usage : # from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, # mode="complet").build() # pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise", # spec={"title": "Mon rapport", "blocks": [...]}).build() # Dépendance : pip install fpdf2 (aucune autre) from __future__ import annotations import math from datetime import datetime from zoneinfo import ZoneInfo from fpdf import FPDF INK = (20, 24, 20) INK2 = (77, 85, 81) INK3 = (139, 146, 140) PAPER = (245, 243, 238) SURFACE2 = (250, 249, 245) GREEN = (28, 92, 65) DANGER = (179, 66, 58) WHITE = (255, 255, 255) REPORT_MODES = { "complet": "Rapport complet", "synthese": "Synthèse exécutive", "tendances": "Tendances & évolution", "repartitions": "Répartitions & géographie", "donnees": "Données détaillées", } # v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes) CUSTOM_MODE = "personnalise" CUSTOM_LABEL = "Rapport personnalisé" # v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ; # « table » est toujours offert : toute donnée a un équivalent tableau) RENDER_LABELS = { "line": "Courbe", "area": "Aire", "bar": "Barres verticales", "bars": "Barres horizontales", "donut": "Anneau", "lines": "Multi-courbes", "stacked": "Barres empilées", "histogram": "Histogramme", "heatmap": "Heatmap", "cards": "Cartes", "gauges": "Jauges", "table": "Tableau", } SECTION_LABELS = { "kpis": "Indicateurs", "gauges": "Taux & couvertures", "series": "Évolution", "multiseries": "Comparaisons", "stacked": "Compositions", "breakdowns": "Répartitions", "distributions": "Distributions", "geo": "Géographie", "heatmap": "Calendrier", "hourly": "Activité horaire", "tables": "Tableaux", "records": "Records", } def catalog(dash: dict) -> list[dict]: """v3 — blocs composables d'un dashboard : ce que le constructeur de rapports personnalisés peut inclure, avec les rendus compatibles. key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard.""" out: list[dict] = [] def add(key, title, renders, default=None, count=None): b = {"key": key, "section": key.split(":")[0], "title": title, "renders": renders, "default_render": default or renders[0]} if count is not None: b["count"] = count out.append(b) if dash.get("kpis"): add("kpis", "Indicateurs clés (KPI)", ["cards", "table"], count=len(dash["kpis"])) gs = [g for g in (dash.get("gauges") or []) if isinstance(g.get("value"), (int, float)) and g.get("max")] if gs: add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"], count=len(gs)) for s in dash.get("series") or []: if len(s.get("points") or []) < 2: continue kind = s.get("kind") or "line" default = kind if kind in ("line", "area", "bar") else "line" add(f"series:{s.get('id')}", s.get("title", ""), ["line", "area", "bar", "table"], default, len(s.get("points") or [])) for ms in dash.get("multiseries") or []: if not (ms.get("series") or []): continue add(f"multiseries:{ms.get('id')}", ms.get("title", ""), ["lines", "table"], count=len(ms["series"])) for st in dash.get("stacked") or []: if not (st.get("points") or []): continue add(f"stacked:{st.get('id')}", st.get("title", ""), ["stacked", "table"], count=len(st.get("keys") or [])) for b in dash.get("breakdowns") or []: if not (b.get("items") or []): continue default = "donut" if b.get("kind") == "donut" else "bars" add(f"breakdowns:{b.get('id')}", b.get("title", ""), ["donut", "bars", "table"], default, len(b["items"])) for d in dash.get("distributions") or []: if not (d.get("bins") or []): continue add(f"distributions:{d.get('id')}", d.get("title", ""), ["histogram", "table"], count=len(d["bins"])) geo = dash.get("geo") or {} if geo.get("items"): add("geo", geo.get("title", "Répartition géographique"), ["bars", "table"], count=len(geo["items"])) hm = dash.get("heatmap") or {} if hm.get("cells"): add("heatmap", hm.get("title", "Calendrier d'activité"), ["heatmap", "table"]) hr = dash.get("hourly") or {} if hr.get("cells"): add("hourly", hr.get("title", "Activité par jour et heure"), ["heatmap", "table"]) for t in dash.get("tables") or []: if not (t.get("rows") or []): continue add(f"tables:{t.get('id')}", t.get("title", ""), ["table"], count=len(t["rows"])) if dash.get("records"): add("records", "Records & faits marquants", ["cards", "table"], count=len(dash["records"])) return out EMAILS = [ ("contact@groupe-ka.com", "Projets, partenariats & données"), ("info@groupe-ka.com", "Médias & questions générales"), ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"), ] DISCLAIMER = ( "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons " "rien et ne sommes partie à aucune transaction. Données lues à la source, " "rien d'inventé, tout est traçable." ) def _hex(c: str) -> tuple[int, int, int]: c = c.lstrip("#") return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore def _fr(n) -> str: if isinstance(n, float) and not n.is_integer(): return f"{n:,.2f}".replace(",", " ").replace(".", ",") return f"{int(n):,}".replace(",", " ") _SUBST = { "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-", "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"', "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ", "×": "x", "·": ".", "σ": "sigma", "Δ": "delta", } def _latin1(s: str) -> str: for k, v in _SUBST.items(): s = s.replace(k, v) return s.encode("latin-1", "replace").decode("latin-1") class _PDF(FPDF): """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture). Les polices core sont latin-1 : normalize_text sanitise en amont.""" def normalize_text(self, text): return super().normalize_text(_latin1(text)) def __init__(self, brand: str, accent: tuple, period_label: str): super().__init__(orientation="P", unit="mm", format="A4") self.brand = brand self.accent = accent self.period_label = period_label self.cover_mode = False self.set_margins(18, 20, 18) self.set_auto_page_break(True, margin=22) def header(self): if self.cover_mode or self.page_no() == 1: return self.set_font("helvetica", "B", 8.5) self.set_text_color(*INK) self.set_xy(18, 9) self.cell(0, 5, f"Groupe KA · {self.brand}") self.set_font("helvetica", "", 8) self.set_text_color(*INK3) self.set_xy(18, 9) self.cell(0, 5, "Rapport statistique", align="R") self.set_draw_color(*INK) self.set_line_width(0.5) self.line(18, 15.5, 192, 15.5) self.set_y(20) def footer(self): # page 1 = couverture (le flag cover_mode est déjà retombé quand # add_page() clôt la page 1 → tester aussi le numéro de page) if self.cover_mode or self.page_no() == 1: return self.set_y(-15) self.set_draw_color(*INK3) self.set_line_width(0.2) self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5) self.set_font("helvetica", "", 7.5) self.set_text_color(*INK3) year = datetime.now(ZoneInfo("America/Toronto")).year self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}") self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R") class GroupeKAReport: def __init__(self, site: dict, dashboard: dict, mode: str = "complet", spec: dict | None = None): self.site = site self.d = dashboard self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet" self.spec = spec or {} self.accent = _hex(site.get("accent", "#d9f26b")) period = dashboard.get("period", {}) or {} self.period_label = period.get("label") or "toute la période" self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) self.toc: list[tuple[str, int]] = [] @property def mode_label(self) -> str: if self.mode == CUSTOM_MODE: t = str(self.spec.get("title") or "").strip() return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL return REPORT_MODES[self.mode] # ---------- primitives ---------- def _card(self, x, y, w, h, fill=WHITE): p = self.pdf p.set_draw_color(*INK) p.set_line_width(0.45) p.set_fill_color(*fill) p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2) def _shade(self, i, n=8): shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10] f = shades[i % len(shades)] return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) def _kicker(self, text): p = self.pdf p.set_font("helvetica", "B", 8) p.set_text_color(*GREEN) p.set_draw_color(*GREEN) p.set_line_width(0.6) y = p.get_y() + 2 p.line(p.l_margin, y, p.l_margin + 7, y) p.set_xy(p.l_margin + 9, y - 2.5) p.cell(0, 5, text.upper()) p.ln(8) def _section_title(self, title): if self.pdf.get_y() > 240: self.pdf.add_page() self._kicker("Groupe KA · " + self.site.get("wordmark", "")) self.pdf.set_font("helvetica", "B", 15) self.pdf.set_text_color(*INK) self.pdf.set_x(self.pdf.l_margin) self.pdf.cell(0, 8, title) self.toc.append((title, self.pdf.page_no())) self.pdf.ln(11) def _chart_title(self, title): p = self.pdf p.set_font("helvetica", "B", 10) p.set_text_color(*INK) p.set_x(p.l_margin) p.cell(0, 6, title) p.ln(7) # ---------- pages ---------- def _cover(self): p = self.pdf p.cover_mode = True p.set_auto_page_break(False) p.add_page() p.set_fill_color(*PAPER) p.rect(0, 0, 210, 297, style="F") p.set_draw_color(*INK) p.set_line_width(1.0) p.rect(10, 10, 190, 277) p.set_font("helvetica", "B", 10) p.set_text_color(*GREEN) p.set_xy(24, 34) p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE") wm = self.site.get("wordmark", "") left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None) p.set_xy(24, 70) p.set_font("helvetica", "B", 40) p.set_text_color(*INK) p.cell(p.get_string_width(left) + 2, 20, left) if boxed: bw = p.get_string_width(boxed) + 12 x = p.get_x() + 2 p.set_fill_color(*INK) p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3) p.set_text_color(*self.accent) p.set_xy(x + 6, 70) p.cell(bw - 12, 18, boxed) p.set_xy(24, 100) p.set_font("helvetica", "", 13) p.set_text_color(*INK2) p.multi_cell(150, 7, f"{self.mode_label} — {wm}") now = datetime.now(ZoneInfo("America/Toronto")) per = self.d.get("period", {}) or {} p.set_xy(24, 125) p.set_font("helvetica", "", 10.5) rows = [ ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), ("Plateforme", "https://" + self.site.get("domain", "")), ("Type de rapport", self.mode_label), ] y = 128 for k, v in rows: p.set_xy(24, y) p.set_text_color(*INK3) p.cell(40, 6, k) p.set_text_color(*INK) p.set_font("helvetica", "B", 10.5) p.cell(0, 6, str(v)) p.set_font("helvetica", "", 10.5) y += 8 p.set_fill_color(*INK) p.rect(10, 262, 190, 25, style="F") p.set_xy(24, 270) p.set_font("helvetica", "B", 12) p.set_text_color(*WHITE) p.cell(60, 8, "par Groupe ") p.set_text_color(*self.accent) p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270) p.cell(20, 8, "KA") p.set_font("helvetica", "B", 10) p.set_xy(24, 270) p.set_text_color(*self.accent) p.cell(162, 8, "groupe-ka.com", align="R") p.set_auto_page_break(True, margin=22) p.cover_mode = False def _kpis(self): kpis = self.d.get("kpis") or [] if not kpis: return self._section_title("Synthèse des indicateurs") p = self.pdf cols, gw, gh, gap = 3, 56, 26, 3 x0, y = p.l_margin, p.get_y() for i, k in enumerate(kpis[:12]): x = x0 + (i % cols) * (gw + gap) if i and i % cols == 0: y += gh + gap if y > 250: p.add_page(); y = p.get_y() self._card(x, y, gw, gh) p.set_xy(x + 4, y + 4) p.set_font("helvetica", "B", 14) p.set_text_color(*INK) val = k.get("value") p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else "")) p.set_xy(x + 4, y + 12) p.set_font("helvetica", "", 7.6) p.set_text_color(*INK2) p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70]) if k.get("delta_pct") is not None: up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up" p.set_xy(x + 4, y + gh - 6.5) p.set_font("helvetica", "B", 8) p.set_text_color(*(GREEN if up else DANGER)) arrow = "+" if k["delta_pct"] >= 0 else "" dv = round(float(k["delta_pct"]), 1) dv = int(dv) if float(dv).is_integer() else dv p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.") p.set_y(y + gh + 8) def _gauges(self): gs = self.d.get("gauges") or [] gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")] if not gs: return self._section_title("Taux & couvertures") p = self.pdf cols, gw, gh, gap = 3, 56, 34, 3 x0, y = p.l_margin, p.get_y() for i, g in enumerate(gs[:9]): x = x0 + (i % cols) * (gw + gap) if i and i % cols == 0: y += gh + gap if y > 240: p.add_page(); y = p.get_y() self._card(x, y, gw, gh) frac = max(0.0, min(1.0, g["value"] / g["max"])) cx, cy, r = x + gw / 2, y + 20, 14 # arc de fond + arc de valeur (demi-cercle en petits segments) for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)): p.set_draw_color(*pass_col) p.set_line_width(lw) steps = max(2, int(60 * pass_frac)) last = None for st in range(steps + 1): a = math.pi + math.pi * pass_frac * st / steps pt = (cx + r * math.cos(a), cy + r * math.sin(a)) if last: p.line(last[0], last[1], pt[0], pt[1]) last = pt p.set_font("helvetica", "B", 11) p.set_text_color(*INK) p.set_xy(x + 4, cy - 5) p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C") p.set_font("helvetica", "", 6.6) p.set_text_color(*INK3) p.set_xy(x + 4, cy + 1.5) p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C") p.set_xy(x + 3, y + gh - 7) p.set_font("helvetica", "", 7) p.set_text_color(*INK2) p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C") p.set_y(y + gh + 8) def _serie_stats_row(self, s): """Ligne min/max/moyenne/médiane sous un graphique de série.""" p = self.pdf vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))] if len(vs) < 2: return sv = sorted(vs) mean = sum(vs) / len(vs) med = sv[len(sv) // 2] sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs)) p.set_font("helvetica", "", 6.8) p.set_text_color(*INK3) 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))}") p.ln(5.5) def _line_chart(self, s, with_stats=False): p = self.pdf pts = s.get("points") or [] if len(pts) < 2: return if s.get("kind") == "bar": self._vbars(s) return if p.get_y() > 200: p.add_page() self._chart_title(s.get("title", "")) x0, y0, w, h = p.l_margin, p.get_y(), 174, 52 self._card(x0, y0, w, h, fill=WHITE) cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16 vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])] vmax = max(vals) or 1 vmin = min(0, min(vals)) rng = (vmax - vmin) or 1 p.set_font("helvetica", "", 6.3) p.set_text_color(*INK3) p.set_draw_color(200, 200, 195) p.set_line_width(0.15) for g in range(5): gy = cy + ch - ch * g / 4 p.line(cx, gy, cx + cw, gy) p.set_xy(x0 + 1, gy - 1.6) p.cell(10, 3, _fr(vmin + rng * g / 4), align="R") def xy(i, n, v): return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng)) # aire sous la courbe (kind=area) : petits trapèzes accent pâle if s.get("kind") == "area": fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3)) p.set_fill_color(*fill) p.set_draw_color(*fill) n = len(pts) for i in range(n - 1): x1, y1 = xy(i, n, pts[i]["v"]) x2, y2 = xy(i + 1, n, pts[i + 1]["v"]) p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF") def draw(series, color, width, dash=None): n = len(series) p.set_draw_color(*color) p.set_line_width(width) if dash: p.set_dash_pattern(dash=1.2, gap=1.2) last = None for i, pt in enumerate(series): px, py = xy(i, n, pt["v"]) if last: p.line(last[0], last[1], px, py) last = (px, py) p.set_dash_pattern() if s.get("compare"): draw(s["compare"], INK3, 0.35, dash=True) draw(pts, self.accent, 0.7) p.set_text_color(*INK3) for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)): p.set_xy(cx + cw * frac - 9, cy + ch + 1.5) p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C") p.set_y(y0 + h + 4) if s.get("compare"): p.set_font("helvetica", "", 6.8) p.set_text_color(*INK3) p.cell(0, 4, "— période courante (accent) · ---- période comparée") p.ln(5.5) if with_stats: self._serie_stats_row(s) p.ln(1.5) def _vbars(self, s): """Barres verticales : série kind=bar ou distribution (bins).""" p = self.pdf pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])] pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))] if not pts: return if p.get_y() > 205: p.add_page() self._chart_title(s.get("title", "")) x0, y0, w, h = p.l_margin, p.get_y(), 174, 48 self._card(x0, y0, w, h, fill=WHITE) cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14 vmax = max(pt["v"] for pt in pts) or 1 p.set_font("helvetica", "", 6.3) p.set_text_color(*INK3) p.set_draw_color(200, 200, 195) p.set_line_width(0.15) for g in range(5): gy = cy + ch - ch * g / 4 p.line(cx, gy, cx + cw, gy) p.set_xy(x0 + 1, gy - 1.6) p.cell(10, 3, _fr(vmax * g / 4), align="R") n = len(pts) bw = max(0.8, cw / n - 0.6) p.set_fill_color(*self.accent) p.set_draw_color(*INK) p.set_line_width(0.15) for i, pt in enumerate(pts): bh = ch * (pt["v"] / vmax) p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF") p.set_text_color(*INK3) for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)): p.set_xy(cx + cw * frac - 10, cy + ch + 1.5) p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C") p.set_y(y0 + h + 5) def _multiline(self, ms): """Multi-séries (≤4) : accent plein / encre fin / accent pointillé / gris pointillé — l'identité passe par le motif, pas la couleur seule.""" p = self.pdf series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4] if not series: return if p.get_y() > 195: p.add_page() self._chart_title(ms.get("title", "")) x0, y0, w, h = p.l_margin, p.get_y(), 174, 52 self._card(x0, y0, w, h, fill=WHITE) cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16 vals = [pt["v"] for s in series for pt in s["points"]] vmax = max(vals) or 1 vmin = min(0, min(vals)) rng = (vmax - vmin) or 1 p.set_font("helvetica", "", 6.3) p.set_text_color(*INK3) p.set_draw_color(200, 200, 195) p.set_line_width(0.15) for g in range(5): gy = cy + ch - ch * g / 4 p.line(cx, gy, cx + cw, gy) p.set_xy(x0 + 1, gy - 1.6) p.cell(10, 3, _fr(vmin + rng * g / 4), align="R") styles = [ (self.accent, 0.7, None), (INK, 0.45, None), (self.accent, 0.55, True), (INK3, 0.5, True), ] for si, s in enumerate(series): col, lw, dash = styles[si] p.set_draw_color(*col) p.set_line_width(lw) if dash: p.set_dash_pattern(dash=1.4, gap=1.2) n = len(s["points"]) last = None for i, pt in enumerate(s["points"]): px = cx + cw * (i / (n - 1)) py = cy + ch - ch * ((pt["v"] - vmin) / rng) if last: p.line(last[0], last[1], px, py) last = (px, py) p.set_dash_pattern() ref = series[0]["points"] p.set_text_color(*INK3) for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)): p.set_xy(cx + cw * frac - 9, cy + ch + 1.5) p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C") p.set_y(y0 + h + 4) p.set_font("helvetica", "", 6.8) p.set_text_color(*INK3) marks = ["—", "—", "----", "----"] leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series)) p.cell(0, 4, leg[:120]) p.ln(6) def _stacked(self, st): p = self.pdf keys = (st.get("keys") or [])[:6] pts = st.get("points") or [] if not keys or not pts: return if p.get_y() > 195: p.add_page() self._chart_title(st.get("title", "")) x0, y0, w, h = p.l_margin, p.get_y(), 174, 52 self._card(x0, y0, w, h, fill=WHITE) cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16 totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts] vmax = max(totals) or 1 p.set_font("helvetica", "", 6.3) p.set_text_color(*INK3) p.set_draw_color(200, 200, 195) p.set_line_width(0.15) for g in range(5): gy = cy + ch - ch * g / 4 p.line(cx, gy, cx + cw, gy) p.set_xy(x0 + 1, gy - 1.6) p.cell(10, 3, _fr(vmax * g / 4), align="R") n = len(pts) bw = max(0.8, cw / n - 0.6) p.set_draw_color(*WHITE) p.set_line_width(0.12) for i, pt in enumerate(pts): yacc = cy + ch for j, k in enumerate(keys): v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0 if not v: continue bh = ch * (v / vmax) yacc -= bh p.set_fill_color(*self._shade(j)) p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF") p.set_text_color(*INK3) for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)): p.set_xy(cx + cw * frac - 10, cy + ch + 1.5) p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C") p.set_y(y0 + h + 4) # légende p.set_font("helvetica", "", 6.8) lx = p.l_margin for j, k in enumerate(keys): p.set_fill_color(*self._shade(j)) p.set_draw_color(*INK) p.set_line_width(0.2) p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF") p.set_xy(lx + 4, p.get_y()) p.set_text_color(*INK2) txt = str(k)[:22] p.cell(p.get_string_width(txt) + 3, 4, txt) lx = p.get_x() + 3 if lx > 165: break p.ln(7) def _bars(self, title, items, unit=""): p = self.pdf items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12] if not items: return need = 10 + len(items) * 7 if p.get_y() + need > 265: p.add_page() self._chart_title(title) p.ln(1) vmax = max(it["value"] for it in items) or 1 for it in items: y = p.get_y() p.set_font("helvetica", "", 7.6) p.set_text_color(*INK) p.set_x(p.l_margin) p.cell(46, 5, str(it["label"])[:34]) bw = 86 * (it["value"] / vmax) p.set_fill_color(*self.accent) p.set_draw_color(*INK) p.set_line_width(0.25) p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF") p.set_xy(p.l_margin + 136, y) p.set_font("helvetica", "B", 7.6) p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R") if it.get("delta_pct") is not None: up = it["delta_pct"] >= 0 p.set_font("helvetica", "B", 6.6) p.set_text_color(*(GREEN if up else DANGER)) p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R") p.ln(6.4) p.ln(3) def _donut(self, b): p = self.pdf items = [it for it in (b.get("items") or []) if it.get("value")][:8] total = sum(it["value"] for it in items) if not items or not total: return if p.get_y() > 210: p.add_page() self._chart_title(b.get("title", "")) p.ln(1) cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20 start = -90.0 for i, it in enumerate(items): frac = it["value"] / total col = self._shade(i) steps = max(2, int(72 * frac)) p.set_fill_color(*col) p.set_draw_color(*col) for st in range(steps): a0 = math.radians(start + 360 * frac * st / steps) a1 = math.radians(start + 360 * frac * (st + 1) / steps) p.polygon( [(cx, cy), (cx + r * math.cos(a0), cy + r * math.sin(a0)), (cx + r * math.cos(a1), cy + r * math.sin(a1))], style="DF", ) start += 360 * frac p.set_fill_color(*WHITE) p.set_draw_color(*INK) p.set_line_width(0.4) p.ellipse(cx - 11, cy - 11, 22, 22, style="DF") p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D") ly = cy - 22 for i, it in enumerate(items): col = self._shade(i) p.set_fill_color(*col) p.set_draw_color(*INK) p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF") p.set_xy(p.l_margin + 66, ly) p.set_font("helvetica", "", 7.6) p.set_text_color(*INK) pct = 100 * it["value"] / total p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ",")) ly += 5.6 p.set_y(max(cy + r, ly) + 6) def _hourly(self): hh = self.d.get("hourly") or {} cells = hh.get("cells") or [] if not cells: return p = self.pdf if p.get_y() > 190: p.add_page() self._chart_title(hh.get("title", "Activité par jour et heure")) x0, y0 = p.l_margin, p.get_y() cw, chh, lx, ly = 6.4, 6.4, 12, 5 vmax = max((c.get("value") or 0) for c in cells) or 1 grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells} dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"] p.set_font("helvetica", "", 5.8) p.set_text_color(*INK3) for h in (0, 6, 12, 18, 23): p.set_xy(x0 + lx + h * cw, y0) p.cell(cw, 3, f"{h}h", align="C") for d in range(7): p.set_xy(x0, y0 + ly + d * chh + 1.5) p.cell(lx - 1, 3, dows[d], align="R") for h in range(24): v = grid.get((d, h), 0) f = 0.1 + 0.9 * (v / vmax) if v else 0.0 col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228) p.set_fill_color(*col) p.set_draw_color(215, 213, 207) p.set_line_width(0.1) p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF") p.set_y(y0 + ly + 7 * chh + 5) def _calheat(self, hm): """v3 — calendrier de chaleur 26 semaines (équivalent PDF du CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours.""" from datetime import date as _date, timedelta as _td cells = hm.get("cells") or [] vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")} if not vals: return p = self.pdf if p.get_y() > 215: p.add_page() self._chart_title(hm.get("title", "Calendrier d'activité")) try: end = _date.fromisoformat(max(vals)) except ValueError: return weeks = 26 start = end - _td(days=weeks * 7 - 1) start -= _td(days=start.weekday()) # lundi vmax = max(vals.values()) or 1 x0, y0 = p.l_margin, p.get_y() cw, lx, ly = 6.3, 10, 4 dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"] p.set_font("helvetica", "", 5.8) p.set_text_color(*INK3) for d in range(7): if dows[d]: p.set_xy(x0, y0 + ly + d * cw + 1.2) p.cell(lx - 1, 3, dows[d], align="R") for w in range(weeks): monday = start + _td(days=7 * w) if monday.day <= 7: # étiquette de mois à la 1re semaine du mois p.set_xy(x0 + lx + w * cw, y0) p.cell(cw * 4, 3, monday.strftime("%m")) for d in range(7): day = monday + _td(days=d) v = vals.get(day.isoformat(), 0) f = 0.15 + 0.85 * (v / vmax) if v else 0.0 col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)) p.set_fill_color(*col) p.set_draw_color(215, 213, 207) p.set_line_width(0.1) p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5, style="DF") p.set_y(y0 + ly + 7 * cw + 5) # ---------- v3 : conversions bloc → tableau ---------- @staticmethod def _serie_as_table(s): unit = s.get("unit") or "Valeur" cols = ["Date", unit.capitalize()] cmp_ = s.get("compare") or [] if cmp_: cols.append("Période comparée") rows = [] for i, pt in enumerate(s.get("points") or []): row = [str(pt.get("t", "")), pt.get("v", "")] if cmp_: row.append(cmp_[i]["v"] if i < len(cmp_) else "") rows.append(row) return {"id": s.get("id"), "title": s.get("title", ""), "columns": cols, "rows": rows} @staticmethod def _multi_as_table(ms): labels = [s.get("label", "") for s in (ms.get("series") or [])][:4] by_t: dict[str, dict] = {} for s in (ms.get("series") or [])[:4]: for pt in s.get("points") or []: by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v") rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels] for t in sorted(by_t)] return {"id": ms.get("id"), "title": ms.get("title", ""), "columns": ["Date"] + labels, "rows": rows} @staticmethod def _stacked_as_table(st): keys = (st.get("keys") or [])[:6] rows = [] for pt in st.get("points") or []: vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0 for j in range(len(keys))] rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)]) return {"id": st.get("id"), "title": st.get("title", ""), "columns": ["Date"] + list(keys) + ["Total"], "rows": rows} @staticmethod def _items_as_table(id_, title, items, label_col="Libellé"): items = items or [] with_delta = any(it.get("delta_pct") is not None for it in items) cols = [label_col, "Valeur"] + (["delta %"] if with_delta else []) rows = [] for it in items: row = [str(it.get("label", "")), it.get("value", "")] if with_delta: d = it.get("delta_pct") row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %") rows.append(row) return {"id": id_, "title": title, "columns": cols, "rows": rows} def _kpis_as_table(self): rows = [] for k in self.d.get("kpis") or []: v = k.get("value") val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \ ((" " + k["unit"]) if k.get("unit") else "") d = k.get("delta_pct") rows.append([str(k.get("label", "")), val, "" if d is None else f"{'+' if d >= 0 else ''}{d} %"]) return {"id": "kpis", "title": "Indicateurs clés", "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows} def _gauges_as_table(self): rows = [[str(g.get("label", "")), f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"] for g in self.d.get("gauges") or [] if isinstance(g.get("value"), (int, float)) and g.get("max")] return {"id": "gauges", "title": "Taux & couvertures", "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows} def _records_as_table(self): rows = [[str(r.get("label", "")), str(r.get("value", "")), str(r.get("date", "") or "")] for r in self.d.get("records") or []] return {"id": "records", "title": "Records & faits marquants", "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows} @staticmethod def _heatmap_as_table(hm, title): cells = sorted((hm.get("cells") or []), key=lambda c: -(c.get("value") or 0))[:40] return {"id": "heatmap", "title": title + " — jours les plus chargés", "columns": ["Date", "Valeur"], "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]} @staticmethod def _hourly_as_table(hr, title): days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"] cells = sorted((hr.get("cells") or []), key=lambda c: -(c.get("value") or 0))[:40] return {"id": "hourly", "title": title + " — créneaux les plus actifs", "columns": ["Jour", "Heure", "Valeur"], "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?", f"{c.get('hour', '?')} h", c.get("value") or 0] for c in cells]} # ---------- v3 : rendu d'un bloc du rapport personnalisé ---------- def _find(self, coll: str, id_: str): for it in self.d.get(coll) or []: if str(it.get("id")) == id_: return it return None def _toc_mark(self, title: str): """Blocs graphiques du mode personnalisé : entrée de sommaire sans _section_title (le graphique porte déjà son titre).""" if self.pdf.get_y() > 235: self.pdf.add_page() self.toc.append((title, self.pdf.page_no())) def _render_block(self, key: str, render: str): section, _, id_ = key.partition(":") if section == "kpis": self._table(self._kpis_as_table()) if render == "table" else self._kpis() elif section == "gauges": self._table(self._gauges_as_table()) if render == "table" else self._gauges() elif section == "records": self._table(self._records_as_table()) if render == "table" else self._records() elif section == "series": s = self._find("series", id_) if not s: return if render == "table": self._table(self._serie_as_table(s), max_rows=400) else: s2 = dict(s) if render in ("line", "area", "bar"): s2["kind"] = render self._toc_mark(s2.get("title", "")) if s2.get("kind") == "bar": self._vbars(s2) else: self._line_chart(s2, with_stats=True) elif section == "multiseries": ms = self._find("multiseries", id_) if not ms: return if render == "table": self._table(self._multi_as_table(ms), max_rows=400) else: self._toc_mark(ms.get("title", "")) self._multiline(ms) elif section == "stacked": st = self._find("stacked", id_) if not st: return if render == "table": self._table(self._stacked_as_table(st), max_rows=400) else: self._toc_mark(st.get("title", "")) self._stacked(st) elif section == "breakdowns": b = self._find("breakdowns", id_) if not b: return if render == "table": self._table(self._items_as_table(id_, b.get("title", ""), b.get("items")), max_rows=400) else: self._toc_mark(b.get("title", "")) if render == "donut": self._donut(b) else: self._bars(b.get("title", ""), b.get("items")) elif section == "distributions": d = self._find("distributions", id_) if not d: return if render == "table": bins = [{"label": bn.get("label"), "value": bn.get("value")} for bn in d.get("bins") or []] self._table(self._items_as_table(id_, d.get("title", ""), bins, label_col="Tranche")) else: self._toc_mark(d.get("title", "")) self._vbars(d) elif section == "geo": geo = self.d.get("geo") or {} if not geo.get("items"): return title = geo.get("title", "Répartition géographique") if render == "table": self._table(self._items_as_table("geo", title, geo["items"], label_col="Zone"), max_rows=400) else: self._toc_mark(title) self._bars(title, geo["items"]) elif section == "heatmap": hm = self.d.get("heatmap") or {} if not hm.get("cells"): return title = hm.get("title", "Calendrier d'activité") if render == "table": self._table(self._heatmap_as_table(hm, title)) else: self._toc_mark(title) self._calheat(hm) elif section == "hourly": hr = self.d.get("hourly") or {} if not hr.get("cells"): return title = hr.get("title", "Activité par jour et heure") if render == "table": self._table(self._hourly_as_table(hr, title)) else: self._toc_mark(title) self._hourly() elif section == "tables": t = self._find("tables", id_) if t: self._table(t, max_rows=400) def _table(self, t, max_rows=200): p = self.pdf cols = t.get("columns") or [] rows = t.get("rows") or [] if not cols or not rows: return self._section_title(t.get("title", "Tableau")) w = 174 / len(cols) def head(): p.set_font("helvetica", "B", 7.6) p.set_fill_color(*INK) p.set_text_color(*WHITE) for c in cols: p.cell(w, 6, " " + str(c)[:30], fill=True) p.ln(6) head() p.set_text_color(*INK) for i, row in enumerate(rows[:max_rows]): if p.get_y() > 262: p.add_page() head() p.set_text_color(*INK) p.set_font("helvetica", "", 7.4) p.set_fill_color(*(SURFACE2 if i % 2 else WHITE)) for cell in row: txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell) p.cell(w, 5.4, " " + txt[:34], fill=True) p.ln(5.4) if len(rows) > max_rows: p.set_font("helvetica", "", 7) p.set_text_color(*INK3) p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées") p.ln(6) def _records(self): recs = self.d.get("records") or [] if not recs: return self._section_title("Records & faits marquants") p = self.pdf for r in recs[:14]: if p.get_y() > 258: p.add_page() y = p.get_y() self._card(p.l_margin, y, 174, 11, fill=SURFACE2) p.set_xy(p.l_margin + 4, y + 2) p.set_font("helvetica", "", 8.6) p.set_text_color(*INK2) p.cell(96, 7, str(r.get("label", ""))[:70]) p.set_font("helvetica", "B", 9) p.set_text_color(*INK) p.cell(52, 7, str(r.get("value", ""))[:36], align="R") p.set_font("helvetica", "", 7.6) p.set_text_color(*INK3) p.cell(20, 7, str(r.get("date", "") or ""), align="R") p.set_y(y + 13.5) p.ln(4) def _final_page(self): p = self.pdf p.add_page() self._kicker("Groupe KA · contact") p.set_font("helvetica", "B", 15) p.set_text_color(*INK) p.cell(0, 8, "Coordonnées du Groupe KA") p.ln(12) for email, role in EMAILS: p.set_font("helvetica", "B", 10.5) p.set_text_color(*INK) p.cell(0, 6, email) p.ln(5.5) p.set_font("helvetica", "", 8.6) p.set_text_color(*INK3) p.cell(0, 5, role) p.ln(8) p.ln(2) p.set_font("helvetica", "B", 10) p.set_text_color(*GREEN) p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka") p.ln(10) p.set_draw_color(*self.accent) p.set_line_width(0.8) p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y()) p.ln(4) p.set_font("helvetica", "", 8.6) p.set_text_color(*INK2) p.multi_cell(160, 4.6, DISCLAIMER) p.ln(4) p.set_font("helvetica", "", 7.6) p.set_text_color(*INK3) p.multi_cell( 160, 4.2, "Mentions : rapport généré automatiquement à partir des données réelles de la " "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique " "de confidentialité et protection des renseignements personnels (Loi 25) : " "groupe-ka.com/conditions · /confidentialite · /loi-25.", ) # ---------- groupes de sections ---------- def _all_series(self, with_stats=True): for s in self.d.get("series") or []: self._line_chart(s, with_stats=with_stats) for ms in self.d.get("multiseries") or []: self._multiline(ms) for st in self.d.get("stacked") or []: self._stacked(st) def _all_breakdowns(self): for b in self.d.get("breakdowns") or []: if b.get("kind") == "donut": self._donut(b) else: self._bars(b.get("title", ""), b.get("items")) for dist in self.d.get("distributions") or []: self._vbars(dist) geo = self.d.get("geo") if geo: self._bars(geo.get("title", "Répartition géographique"), geo.get("items")) self._hourly() def build(self) -> bytes: p = self.pdf p.alias_nb_pages() self._cover() with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE) toc_page_no = None if self.mode == "synthese": p.add_page() self._kpis() self._gauges() self._records() self._final_page() elif self.mode == "tendances": p.add_page() self._kpis() self._section_title("Évolution & tendances") self._all_series(with_stats=True) self._records() self._final_page() elif self.mode == "repartitions": p.add_page() self._section_title("Répartitions, distributions & géographie") self._all_breakdowns() self._final_page() elif self.mode == "donnees": p.add_page() toc_page_no = p.page_no() for t in self.d.get("tables") or []: self._table(t, max_rows=400) self._final_page() elif self.mode == CUSTOM_MODE: p.add_page() toc_page_no = p.page_no() p.add_page() known = {b["key"]: b for b in catalog(self.d)} for blk in self.spec.get("blocks") or []: key = str(blk.get("key", "")) b = known.get(key) if not b: continue render = str(blk.get("render") or "") if render not in b["renders"]: render = b["default_render"] self._render_block(key, render) self._final_page() else: # complet p.add_page() toc_page_no = p.page_no() p.add_page() self._kpis() self._gauges() if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")): self._section_title("Évolution & tendances") self._all_series(with_stats=True) if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")): self._section_title("Répartitions, distributions & géographie") self._all_breakdowns() for t in self.d.get("tables") or []: self._table(t) self._records() self._final_page() # sommaire écrit sur la page réservée if toc_page_no is not None: last_page = p.page p.page = toc_page_no p.set_y(22) p.set_font("helvetica", "B", 15) p.set_text_color(*INK) p.cell(0, 8, "Sommaire") p.ln(12) p.set_font("helvetica", "", 9.5) for title, page_no in self.toc: p.set_text_color(*INK) p.cell(140, 6.5, title[:80]) p.set_text_color(*INK3) p.cell(0, 6.5, str(page_no), align="R") p.ln(6.5) p.page = last_page return bytes(p.output()) def filename(platform_id: str, period: str, mode: str = "complet") -> str: today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d") suffix = "" if mode in ("", "complet") else f"_{mode}" return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"