Étape 20 : planches de badges bénévoles (PDF)

Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).

Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).

Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-20 20:05:03 +02:00
co-authored by Claude Sonnet 5
parent 4bcdafca96
commit b7fc80a7dc
25 changed files with 1800 additions and 3 deletions
+131
View File
@@ -0,0 +1,131 @@
"""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()