Files
benevoles/backend/app/badges/pdf.py
T
AdamThomasandClaude Opus 5.5 1a23881e81
ci-cd / build-backend (push) Successful in 37s
ci-cd / build-frontend (push) Successful in 22s
ci-cd / deploy (push) Successful in 13s
Badges : retour à la ligne et créneaux en tableau par jour
Le texte des champs passe à la ligne pour tenir dans la largeur de son
cadre (wrap_text) au lieu de déborder. Les créneaux affectés s'affichent
en tableau : le jour de festival en en-tête, puis une colonne horaire et
une colonne poste (plus de référent sur le badge) ; même tableau dans
l'aperçu de l'éditeur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:44:55 +01:00

383 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.pdfbase.pdfmetrics import stringWidth
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
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
# des filets.
_CELL_PAD_X_MM = 0.8
_CELL_PAD_Y_MM = 0.5
_TABLE_LINE_WIDTH_PT = 0.4
# 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 BadgeTableSection:
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
jour), puis ses lignes (horaire, poste)."""
title: str
rows: tuple[tuple[str, ...], ...]
@dataclass(frozen=True)
class BadgeTable:
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
créneaux du bénévole regroupés par jour."""
sections: tuple[BadgeTableSection, ...]
@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é.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`."""
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
table: BadgeTable | 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 wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
du texte restent des retours à la ligne, puis chaque ligne trop longue
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
def fits(candidate: str) -> bool:
return stringWidth(candidate, font_name, font_size) <= max_width_pt
lines: list[str] = []
for paragraph in text.split("\n"):
current = ""
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
# ex. entre le nom du poste et son horaire.
for word in paragraph.split(" "):
candidate = f"{current} {word}" if current else word
if fits(candidate):
current = candidate
continue
if current:
lines.append(current.rstrip())
current = word
while current and not fits(current):
cut = len(current) - 1
while cut > 1 and not fits(current[:cut]):
cut -= 1
lines.append(current[:cut])
current = current[cut:]
lines.append(current.rstrip())
return lines
def _column_widths(natural: list[float], available: float) -> list[float]:
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
courte (jour, horaire) garde sa largeur naturelle, les plus longues
(poste, référent) se partagent le reste à parts égales et passent à la
ligne. S'il reste de la place, la dernière colonne la prend."""
widths = [0.0] * len(natural)
pending = sorted(range(len(natural)), key=lambda i: natural[i])
remaining = available
while pending:
share = remaining / len(pending)
if natural[pending[0]] > share:
for i in pending:
widths[i] = share
return widths
i = pending.pop(0)
widths[i] = natural[i]
remaining -= natural[i]
widths[-1] += remaining
return widths
def _draw_table(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
"""Tableau dans le cadre du champ : pour chaque section, une ligne
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
filets à la couleur du champ, texte de chaque cellule replié dans sa
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
texte."""
table = f.table
if table is None or not table.sections:
return
regular, bold = "Helvetica", "Helvetica-Bold"
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
line_height = f.font_size * _LINE_HEIGHT_FACTOR
body = [row for section in table.sections for row in section.rows]
columns = max(len(row) for row in body) if body else 1
natural = [
max(stringWidth(row[col], regular, f.font_size) for row in body) + 2 * pad_x
for col in range(columns)
] if body else [0.0]
widths = _column_widths(natural, f.width_mm * mm)
total_width = sum(widths)
# (cellules, police, en-tête de section ?) — un en-tête n'a qu'une
# cellule, sur toute la largeur.
lines_to_draw = []
for section in table.sections:
lines_to_draw.append(((section.title,), bold, True))
lines_to_draw.extend((row, regular, False) for row in section.rows)
left = (x0_mm + f.x_mm) * mm
top = (y0_mm + badge_height_mm - f.y_mm) * mm
bottom_limit = top - f.height_mm * mm
color = HexColor(f.color)
c.setFillColor(color)
c.setStrokeColor(color)
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
y = top
for cells, font, is_title in lines_to_draw:
cell_widths = [total_width] if is_title else widths
wrapped = [
wrap_text(text, font, f.font_size, max(1.0, cell_widths[col] - 2 * pad_x))
for col, text in enumerate(cells)
]
row_height = max(len(lines) for lines in wrapped) * line_height + 2 * pad_y
if y - row_height < bottom_limit - 0.01:
break # la ligne déborderait du cadre du champ
c.setFont(font, f.font_size)
x = left
for col, lines in enumerate(wrapped):
for i, line in enumerate(lines):
baseline = y - pad_y - f.font_size * 0.85 - i * line_height
c.drawString(x + pad_x, baseline, line)
x += cell_widths[col]
if not is_title:
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
x = left
for width in widths[:-1]:
x += width
c.line(x, y, x, y - row_height)
y -= row_height
c.line(left, y, left + total_width, y)
if y == top:
return # pas même le premier en-tête ne tient
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
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
if f.table is not None:
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
continue
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
c.setFont(font_name, 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
lines = wrap_text(f.text, font_name, f.font_size, f.width_mm * mm)
for i, line in enumerate(lines):
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()