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>
383 lines
15 KiB
Python
383 lines
15 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.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()
|