Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024), repris dans l'export Excel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
8.6 KiB
Python
228 lines
8.6 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 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
|
||
recto-verso classique, page de verso n directement après la page de recto n.
|
||
"""
|
||
|
||
import io
|
||
from dataclasses import dataclass
|
||
|
||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||
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
|
||
|
||
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 (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).
|
||
|
||
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
|
||
bruts tels que stockés) plutôt qu'un texte — `text` et le style sont
|
||
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné."""
|
||
|
||
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"
|
||
is_photo: bool = False
|
||
image: bytes | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class BadgeSpec:
|
||
"""Un badge = un bénévole, avec ses champs déjà résolus."""
|
||
|
||
fields: tuple[BadgeFieldSpec, ...]
|
||
|
||
|
||
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)))
|
||
return cols, rows
|
||
|
||
|
||
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
|
||
return x0, y0
|
||
|
||
|
||
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,
|
||
# 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 _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
|
||
"""Recadre la photo au ratio du cadre (centrée, sans déformation — une
|
||
photo de téléphone n'a jamais le ratio du cadre) et la réduit à la
|
||
résolution d'impression : une photo de plusieurs Mo répétée sur 200
|
||
badges alourdirait inutilement le PDF. Applique l'orientation EXIF (une
|
||
photo prise en portrait au téléphone est souvent stockée couchée).
|
||
`None` si l'image est illisible : le badge s'imprime sans photo plutôt
|
||
que de faire échouer toute la planche."""
|
||
target = (
|
||
max(1, round(width_mm / 25.4 * ASSUMED_DPI)),
|
||
max(1, round(height_mm / 25.4 * ASSUMED_DPI)),
|
||
)
|
||
try:
|
||
with Image.open(io.BytesIO(image)) as img:
|
||
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
|
||
except (UnidentifiedImageError, OSError):
|
||
return None
|
||
out = io.BytesIO()
|
||
fitted.save(out, format="JPEG", quality=88)
|
||
return out.getvalue()
|
||
|
||
|
||
def _draw_photo(
|
||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||
) -> None:
|
||
if not f.image:
|
||
return
|
||
photo = _fit_photo(f.image, f.width_mm, f.height_mm)
|
||
if photo is None:
|
||
return
|
||
c.drawImage(
|
||
ImageReader(io.BytesIO(photo)),
|
||
(x0_mm + f.x_mm) * mm,
|
||
# `f.y_mm` est le haut du cadre depuis le HAUT du badge ; reportlab
|
||
# place l'image par son coin bas-gauche, depuis le BAS de la page.
|
||
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
|
||
width=f.width_mm * mm,
|
||
height=f.height_mm * mm,
|
||
)
|
||
|
||
|
||
def _draw_fields(
|
||
c: canvas.Canvas,
|
||
fields: tuple[BadgeFieldSpec, ...],
|
||
x0_mm: float,
|
||
y0_mm: float,
|
||
badge_height_mm: float,
|
||
) -> None:
|
||
for f in fields:
|
||
if f.is_photo:
|
||
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
|
||
continue
|
||
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.
|
||
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(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)]
|
||
|
||
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, 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, 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()
|