Files
benevoles/backend/app/badges/pdf.py
T
AdamThomasandClaude Opus 5.5 3754cfc69a
ci-cd / build-backend (push) Successful in 57s
ci-cd / build-frontend (push) Successful in 9s
ci-cd / deploy (push) Successful in 16s
Planches de badges centrées, verso aligné sur le recto
Espace libre réparti à parts égales (mêmes écarts à gauche, entre les
badges et à droite ; en haut, entre les rangées et en bas) : grille
symétrique, le verso imprimé en miroir tombe exactement derrière le recto
au lieu d'être décalé de l'espace restant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:50:59 +01:00

563 lines
22 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. Chaque page de recto est
suivie de sa page de verso (même image de fond à chaque emplacement rempli,
plus les champs du verso de chaque bénévole) — impression recto-verso
classique, retournement sur le bord long : sur la page de verso, chaque
badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée)
pour tomber exactement derrière son recto — voir `_cell_origin`.
"""
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
# Minimums pour compter combien de badges tiennent sur la page (marge
# d'impression, écart entre deux badges) ; l'espace effectivement laissé est
# ensuite réparti à parts égales — voir `_spacing`.
_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éduction automatique de la taille du texte (`auto_fit`) : pas et plancher.
_AUTO_FIT_STEP_PT = 0.5
_AUTO_FIT_MIN_PT = 4.0
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
# la largeur du cadre).
# 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. prénom puis nom), 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, découpée selon
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien
n'est imprimé : le cadre reste vide.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
`bg_color` : fond plein de la zone du champ (`None` = transparent ;
ignoré pour la photo). `auto_fit` : réduit la taille du texte jusqu'à ce
que tout tienne dans le cadre (sinon, les lignes en trop ne sont pas
imprimées). `valign` : `TOP` ou `MIDDLE` (centré verticalement)."""
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
bg_color: str | None = None
auto_fit: bool = False
valign: str = "TOP" # "TOP" | "MIDDLE"
photo_shape: str = "RECT" # "RECT" | "OVAL"
@dataclass(frozen=True)
class BadgeSpec:
"""Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour
le recto, `back_fields` pour le verso."""
fields: tuple[BadgeFieldSpec, ...]
back_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 _spacing(count: int, page_mm: float, size_mm: float) -> tuple[float, float]:
"""`(marge extérieure, écart entre badges)` sur un axe, en mm : l'espace
libre est réparti **à parts égales** — même écart à gauche, entre chaque
badge et à droite (resp. en haut, entre les rangées et en bas) — pour une
grille centrée et symétrique. Si cet écart passait sous la marge
d'impression (`_MARGIN_MM`), la marge est gardée de chaque côté et le
reste réparti entre les badges : toujours symétrique. Avant, la grille
partait du coin haut-gauche et tout l'espace restant tombait à droite et
en bas ; une fois la feuille retournée, le verso se décalait d'autant par
rapport au recto (bug corrigé une fois)."""
free = page_mm - count * size_mm
even = free / (count + 1)
if even >= _MARGIN_MM or count == 1:
return (even, even) if count > 1 else (free / 2, 0.0)
return _MARGIN_MM, (free - 2 * _MARGIN_MM) / (count - 1)
def _cell_origin(
index: int,
cols: int,
badge_width_mm: float,
badge_height_mm: float,
mirrored: bool = False,
) -> 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.
`mirrored` : page de verso. Une feuille retournée sur son bord long
inverse gauche et droite : le verso du badge de la colonne `col` doit
être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber
derrière son recto — y compris sur une dernière planche incomplète."""
col, row = index % cols, index // cols
if mirrored:
col = cols - 1 - col
rows = _grid_size(badge_width_mm, badge_height_mm)[1]
outer_x, gap_x = _spacing(cols, _PAGE_WIDTH_MM, badge_width_mm)
outer_y, gap_y = _spacing(rows, _PAGE_HEIGHT_MM, badge_height_mm)
x0 = outer_x + col * (badge_width_mm + gap_x)
y0 = _PAGE_HEIGHT_MM - outer_y - (row + 1) * badge_height_mm - row * gap_y
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:
# JPEG : décodage directement à taille réduite (bien plus rapide
# qu'une photo de téléphone décodée en pleine résolution).
img.draft("RGB", (target[0] * 2, target[1] * 2))
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 _frame_rect(
f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> tuple[float, float, float, float]:
"""Cadre du champ en points, repère PDF : (gauche, bas, largeur,
hauteur). `f.y_mm` est le haut du cadre depuis le HAUT du badge ;
reportlab dessine depuis le BAS de la page."""
return (
(x0_mm + f.x_mm) * mm,
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
f.width_mm * mm,
f.height_mm * mm,
)
def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: float):
path = c.beginPath()
if shape == "OVAL":
path.ellipse(x, y, w, h)
else:
path.rect(x, y, w, h)
return path
# Photos déjà recadrées pendant une génération, par (photo, largeur,
# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
# fois.
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
def _draw_photo(
c: canvas.Canvas,
f: BadgeFieldSpec,
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
cache: PhotoCache,
) -> None:
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
photo = None
if f.image:
key = (f.image, f.width_mm, f.height_mm)
if key not in cache:
cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm)
photo = cache[key]
if photo is None:
# Pas de photo (ou illisible) : cadre laissé vide, choix de la
# coordination — avant, les initiales étaient imprimées à la place.
return
c.saveState()
if f.photo_shape == "OVAL":
# Découpe en ellipse inscrite dans le cadre.
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
c.restoreState()
def _draw_zone_background(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
if not f.bg_color:
return
c.saveState()
c.setFillColor(HexColor(f.bg_color))
c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1)
c.restoreState()
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
_TableLine = tuple[list[list[str]], str, bool, float]
def _table_layout(
table: BadgeTable, width_pt: float, font_size: float
) -> tuple[list[float], list[_TableLine]]:
"""Largeurs des colonnes et lignes du tableau à `font_size` : pour
chaque ligne, (texte replié de chaque cellule, police, en-tête de
section ?, hauteur en points)."""
regular, bold = "Helvetica", "Helvetica-Bold"
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
line_height = 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, font_size) for row in body) + 2 * pad_x
for col in range(columns)
] if body else [0.0]
widths = _column_widths(natural, width_pt)
total_width = sum(widths)
# Un en-tête n'a qu'une cellule, sur toute la largeur.
cells_to_draw = []
for section in table.sections:
cells_to_draw.append(((section.title,), bold, True))
cells_to_draw.extend((row, regular, False) for row in section.rows)
lines: list[_TableLine] = []
for cells, font, is_title in cells_to_draw:
cell_widths = [total_width] if is_title else widths
wrapped = [
wrap_text(text, font, font_size, max(1.0, cell_widths[col] - 2 * pad_x))
for col, text in enumerate(cells)
]
row_height = max(len(w) for w in wrapped) * line_height + 2 * pad_y
lines.append((wrapped, font, is_title, row_height))
return widths, lines
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 — sauf avec `auto_fit`, qui réduit la taille du texte jusqu'à ce
que toutes les lignes tiennent (plancher `_AUTO_FIT_MIN_PT`)."""
table = f.table
if table is None or not table.sections:
return
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
font_size = f.font_size
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
if f.auto_fit:
while (
sum(line[3] for line in lines_to_draw) > height_pt + 0.01
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
):
font_size -= _AUTO_FIT_STEP_PT
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
line_height = font_size * _LINE_HEIGHT_FACTOR
total_width = sum(widths)
left = (x0_mm + f.x_mm) * mm
top = (y0_mm + badge_height_mm - f.y_mm) * mm
bottom_limit = top - height_pt
color = HexColor(f.color)
c.setFillColor(color)
c.setStrokeColor(color)
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
y = top
for wrapped, font, is_title, row_height in lines_to_draw:
cell_widths = [total_width] if is_title else widths
if y - row_height < bottom_limit - 0.01:
break # la ligne déborderait du cadre du champ
c.setFont(font, font_size)
x = left
for col, lines in enumerate(wrapped):
for i, line in enumerate(lines):
baseline = y - pad_y - 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 _lines_that_fit(font_size: float, height_mm: float) -> int:
"""Nombre de lignes de texte qui tiennent dans `height_mm` (première
ligne de base à 0,85 × la taille sous le haut du cadre, puis un
interligne par ligne)."""
font_size_mm = font_size / mm
first = font_size_mm * 0.85
if first > height_mm + 1e-9:
return 0
return 1 + int((height_mm - first) / (font_size_mm * _LINE_HEIGHT_FACTOR) + 1e-9)
def fit_text(
text: str, font_name: str, font_size: float, width_mm: float, height_mm: float,
auto_fit: bool,
) -> tuple[float, list[str]]:
"""Taille retenue et lignes à imprimer d'un champ texte. Sans
`auto_fit` : la taille du champ, lignes en trop retirées. Avec : taille
réduite par pas de `_AUTO_FIT_STEP_PT` (plancher `_AUTO_FIT_MIN_PT`)
jusqu'à ce que toutes les lignes repliées tiennent dans le cadre."""
lines = wrap_text(text, font_name, font_size, width_mm * mm)
if auto_fit:
while (
len(lines) > _lines_that_fit(font_size, height_mm)
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
):
font_size -= _AUTO_FIT_STEP_PT
lines = wrap_text(text, font_name, font_size, width_mm * mm)
return font_size, lines[: _lines_that_fit(font_size, height_mm)]
def _draw_text(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
font_size, lines = fit_text(
f.text, font_name, f.font_size, f.width_mm, f.height_mm, f.auto_fit
)
if not lines:
return
c.setFont(font_name, font_size)
c.setFillColor(HexColor(f.color))
left_x = x0_mm + f.x_mm
# `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 = 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
if f.valign == "MIDDLE":
# Hauteur du bloc : interlignes entre les lignes + une hauteur de
# caractère pour la dernière.
block = (len(lines) - 1) * line_height_mm + font_size_mm
top_y -= max(0.0, (f.height_mm - block) / 2)
for i, line in enumerate(lines):
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
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 _draw_fields(
c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...],
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
photos: PhotoCache,
) -> None:
for f in fields:
if f.is_photo:
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
continue
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
if f.table is not None:
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
else:
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
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)
photos: PhotoCache = {}
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, photos)
c.showPage()
for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
c.showPage()
c.save()
return buffer.getvalue()