Badges : format dérivé de l'image importée, postes avec horaire et référent

- Le format du badge n'est plus fixé à CR80 : il est déduit des pixels de
  l'image du recto à l'import (résolution supposée 300 dpi,
  app/badges/pdf.py:image_size_mm), stocké sur BadgeTemplate (migration
  0012). L'éditeur (BadgesPage.tsx) et la génération PDF s'alignent sur ce
  format réel ; CR80 reste le repère par défaut tant qu'aucune image n'est
  importée. Le verso est ajusté sans déformation (preserveAspectRatio).

- Le champ « Postes affectés » liste désormais une ligne par créneau
  (triées chronologiquement) : nom du poste + horaire, et le référent du
  poste s'il y en a un et que ce n'est pas le bénévole badgé lui-même.
  Texte multi-lignes pris en charge par la génération PDF (découpe sur
  `\n`, hauteur de ligne calculée en mm) et par l'aperçu de l'éditeur.

- Corrige au passage un bug d'unités dans le positionnement vertical du
  texte (police en points confondue avec des millimètres).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-20 20:35:11 +02:00
co-authored by Claude Sonnet 5
parent b7fc80a7dc
commit 579ea5d9db
17 changed files with 534 additions and 87 deletions
+77 -32
View File
@@ -4,8 +4,11 @@ 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
Format du badge : dérivé de l'image de fond du recto (pas de format fixe
imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
`DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la
taille standard des badges/cartes d'événement) servent de repère à
l'éditeur. 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
@@ -21,19 +24,33 @@ 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
DEFAULT_BADGE_WIDTH_MM = 85.6
DEFAULT_BADGE_HEIGHT_MM = 54.0
_MARGIN_MM = 10.0
_GUTTER_MM = 4.0
_LINE_HEIGHT_FACTOR = 1.25
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
# d'impression standard.
ASSUMED_DPI = 300.0
_PAGE_WIDTH_MM = A4[0] / mm
_PAGE_HEIGHT_MM = A4[1] / mm
def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tuple[float, float]:
"""Taille physique (mm) d'une image, en supposant qu'elle a été exportée
à `dpi` — c'est cette taille qui devient celle du badge (voir
`app/api/badges.py:set_front_image`)."""
return (width_px / dpi * 25.4, height_px / dpi * 25.4)
@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)."""
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
possibles — ex. un poste par ligne avec son horaire), positionné depuis
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end)."""
text: str
x_mm: float
@@ -53,64 +70,92 @@ class BadgeSpec:
fields: tuple[BadgeFieldSpec, ...]
def _grid_size() -> tuple[int, int]:
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> 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)))
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]:
def _cell_origin(
index: int, cols: int, badge_width_mm: float, badge_height_mm: float
) -> 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
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:
def _draw_background(
c: canvas.Canvas,
image: bytes | None,
x0_mm: float,
y0_mm: float,
badge_width_mm: float,
badge_height_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,
width=badge_width_mm * mm,
height=badge_height_mm * mm,
# Le format du badge est désormais dérivé de l'image du recto (voir
# `image_size_mm`), donc déjà au bon ratio ; `True` évite en plus
# toute déformation du verso s'il n'a pas exactement le même ratio.
preserveAspectRatio=True,
anchor="c",
mask="auto",
)
def _draw_fields(
c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], x0_mm: float, y0_mm: float
c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...],
x0_mm: float,
y0_mm: float,
badge_height_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.font_size` est en points (unité reportlab) ; le reste du calcul
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
# ici le facteur de conversion pt/mm de reportlab).
font_size_mm = f.font_size / mm
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
# `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)
# dessine depuis le BAS de la page.
top_y = y0_mm + badge_height_mm - f.y_mm
bottom_limit = top_y - f.height_mm
for i, line in enumerate(f.text.split("\n")):
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
if baseline_y < bottom_limit:
break # la ligne déborderait de la boîte du champ
if f.align == "CENTER":
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
elif f.align == "RIGHT":
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
else:
c.drawString(left_x * mm, baseline_y * mm, line)
def generate_badge_sheets_pdf(
badges: list[BadgeSpec],
front_image: bytes | None,
back_image: bytes | None,
badge_width_mm: float = DEFAULT_BADGE_WIDTH_MM,
badge_height_mm: float = DEFAULT_BADGE_HEIGHT_MM,
) -> 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()
cols, rows = _grid_size(badge_width_mm, badge_height_mm)
per_page = cols * rows
pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)]
@@ -118,14 +163,14 @@ def generate_badge_sheets_pdf(
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)
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
_draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.fields, x0, y0, badge_height_mm)
c.showPage()
for i in range(len(page_badges)):
x0, y0 = _cell_origin(i, cols)
_draw_background(c, back_image, x0, y0)
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
c.showPage()
c.save()
return buffer.getvalue()