"""Génération des planches de badges bénévoles (PDF). Module isolé, fonction pure (`generate_badge_sheets_pdf`) : aucun accès base ici — voir `app/api/badges.py` pour l'assemblage des données (bénévole → champs résolus en texte). Format badge : CR80 (85,6 × 54 mm — taille standard des badges/cartes d'événement). Planche : grille sur page A4 portrait. Le verso est identique pour tous les bénévoles : la planche imprime toutes les pages de recto (personnalisées) puis, pour chacune, la page de verso correspondante (même disposition, même image répétée à chaque emplacement rempli) — impression recto-verso classique, page de verso n directement après la page de recto n. """ import io from dataclasses import dataclass from reportlab.lib.colors import HexColor from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas BADGE_WIDTH_MM = 85.6 BADGE_HEIGHT_MM = 54.0 _MARGIN_MM = 10.0 _GUTTER_MM = 4.0 _PAGE_WIDTH_MM = A4[0] / mm _PAGE_HEIGHT_MM = A4[1] / mm @dataclass(frozen=True) class BadgeFieldSpec: """Un champ déjà résolu en texte, positionné depuis le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end).""" text: str x_mm: float y_mm: float width_mm: float height_mm: float font_size: float align: str # "LEFT" | "CENTER" | "RIGHT" bold: bool color: str # "#rrggbb" @dataclass(frozen=True) class BadgeSpec: """Un badge = un bénévole, avec ses champs déjà résolus.""" fields: tuple[BadgeFieldSpec, ...] def _grid_size() -> tuple[int, int]: usable_w = _PAGE_WIDTH_MM - 2 * _MARGIN_MM usable_h = _PAGE_HEIGHT_MM - 2 * _MARGIN_MM cols = max(1, int((usable_w + _GUTTER_MM) // (BADGE_WIDTH_MM + _GUTTER_MM))) rows = max(1, int((usable_h + _GUTTER_MM) // (BADGE_HEIGHT_MM + _GUTTER_MM))) return cols, rows def _cell_origin(index: int, cols: int) -> tuple[float, float]: """Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page, en mm depuis le coin bas-gauche de la page.""" col, row = index % cols, index // cols x0 = _MARGIN_MM + col * (BADGE_WIDTH_MM + _GUTTER_MM) y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * BADGE_HEIGHT_MM - row * _GUTTER_MM return x0, y0 def _draw_background(c: canvas.Canvas, image: bytes | None, x0_mm: float, y0_mm: float) -> None: if not image: return c.drawImage( ImageReader(io.BytesIO(image)), x0_mm * mm, y0_mm * mm, width=BADGE_WIDTH_MM * mm, height=BADGE_HEIGHT_MM * mm, preserveAspectRatio=False, mask="auto", ) def _draw_fields( c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], x0_mm: float, y0_mm: float ) -> None: for f in fields: c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size) c.setFillColor(HexColor(f.color)) left_x = x0_mm + f.x_mm # `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab # dessine depuis le BAS de la page. On approxime un alignement en # haut de la boîte plutôt qu'un centrage vertical exact. baseline_y = y0_mm + BADGE_HEIGHT_MM - f.y_mm - f.font_size * 0.8 if f.align == "CENTER": c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, f.text) elif f.align == "RIGHT": c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, f.text) else: c.drawString(left_x * mm, baseline_y * mm, f.text) def generate_badge_sheets_pdf( badges: list[BadgeSpec], front_image: bytes | None, back_image: bytes | None, ) -> bytes: """`badges` : un par bénévole, dans l'ordre d'impression. Retourne les octets du PDF (recto puis verso, planche par planche).""" cols, rows = _grid_size() per_page = cols * rows pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)] buffer = io.BytesIO() c = canvas.Canvas(buffer, pagesize=A4) for page_badges in pages: for i, badge in enumerate(page_badges): x0, y0 = _cell_origin(i, cols) _draw_background(c, front_image, x0, y0) _draw_fields(c, badge.fields, x0, y0) c.showPage() for i in range(len(page_badges)): x0, y0 = _cell_origin(i, cols) _draw_background(c, back_image, x0, y0) c.showPage() c.save() return buffer.getvalue()