SPB Git

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

20.9 KB · 564 lines python
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.has_cover = False79        self.set_margins(18, 20, 18)80        self.set_auto_page_break(True, margin=22)8182    def header(self):83        if self.cover_mode or (self.has_cover and self.page_no() == 1):84            return85        self.set_font("helvetica", "B", 8.5)86        self.set_text_color(*INK)87        self.set_xy(18, 9)88        self.cell(0, 5, f"Groupe KA · {self.brand}")89        self.set_font("helvetica", "", 8)90        self.set_text_color(*INK3)91        self.set_xy(18, 9)92        self.cell(0, 5, "Rapport statistique", align="R")93        self.set_draw_color(*INK)94        self.set_line_width(0.5)95        self.line(18, 15.5, 192, 15.5)96        self.set_y(20)9798    def footer(self):99        # la couverture (page 1) ne porte jamais le pied de page standard :100        # son footer se déclenche au add_page suivant, quand cover_mode est101        # déjà retombé — on la repère donc par son numéro de page.102        if self.cover_mode or (self.has_cover and self.page_no() == 1):103            return104        self.set_y(-15)105        self.set_draw_color(*INK3)106        self.set_line_width(0.2)107        self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)108        self.set_font("helvetica", "", 7.5)109        self.set_text_color(*INK3)110        year = datetime.now(ZoneInfo("America/Toronto")).year111        self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")112        self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")113114115class GroupeKAReport:116    def __init__(self, site: dict, dashboard: dict, mode: str = "complet"):117        self.site = site118        self.d = dashboard119        self.mode = mode120        self.accent = _hex(site.get("accent", "#d9f26b"))121        period = dashboard.get("period", {}) or {}122        self.period_label = period.get("label") or "toute la période"123        self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)124        self.toc: list[tuple[str, int]] = []125126    # ---------- primitives ----------127    def _card(self, x, y, w, h, fill=WHITE):128        p = self.pdf129        p.set_draw_color(*INK)130        p.set_line_width(0.45)131        p.set_fill_color(*fill)132        p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)133134    def _kicker(self, text):135        p = self.pdf136        p.set_font("helvetica", "B", 8)137        p.set_text_color(*GREEN)138        p.set_draw_color(*GREEN)139        p.set_line_width(0.6)140        y = p.get_y() + 2141        p.line(p.l_margin, y, p.l_margin + 7, y)142        p.set_xy(p.l_margin + 9, y - 2.5)143        p.cell(0, 5, text.upper())144        p.ln(8)145146    def _section_title(self, title):147        if self.pdf.get_y() > 240:148            self.pdf.add_page()149        self._kicker("Groupe KA · " + self.site.get("wordmark", ""))150        self.pdf.set_font("helvetica", "B", 15)151        self.pdf.set_text_color(*INK)152        self.pdf.set_x(self.pdf.l_margin)153        self.pdf.cell(0, 8, title)154        self.toc.append((title, self.pdf.page_no()))155        self.pdf.ln(11)156157    # ---------- pages ----------158    def _cover(self):159        p = self.pdf160        p.cover_mode = True161        p.has_cover = True162        p.set_auto_page_break(False)163        p.add_page()164        p.set_fill_color(*PAPER)165        p.rect(0, 0, 210, 297, style="F")166        p.set_draw_color(*INK)167        p.set_line_width(1.0)168        p.rect(10, 10, 190, 277)169        # kicker170        p.set_font("helvetica", "B", 10)171        p.set_text_color(*GREEN)172        p.set_xy(24, 34)173        p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")174        # wordmark : partie gauche + boîte encre/accent175        wm = self.site.get("wordmark", "")176        left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)177        p.set_xy(24, 70)178        p.set_font("helvetica", "B", 40)179        p.set_text_color(*INK)180        p.cell(p.get_string_width(left) + 2, 20, left)181        if boxed:182            bw = p.get_string_width(boxed) + 12183            x = p.get_x() + 2184            p.set_fill_color(*INK)185            p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)186            p.set_text_color(*self.accent)187            p.set_xy(x + 6, 70)188            p.cell(bw - 12, 18, boxed)189        p.set_xy(24, 100)190        p.set_font("helvetica", "", 13)191        p.set_text_color(*INK2)192        p.multi_cell(150, 7, f"Rapport statistique — {wm}")193        now = datetime.now(ZoneInfo("America/Toronto"))194        per = self.d.get("period", {}) or {}195        p.set_xy(24, 125)196        p.set_font("helvetica", "", 10.5)197        rows = [198            ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),199            ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),200            ("Plateforme", "https://" + self.site.get("domain", "")),201            ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"),202        ]203        y = 128204        for k, v in rows:205            p.set_xy(24, y)206            p.set_text_color(*INK3)207            p.cell(40, 6, k)208            p.set_text_color(*INK)209            p.set_font("helvetica", "B", 10.5)210            p.cell(0, 6, str(v))211            p.set_font("helvetica", "", 10.5)212            y += 8213        # bande encre au pied214        p.set_fill_color(*INK)215        p.rect(10, 262, 190, 25, style="F")216        p.set_xy(24, 270)217        p.set_font("helvetica", "B", 12)218        p.set_text_color(*WHITE)219        p.cell(60, 8, "par Groupe ")220        p.set_text_color(*self.accent)221        p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)222        p.cell(20, 8, "KA")223        p.set_font("helvetica", "B", 10)224        p.set_xy(24, 270)225        p.set_text_color(*self.accent)226        p.cell(162, 8, "groupe-ka.com", align="R")227        p.set_auto_page_break(True, margin=22)228        p.cover_mode = False229230    def _kpis(self):231        kpis = self.d.get("kpis") or []232        if not kpis:233            return234        self._section_title("Synthèse des indicateurs")235        p = self.pdf236        cols, gw, gh, gap = 3, 56, 26, 3237        x0, y = p.l_margin, p.get_y()238        for i, k in enumerate(kpis[:9]):239            x = x0 + (i % cols) * (gw + gap)240            if i and i % cols == 0:241                y += gh + gap242            if y > 250:243                p.add_page(); y = p.get_y()244            self._card(x, y, gw, gh)245            p.set_xy(x + 4, y + 4)246            p.set_font("helvetica", "B", 14)247            p.set_text_color(*INK)248            val = k.get("value")249            p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))250            p.set_xy(x + 4, y + 12)251            p.set_font("helvetica", "", 7.6)252            p.set_text_color(*INK2)253            p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])254            if k.get("delta_pct") is not None:255                up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"256                p.set_xy(x + 4, y + gh - 6.5)257                p.set_font("helvetica", "B", 8)258                p.set_text_color(*(GREEN if up else DANGER))259                arrow = "+" if k["delta_pct"] >= 0 else ""260                p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.")261        p.set_y(y + gh + 8)262263    def _line_chart(self, s):264        p = self.pdf265        pts = s.get("points") or []266        if len(pts) < 2:267            return268        if p.get_y() > 200:269            p.add_page()270        p.set_font("helvetica", "B", 10)271        p.set_text_color(*INK)272        p.cell(0, 6, s.get("title", ""))273        p.ln(7)274        x0, y0, w, h = p.l_margin, p.get_y(), 174, 52275        self._card(x0, y0, w, h, fill=WHITE)276        cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16277        vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]278        vmax = max(vals) or 1279        vmin = min(0, min(vals))280        rng = (vmax - vmin) or 1281        # grille + graduations282        p.set_font("helvetica", "", 6.3)283        p.set_text_color(*INK3)284        p.set_draw_color(200, 200, 195)285        p.set_line_width(0.15)286        for g in range(5):287            gy = cy + ch - ch * g / 4288            p.line(cx, gy, cx + cw, gy)289            p.set_xy(x0 + 1, gy - 1.6)290            p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")291292        def draw(series, color, width, dash=None):293            n = len(series)294            p.set_draw_color(*color)295            p.set_line_width(width)296            if dash:297                p.set_dash_pattern(dash=1.2, gap=1.2)298            last = None299            for i, pt in enumerate(series):300                px = cx + cw * (i / (n - 1))301                py = cy + ch - ch * ((pt["v"] - vmin) / rng)302                if last:303                    p.line(last[0], last[1], px, py)304                last = (px, py)305            p.set_dash_pattern()306307        if s.get("compare"):308            draw(s["compare"], INK3, 0.35, dash=True)309        draw(pts, self.accent, 0.7)310        # libellés d'axe X (premier / milieu / dernier)311        p.set_text_color(*INK3)312        for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):313            p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)314            p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")315        p.set_y(y0 + h + 4)316        if s.get("compare"):317            p.set_font("helvetica", "", 6.8)318            p.set_text_color(*INK3)319            p.cell(0, 4, "— période courante (accent) · ---- période comparée")320            p.ln(6)321        else:322            p.ln(2)323324    def _bars(self, title, items, unit=""):325        p = self.pdf326        items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]327        if not items:328            return329        need = 10 + len(items) * 7330        if p.get_y() + need > 265:331            p.add_page()332        p.set_font("helvetica", "B", 10)333        p.set_text_color(*INK)334        p.cell(0, 6, title)335        p.ln(8)336        vmax = max(it["value"] for it in items) or 1337        for it in items:338            y = p.get_y()339            p.set_font("helvetica", "", 7.6)340            p.set_text_color(*INK)341            p.set_x(p.l_margin)342            p.cell(46, 5, str(it["label"])[:34])343            bw = 96 * (it["value"] / vmax)344            p.set_fill_color(*self.accent)345            p.set_draw_color(*INK)346            p.set_line_width(0.25)347            p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")348            p.set_xy(p.l_margin + 148, y)349            p.set_font("helvetica", "B", 7.6)350            p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")351            p.ln(6.4)352        p.ln(3)353354    def _donut(self, b):355        # anneau vectoriel simple (arcs) + légende356        p = self.pdf357        items = [it for it in (b.get("items") or []) if it.get("value")][:8]358        total = sum(it["value"] for it in items)359        if not items or not total:360            return361        if p.get_y() > 210:362            p.add_page()363        p.set_font("helvetica", "B", 10)364        p.set_text_color(*INK)365        p.cell(0, 6, b.get("title", ""))366        p.ln(8)367        cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20368        shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]369        start = -90.0370        for i, it in enumerate(items):371            frac = it["value"] / total372            f = shades[i % len(shades)]373            col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))374            steps = max(2, int(72 * frac))375            p.set_fill_color(*col)376            p.set_draw_color(*col)377            for st in range(steps):378                a0 = math.radians(start + 360 * frac * st / steps)379                a1 = math.radians(start + 360 * frac * (st + 1) / steps)380                p.polygon(381                    [(cx, cy),382                     (cx + r * math.cos(a0), cy + r * math.sin(a0)),383                     (cx + r * math.cos(a1), cy + r * math.sin(a1))],384                    style="DF",385                )386            start += 360 * frac387        p.set_fill_color(*WHITE)388        p.set_draw_color(*INK)389        p.set_line_width(0.4)390        p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")391        p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")392        # légende393        ly = cy - 22394        for i, it in enumerate(items):395            f = shades[i % len(shades)]396            col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))397            p.set_fill_color(*col)398            p.set_draw_color(*INK)399            p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")400            p.set_xy(p.l_margin + 66, ly)401            p.set_font("helvetica", "", 7.6)402            p.set_text_color(*INK)403            pct = 100 * it["value"] / total404            p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))405            ly += 5.6406        p.set_y(max(cy + r, ly) + 6)407408    def _table(self, t):409        p = self.pdf410        cols = t.get("columns") or []411        rows = t.get("rows") or []412        if not cols or not rows:413            return414        self._section_title(t.get("title", "Tableau"))415        w = 174 / len(cols)416        def head():417            p.set_font("helvetica", "B", 7.6)418            p.set_fill_color(*INK)419            p.set_text_color(*WHITE)420            for c in cols:421                p.cell(w, 6, " " + str(c)[:30], fill=True)422            p.ln(6)423        head()424        p.set_text_color(*INK)425        for i, row in enumerate(rows[:200]):426            if p.get_y() > 262:427                p.add_page()428                head()429                p.set_text_color(*INK)430            p.set_font("helvetica", "", 7.4)431            p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))432            for cell in row:433                txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)434                p.cell(w, 5.4, " " + txt[:34], fill=True)435            p.ln(5.4)436        if len(rows) > 200:437            p.set_font("helvetica", "", 7)438            p.set_text_color(*INK3)439            p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées")440        p.ln(6)441442    def _records(self):443        recs = self.d.get("records") or []444        if not recs:445            return446        self._section_title("Records & faits marquants")447        p = self.pdf448        for r in recs[:10]:449            if p.get_y() > 258:450                p.add_page()451            y = p.get_y()452            self._card(p.l_margin, y, 174, 11, fill=SURFACE2)453            p.set_xy(p.l_margin + 4, y + 2)454            p.set_font("helvetica", "", 8.6)455            p.set_text_color(*INK2)456            p.cell(96, 7, str(r.get("label", ""))[:70])457            p.set_font("helvetica", "B", 9)458            p.set_text_color(*INK)459            p.cell(52, 7, str(r.get("value", ""))[:36], align="R")460            p.set_font("helvetica", "", 7.6)461            p.set_text_color(*INK3)462            p.cell(20, 7, str(r.get("date", "") or ""), align="R")463            p.set_y(y + 13.5)464        p.ln(4)465466    def _final_page(self):467        p = self.pdf468        p.add_page()469        self._kicker("Groupe KA · contact")470        p.set_font("helvetica", "B", 15)471        p.set_text_color(*INK)472        p.cell(0, 8, "Coordonnées du Groupe KA")473        p.ln(12)474        for email, role in EMAILS:475            p.set_font("helvetica", "B", 10.5)476            p.set_text_color(*INK)477            p.cell(0, 6, email)478            p.ln(5.5)479            p.set_font("helvetica", "", 8.6)480            p.set_text_color(*INK3)481            p.cell(0, 5, role)482            p.ln(8)483        p.ln(2)484        p.set_font("helvetica", "B", 10)485        p.set_text_color(*GREEN)486        p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")487        p.ln(10)488        p.set_draw_color(*self.accent)489        p.set_line_width(0.8)490        p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())491        p.ln(4)492        p.set_font("helvetica", "", 8.6)493        p.set_text_color(*INK2)494        p.multi_cell(160, 4.6, DISCLAIMER)495        p.ln(4)496        p.set_font("helvetica", "", 7.6)497        p.set_text_color(*INK3)498        p.multi_cell(499            160, 4.2,500            "Mentions : rapport généré automatiquement à partir des données réelles de la "501            "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "502            "de confidentialité et protection des renseignements personnels (Loi 25) : "503            "groupe-ka.com/conditions · /confidentialite · /loi-25.",504        )505506    def _toc_page(self):507        # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en508        # page 2 en réservant la page lors du build (voir build()).509        pass510511    def build(self) -> bytes:512        p = self.pdf513        p.alias_nb_pages()514        self._cover()515        if self.mode == "synthese":516            p.add_page()517            self._kpis()518            self._records()519            self._final_page()520        else:521            p.add_page()522            toc_page_no = p.page_no()523            p.add_page()524            self._kpis()525            for s in self.d.get("series") or []:526                if s.get("kind") == "bar":527                    self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", ""))528                else:529                    self._line_chart(s)530            for b in self.d.get("breakdowns") or []:531                if b.get("kind") == "donut":532                    self._donut(b)533                else:534                    self._bars(b.get("title", ""), b.get("items"))535            geo = self.d.get("geo")536            if geo:537                self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))538            for t in self.d.get("tables") or []:539                self._table(t)540            self._records()541            self._final_page()542            # sommaire écrit sur la page réservée (page 2)543            last_page = p.page544            p.page = toc_page_no545            p.set_y(22)546            p.set_font("helvetica", "B", 15)547            p.set_text_color(*INK)548            p.cell(0, 8, "Sommaire")549            p.ln(12)550            p.set_font("helvetica", "", 9.5)551            for title, page_no in self.toc:552                p.set_text_color(*INK)553                p.cell(140, 6.5, title[:80])554                p.set_text_color(*INK3)555                p.cell(0, 6.5, str(page_no), align="R")556                p.ln(6.5)557            p.page = last_page558        return bytes(p.output())559560561def filename(platform_id: str, period: str) -> str:562    today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")563    return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf"564
Files · a9e4fb2835c3690d653a86a22e34edd4f603f094
Menu

Theme