HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1# Auteur : Simon-Pierre Boucher — contact@spboucher.ai2# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2).3# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit4# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques5# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin.6# Usage :7# from kapdf import GroupeKAReport8# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",9# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,10# mode="complet").build()11# Dépendance : pip install fpdf2 (aucune autre)12from __future__ import annotations1314import math15from datetime import datetime16from zoneinfo import ZoneInfo1718from fpdf import FPDF1920INK = (20, 24, 20)21INK2 = (77, 85, 81)22INK3 = (139, 146, 140)23PAPER = (245, 243, 238)24SURFACE2 = (250, 249, 245)25GREEN = (28, 92, 65)26DANGER = (179, 66, 58)27WHITE = (255, 255, 255)2829EMAILS = [30 ("contact@groupe-ka.com", "Projets, partenariats & données"),31 ("info@groupe-ka.com", "Médias & questions générales"),32 ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),33]34DISCLAIMER = (35 "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "36 "rien et ne sommes partie à aucune transaction. Données lues à la source, "37 "rien d'inventé, tout est traçable."38)394041def _hex(c: str) -> tuple[int, int, int]:42 c = c.lstrip("#")43 return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore444546def _fr(n) -> str:47 if isinstance(n, float) and not n.is_integer():48 return f"{n:,.2f}".replace(",", " ").replace(".", ",")49 return f"{int(n):,}".replace(",", " ")505152_SUBST = {53 "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",54 "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',55 "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",56}575859def _latin1(s: str) -> str:60 for k, v in _SUBST.items():61 s = s.replace(k, v)62 return s.encode("latin-1", "replace").decode("latin-1")636465class _PDF(FPDF):66 """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).67 Les polices core sont latin-1 : normalize_text sanitise en amont."""6869 def normalize_text(self, text):70 return super().normalize_text(_latin1(text))7172 def __init__(self, brand: str, accent: tuple, period_label: str):73 super().__init__(orientation="P", unit="mm", format="A4")74 self.brand = brand75 self.accent = accent76 self.period_label = period_label77 self.cover_mode = False78 self.set_margins(18, 20, 18)79 self.set_auto_page_break(True, margin=22)8081 def header(self):82 if self.cover_mode or self.page_no() == 1:83 return84 self.set_font("helvetica", "B", 8.5)85 self.set_text_color(*INK)86 self.set_xy(18, 9)87 self.cell(0, 5, f"Groupe KA · {self.brand}")88 self.set_font("helvetica", "", 8)89 self.set_text_color(*INK3)90 self.set_xy(18, 9)91 self.cell(0, 5, "Rapport statistique", align="R")92 self.set_draw_color(*INK)93 self.set_line_width(0.5)94 self.line(18, 15.5, 192, 15.5)95 self.set_y(20)9697 def footer(self):98 # page 1 = couverture (le flag cover_mode est déjà retombé quand99 # add_page() clôt la page 1 → tester aussi le numéro de page)100 if self.cover_mode or self.page_no() == 1:101 return102 self.set_y(-15)103 self.set_draw_color(*INK3)104 self.set_line_width(0.2)105 self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)106 self.set_font("helvetica", "", 7.5)107 self.set_text_color(*INK3)108 year = datetime.now(ZoneInfo("America/Toronto")).year109 self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")110 self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")111112113class GroupeKAReport:114 def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):115 self.site = site116 self.d = dashboard117 self.mode = mode118 self.accent = _hex(site.get("accent", "#d9f26b"))119 period = dashboard.get("period", {}) or {}120 self.period_label = period.get("label") or "toute la période"121 self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)122 self.toc: list[tuple[str, int]] = []123124 # ---------- primitives ----------125 def _card(self, x, y, w, h, fill=WHITE):126 p = self.pdf127 p.set_draw_color(*INK)128 p.set_line_width(0.45)129 p.set_fill_color(*fill)130 p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)131132 def _kicker(self, text):133 p = self.pdf134 p.set_font("helvetica", "B", 8)135 p.set_text_color(*GREEN)136 p.set_draw_color(*GREEN)137 p.set_line_width(0.6)138 y = p.get_y() + 2139 p.line(p.l_margin, y, p.l_margin + 7, y)140 p.set_xy(p.l_margin + 9, y - 2.5)141 p.cell(0, 5, text.upper())142 p.ln(8)143144 def _section_title(self, title):145 if self.pdf.get_y() > 240:146 self.pdf.add_page()147 self._kicker("Groupe KA · " + self.site.get("wordmark", ""))148 self.pdf.set_font("helvetica", "B", 15)149 self.pdf.set_text_color(*INK)150 self.pdf.set_x(self.pdf.l_margin)151 self.pdf.cell(0, 8, title)152 self.toc.append((title, self.pdf.page_no()))153 self.pdf.ln(11)154155 # ---------- pages ----------156 def _cover(self):157 p = self.pdf158 p.cover_mode = True159 p.set_auto_page_break(False)160 p.add_page()161 p.set_fill_color(*PAPER)162 p.rect(0, 0, 210, 297, style="F")163 p.set_draw_color(*INK)164 p.set_line_width(1.0)165 p.rect(10, 10, 190, 277)166 # kicker167 p.set_font("helvetica", "B", 10)168 p.set_text_color(*GREEN)169 p.set_xy(24, 34)170 p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")171 # wordmark : partie gauche + boîte encre/accent172 wm = self.site.get("wordmark", "")173 left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)174 p.set_xy(24, 70)175 p.set_font("helvetica", "B", 40)176 p.set_text_color(*INK)177 p.cell(p.get_string_width(left) + 2, 20, left)178 if boxed:179 bw = p.get_string_width(boxed) + 12180 x = p.get_x() + 2181 p.set_fill_color(*INK)182 p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)183 p.set_text_color(*self.accent)184 p.set_xy(x + 6, 70)185 p.cell(bw - 12, 18, boxed)186 p.set_xy(24, 100)187 p.set_font("helvetica", "", 13)188 p.set_text_color(*INK2)189 p.multi_cell(150, 7, f"Rapport statistique — {wm}")190 now = datetime.now(ZoneInfo("America/Toronto"))191 per = self.d.get("period", {}) or {}192 p.set_xy(24, 125)193 p.set_font("helvetica", "", 10.5)194 rows = [195 ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),196 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),197 ("Plateforme", "https://" + self.site.get("domain", "")),198 ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),199 ]200 y = 128201 for k, v in rows:202 p.set_xy(24, y)203 p.set_text_color(*INK3)204 p.cell(40, 6, k)205 p.set_text_color(*INK)206 p.set_font("helvetica", "B", 10.5)207 p.cell(0, 6, str(v))208 p.set_font("helvetica", "", 10.5)209 y += 8210 # bande encre au pied211 p.set_fill_color(*INK)212 p.rect(10, 262, 190, 25, style="F")213 p.set_xy(24, 270)214 p.set_font("helvetica", "B", 12)215 p.set_text_color(*WHITE)216 p.cell(60, 8, "par Groupe ")217 p.set_text_color(*self.accent)218 p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)219 p.cell(20, 8, "KA")220 p.set_font("helvetica", "B", 10)221 p.set_xy(24, 270)222 p.set_text_color(*self.accent)223 p.cell(162, 8, "groupe-ka.com", align="R")224 p.set_auto_page_break(True, margin=22)225 p.cover_mode = False226227 def _kpis(self):228 kpis = self.d.get("kpis") or []229 if not kpis:230 return231 self._section_title("Synthèse des indicateurs")232 p = self.pdf233 cols, gw, gh, gap = 3, 56, 26, 3234 x0, y = p.l_margin, p.get_y()235 for i, k in enumerate(kpis[:9]):236 x = x0 + (i % cols) * (gw + gap)237 if i and i % cols == 0:238 y += gh + gap239 if y > 250:240 p.add_page(); y = p.get_y()241 self._card(x, y, gw, gh)242 p.set_xy(x + 4, y + 4)243 p.set_font("helvetica", "B", 14)244 p.set_text_color(*INK)245 val = k.get("value")246 p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))247 p.set_xy(x + 4, y + 12)248 p.set_font("helvetica", "", 7.6)249 p.set_text_color(*INK2)250 p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])251 if k.get("delta_pct") is not None:252 up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"253 p.set_xy(x + 4, y + gh - 6.5)254 p.set_font("helvetica", "B", 8)255 p.set_text_color(*(GREEN if up else DANGER))256 arrow = "+" if k["delta_pct"] >= 0 else ""257 p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")258 p.set_y(y + gh + 8)259260 def _line_chart(self, s):261 p = self.pdf262 pts = s.get("points") or []263 if len(pts) < 2:264 return265 if p.get_y() > 200:266 p.add_page()267 p.set_font("helvetica", "B", 10)268 p.set_text_color(*INK)269 p.cell(0, 6, s.get("title", ""))270 p.ln(7)271 x0, y0, w, h = p.l_margin, p.get_y(), 174, 52272 self._card(x0, y0, w, h, fill=WHITE)273 cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16274 vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]275 vmax = max(vals) or 1276 vmin = min(0, min(vals))277 rng = (vmax - vmin) or 1278 # grille + graduations279 p.set_font("helvetica", "", 6.3)280 p.set_text_color(*INK3)281 p.set_draw_color(200, 200, 195)282 p.set_line_width(0.15)283 for g in range(5):284 gy = cy + ch - ch * g / 4285 p.line(cx, gy, cx + cw, gy)286 p.set_xy(x0 + 1, gy - 1.6)287 p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")288289 def draw(series, color, width, dash=None):290 n = len(series)291 p.set_draw_color(*color)292 p.set_line_width(width)293 if dash:294 p.set_dash_pattern(dash=1.2, gap=1.2)295 last = None296 for i, pt in enumerate(series):297 px = cx + cw * (i / (n - 1))298 py = cy + ch - ch * ((pt["v"] - vmin) / rng)299 if last:300 p.line(last[0], last[1], px, py)301 last = (px, py)302 p.set_dash_pattern()303304 if s.get("compare"):305 draw(s["compare"], INK3, 0.35, dash=True)306 draw(pts, self.accent, 0.7)307 # libellés d'axe X (premier / milieu / dernier)308 p.set_text_color(*INK3)309 for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):310 p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)311 p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")312 p.set_y(y0 + h + 4)313 if s.get("compare"):314 p.set_font("helvetica", "", 6.8)315 p.set_text_color(*INK3)316 p.cell(0, 4, "— période courante (accent) · ---- période comparée")317 p.ln(6)318 else:319 p.ln(2)320321 def _bars(self, title, items, unit=""):322 p = self.pdf323 items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]324 if not items:325 return326 need = 10 + len(items) * 7327 if p.get_y() + need > 265:328 p.add_page()329 p.set_font("helvetica", "B", 10)330 p.set_text_color(*INK)331 p.cell(0, 6, title)332 p.ln(8)333 vmax = max(it["value"] for it in items) or 1334 for it in items:335 y = p.get_y()336 p.set_font("helvetica", "", 7.6)337 p.set_text_color(*INK)338 p.set_x(p.l_margin)339 p.cell(46, 5, str(it["label"])[:34])340 bw = 96 * (it["value"] / vmax)341 p.set_fill_color(*self.accent)342 p.set_draw_color(*INK)343 p.set_line_width(0.25)344 p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")345 p.set_xy(p.l_margin + 148, y)346 p.set_font("helvetica", "B", 7.6)347 p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")348 p.ln(6.4)349 p.ln(3)350351 def _donut(self, b):352 # anneau vectoriel simple (arcs) + légende353 p = self.pdf354 items = [it for it in (b.get("items") or []) if it.get("value")][:8]355 total = sum(it["value"] for it in items)356 if not items or not total:357 return358 if p.get_y() > 210:359 p.add_page()360 p.set_font("helvetica", "B", 10)361 p.set_text_color(*INK)362 p.cell(0, 6, b.get("title", ""))363 p.ln(8)364 cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20365 shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]366 start = -90.0367 for i, it in enumerate(items):368 frac = it["value"] / total369 f = shades[i % len(shades)]370 col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))371 steps = max(2, int(72 * frac))372 p.set_fill_color(*col)373 p.set_draw_color(*col)374 for st in range(steps):375 a0 = math.radians(start + 360 * frac * st / steps)376 a1 = math.radians(start + 360 * frac * (st + 1) / steps)377 p.polygon(378 [(cx, cy),379 (cx + r * math.cos(a0), cy + r * math.sin(a0)),380 (cx + r * math.cos(a1), cy + r * math.sin(a1))],381 style="DF",382 )383 start += 360 * frac384 p.set_fill_color(*WHITE)385 p.set_draw_color(*INK)386 p.set_line_width(0.4)387 p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")388 p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")389 # légende390 ly = cy - 22391 for i, it in enumerate(items):392 f = shades[i % len(shades)]393 col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))394 p.set_fill_color(*col)395 p.set_draw_color(*INK)396 p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")397 p.set_xy(p.l_margin + 66, ly)398 p.set_font("helvetica", "", 7.6)399 p.set_text_color(*INK)400 pct = 100 * it["value"] / total401 p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))402 ly += 5.6403 p.set_y(max(cy + r, ly) + 6)404405 def _table(self, t):406 p = self.pdf407 cols = t.get("columns") or []408 rows = t.get("rows") or []409 if not cols or not rows:410 return411 self._section_title(t.get("title", "Tableau"))412 w = 174 / len(cols)413 def head():414 p.set_font("helvetica", "B", 7.6)415 p.set_fill_color(*INK)416 p.set_text_color(*WHITE)417 for c in cols:418 p.cell(w, 6, " " + str(c)[:30], fill=True)419 p.ln(6)420 head()421 p.set_text_color(*INK)422 for i, row in enumerate(rows[:200]):423 if p.get_y() > 262:424 p.add_page()425 head()426 p.set_text_color(*INK)427 p.set_font("helvetica", "", 7.4)428 p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))429 for cell in row:430 txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)431 p.cell(w, 5.4, " " + txt[:34], fill=True)432 p.ln(5.4)433 if len(rows) > 200:434 p.set_font("helvetica", "", 7)435 p.set_text_color(*INK3)436 p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")437 p.ln(6)438439 def _records(self):440 recs = self.d.get("records") or []441 if not recs:442 return443 self._section_title("Records & faits marquants")444 p = self.pdf445 for r in recs[:10]:446 if p.get_y() > 258:447 p.add_page()448 y = p.get_y()449 self._card(p.l_margin, y, 174, 11, fill=SURFACE2)450 p.set_xy(p.l_margin + 4, y + 2)451 p.set_font("helvetica", "", 8.6)452 p.set_text_color(*INK2)453 p.cell(96, 7, str(r.get("label", ""))[:70])454 p.set_font("helvetica", "B", 9)455 p.set_text_color(*INK)456 p.cell(52, 7, str(r.get("value", ""))[:36], align="R")457 p.set_font("helvetica", "", 7.6)458 p.set_text_color(*INK3)459 p.cell(20, 7, str(r.get("date", "") or ""), align="R")460 p.set_y(y + 13.5)461 p.ln(4)462463 def _final_page(self):464 p = self.pdf465 p.add_page()466 self._kicker("Groupe KA · contact")467 p.set_font("helvetica", "B", 15)468 p.set_text_color(*INK)469 p.cell(0, 8, "Coordonnées du Groupe KA")470 p.ln(12)471 for email, role in EMAILS:472 p.set_font("helvetica", "B", 10.5)473 p.set_text_color(*INK)474 p.cell(0, 6, email)475 p.ln(5.5)476 p.set_font("helvetica", "", 8.6)477 p.set_text_color(*INK3)478 p.cell(0, 5, role)479 p.ln(8)480 p.ln(2)481 p.set_font("helvetica", "B", 10)482 p.set_text_color(*GREEN)483 p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")484 p.ln(10)485 p.set_draw_color(*self.accent)486 p.set_line_width(0.8)487 p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())488 p.ln(4)489 p.set_font("helvetica", "", 8.6)490 p.set_text_color(*INK2)491 p.multi_cell(160, 4.6, DISCLAIMER)492 p.ln(4)493 p.set_font("helvetica", "", 7.6)494 p.set_text_color(*INK3)495 p.multi_cell(496 160, 4.2,497 "Mentions : rapport généré automatiquement à partir des données réelles de la "498 "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "499 "de confidentialité et protection des renseignements personnels (Loi 25) : "500 "groupe-ka.com/conditions · /confidentialite · /loi-25.",501 )502503 def _toc_page(self):504 # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en505 # page 2 en réservant la page lors du build (voir build()).506 pass507508 def build(self) -> bytes:509 p = self.pdf510 p.alias_nb_pages()511 self._cover()512 if self.mode == "synthese":513 p.add_page()514 self._kpis()515 self._records()516 self._final_page()517 else:518 p.add_page()519 toc_page_no = p.page_no()520 p.add_page()521 self._kpis()522 for s in self.d.get("series") or []:523 if s.get("kind") == "bar":524 self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))525 else:526 self._line_chart(s)527 for b in self.d.get("breakdowns") or []:528 if b.get("kind") == "donut":529 self._donut(b)530 else:531 self._bars(b.get("title", ""), b.get("items"))532 geo = self.d.get("geo")533 if geo:534 self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))535 for t in self.d.get("tables") or []:536 self._table(t)537 self._records()538 self._final_page()539 # sommaire écrit sur la page réservée (page 2)540 last_page = p.page541 p.page = toc_page_no542 p.set_y(22)543 p.set_font("helvetica", "B", 15)544 p.set_text_color(*INK)545 p.cell(0, 8, "Sommaire")546 p.ln(12)547 p.set_font("helvetica", "", 9.5)548 for title, page_no in self.toc:549 p.set_text_color(*INK)550 p.cell(140, 6.5, title[:80])551 p.set_text_color(*INK3)552 p.cell(0, 6.5, str(page_no), align="R")553 p.ln(6.5)554 p.page = last_page555 return bytes(p.output())556557558def filename(platform_id: str, period: str) -> str:559 today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")560 return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"561