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>
132 lines
4.4 KiB
Python
132 lines
4.4 KiB
Python
"""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()
|