Refonte du créateur de badges et nombre de badges par bénévole
ci-cd / build-backend (push) Successful in 30s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 22s

Créateur de badges refait d'après la maquette : champs sur le recto et le
verso (BadgeField.face, verso placé en miroir dans le PDF pour le
recto-verso), fond de zone, réduction automatique du texte, photo ovale,
initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en
trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles,
inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de
découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant
génération.

Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut,
migration 0027) : champ sur la fiche, autant d'exemplaires sur les
planches, total affiché à la génération, colonne dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 19:20:42 +01:00
co-authored by Claude Opus 5.5
parent 1a23881e81
commit ced0855d47
28 changed files with 3738 additions and 803 deletions
+60 -36
View File
@@ -1,15 +1,16 @@
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
et génération du PDF.
Le verso est une image fixe, la même pour tous les bénévoles. Le recto
combine une image de fond et des champs positionnés (nom, poste(s) affectés,
catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py`
pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
Chaque face (recto, verso) combine une image de fond (la même pour tous les
bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
`BadgeField`) pour le modèle.
"""
import datetime as dt
import io
import re
import uuid
from collections import defaultdict
@@ -36,12 +37,14 @@ from app.badges.pdf import (
BadgeTableSection,
generate_badge_sheets_pdf,
image_size_mm,
initials,
)
from app.db import get_session
from app.i18n import t
from app.models import (
Account,
Assignment,
BadgeFace,
BadgeField,
BadgeFieldType,
BadgeTemplate,
@@ -58,6 +61,9 @@ router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
# --------------------------------------------------------------------------- #
@@ -80,7 +86,7 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
session.exec(
select(BadgeField)
.where(BadgeField.edition_id == edition_id)
.order_by(BadgeField.field_type)
.order_by(BadgeField.face, BadgeField.field_type)
)
)
@@ -206,9 +212,11 @@ def set_fields(
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[BadgeField]:
seen: set[BadgeFieldType] = set()
# Au plus un champ de chaque type par face ; le même sur les deux faces
# est permis.
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
for f in payload.fields:
if f.field_type in seen:
if (f.face, f.field_type) in seen:
raise HTTPException(
status_code=422,
detail={
@@ -216,7 +224,12 @@ def set_fields(
"params": {"field_type": f.field_type},
},
)
seen.add(f.field_type)
seen.add((f.face, f.field_type))
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
raise HTTPException(
status_code=422,
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
)
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
for f in payload.fields:
@@ -297,7 +310,11 @@ def _field_text(
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
(`_positions_table`)."""
if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}"
# Prénom puis nom, chacun sur sa ligne.
return f"{volunteer.first_name}\n{volunteer.last_name}"
if field.field_type == BadgeFieldType.PHOTO:
# Imprimées à la place d'une photo absente ou illisible.
return initials(volunteer.first_name, volunteer.last_name)
if field.field_type == BadgeFieldType.FREE_TEXT:
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
# le « Texte du badge » de sa fiche s'il en a un.
@@ -359,35 +376,42 @@ def generate_badges(
if account.volunteer_id is not None
}
badges = [
BadgeSpec(
fields=tuple(
BadgeFieldSpec(
text=_field_text(
field, volunteer, assignments_by_volunteer, lead_volunteer_ids
),
x_mm=field.x_mm,
y_mm=field.y_mm,
width_mm=field.width_mm,
height_mm=field.height_mm,
font_size=field.font_size,
align=field.align.value,
bold=field.bold,
color=field.color,
is_photo=field.field_type == BadgeFieldType.PHOTO,
image=volunteer.photo
if field.field_type == BadgeFieldType.PHOTO
else None,
table=_positions_table(
assignments_by_volunteer.get(volunteer.id, []), boundary
)
if field.field_type == BadgeFieldType.POSITIONS
else None,
)
for field in fields
)
def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
return BadgeFieldSpec(
text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
x_mm=field.x_mm,
y_mm=field.y_mm,
width_mm=field.width_mm,
height_mm=field.height_mm,
font_size=field.font_size,
align=field.align.value,
bold=field.bold,
color=field.color,
is_photo=field.field_type == BadgeFieldType.PHOTO,
image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None,
table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary)
if field.field_type == BadgeFieldType.POSITIONS
else None,
bg_color=field.bg_color,
auto_fit=field.auto_fit,
valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
photo_shape=field.photo_shape.value,
)
front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
back_fields = [f for f in fields if f.face == BadgeFace.BACK]
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
# à la suite les uns des autres.
badges = [
badge
for volunteer in volunteers
for badge in [
BadgeSpec(
fields=tuple(spec(field, volunteer) for field in front_fields),
back_fields=tuple(spec(field, volunteer) for field in back_fields),
)
]
* volunteer.badge_count
]
pdf_bytes = generate_badge_sheets_pdf(
+15 -2
View File
@@ -8,7 +8,9 @@ from pydantic import BaseModel, ConfigDict, Field
from app.models import (
AssignmentSource,
BadgeAlign,
BadgeFace,
BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus,
FormFieldType,
PositionCategory,
@@ -76,6 +78,9 @@ class VolunteerUpdate(ApiModel):
companion_wish_note: str | None = None
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
badge_text: str | None = None
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
# faute de frappe qui imprimerait des centaines de pages).
badge_count: int | None = Field(default=None, ge=1, le=100)
self_declared_available: bool | None = None
willing_to_pay_fee: bool | None = None
@@ -94,6 +99,7 @@ class VolunteerRead(ApiModel):
has_photo: bool
companion_wish_note: str | None
badge_text: str | None
badge_count: int
self_declared_available: bool
willing_to_pay_fee: bool
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
@@ -346,6 +352,7 @@ class MatrixResponse(ApiModel):
# Badges (planches PDF) #
# --------------------------------------------------------------------------- #
class BadgeFieldIn(ApiModel):
face: BadgeFace = BadgeFace.FRONT
field_type: BadgeFieldType
x_mm: float
y_mm: float
@@ -357,11 +364,17 @@ class BadgeFieldIn(ApiModel):
color: str = "#000000"
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
text: str | None = None
# Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`.
bg_color: str | None = None
auto_fit: bool = False
# `PHOTO` seulement, ignoré sinon.
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
class BadgeFieldsSet(ApiModel):
"""Remplace l'intégralité des champs du recto — au plus un par
`field_type` (décocher un champ dans l'éditeur retire sa ligne)."""
"""Remplace l'intégralité des champs du recto et du verso — au plus un
par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
ligne)."""
fields: list[BadgeFieldIn] = Field(default_factory=list)
+234 -78
View File
@@ -8,11 +8,12 @@ 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.
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
@@ -36,6 +37,13 @@ _LINE_HEIGHT_FACTOR = 1.25
_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).
_INITIALS_BG = "#E7E0CE"
_INITIALS_SIZE_RATIO = 0.28
# 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
@@ -73,14 +81,21 @@ class BadgeTable:
@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).
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 — `text` et le style sont
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.
bruts tels que stockés) plutôt qu'un texte, découpée selon
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), les
initiales du bénévole (`text`) sont imprimées à la place, sur un fond
neutre de la même forme.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`."""
`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
@@ -94,13 +109,19 @@ class BadgeFieldSpec:
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."""
"""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]:
@@ -112,11 +133,22 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]
def _cell_origin(
index: int, cols: int, badge_width_mm: float, badge_height_mm: float
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."""
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
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
@@ -169,23 +201,73 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
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
def initials(first_name: str, last_name: str) -> str:
"""Initiales imprimées à la place d'une photo absente : première lettre
du prénom et du nom, en majuscules."""
return "".join(part.strip()[:1] for part in (first_name, last_name)).upper()
def _draw_initials(c: canvas.Canvas, f: BadgeFieldSpec, x: float, y: float, w: float, h: float):
c.setFillColor(HexColor(_INITIALS_BG))
c.drawPath(_shape_path(c, f.photo_shape, x, y, w, h), stroke=0, fill=1)
if not f.text:
return
size = max(_AUTO_FIT_MIN_PT, w * _INITIALS_SIZE_RATIO)
c.setFont("Helvetica-Bold", size)
c.setFillColor(HexColor(f.color))
# Ligne de base décalée d'environ un tiers de la taille : centre
# visuel des majuscules (hauteur de capitale ≈ 0,7 × la taille).
c.drawCentredString(x + w / 2, y + h / 2 - size * 0.35, f.text)
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)
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
photo = _fit_photo(f.image, f.width_mm, f.height_mm) if f.image else None
c.saveState()
if photo is None:
_draw_initials(c, f, x, y, w, h)
else:
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.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,
)
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]:
@@ -242,6 +324,45 @@ def _column_widths(natural: list[float], available: float) -> list[float]:
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:
@@ -250,52 +371,43 @@ def _draw_table(
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."""
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
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)
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)
# (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
bottom_limit = top - height_pt
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:
for wrapped, font, is_title, row_height 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)
c.setFont(font, 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
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:
@@ -312,6 +424,71 @@ def _draw_table(
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, ...],
@@ -323,33 +500,11 @@ def _draw_fields(
if f.is_photo:
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
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)
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)
else:
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
def generate_badge_sheets_pdf(
@@ -374,9 +529,10 @@ def generate_badge_sheets_pdf(
_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)
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)
c.showPage()
c.save()
return buffer.getvalue()
+2
View File
@@ -37,6 +37,7 @@ _HEADER = [
"Mécénat — pays",
"Commentaire",
"Texte du badge",
"Nombre de badges",
"Inscrit le",
]
@@ -125,6 +126,7 @@ def generate_volunteers_xlsx(
_safe_text(v.mecenat_country or ""),
_safe_text(v.comment or ""),
_safe_text(v.badge_text or ""),
v.badge_count,
_fmt_dt(v.created_at),
]
)
+2 -1
View File
@@ -61,7 +61,8 @@
"badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
"badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.",
"badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).",
"badge.no_fields": "Aucun champ n'est configuré sur le badge.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"badge.role.referent": "Référent",
"badge.role.volunteer": "Bénévole",
@@ -0,0 +1,62 @@
"""Éditeur de badges v2 (verso, fond de zone, auto-ajustement, photo ovale)
Revision ID: 0026_badge_editor_v2
Revises: 0025_badge_field_text
Create Date: 2026-10-27
`badge_field.face` : face du badge (`FRONT`/`BACK`) — les champs existants
deviennent des champs du recto. La contrainte d'unicité passe de
(`edition_id`, `field_type`) à (`edition_id`, `face`, `field_type`) : un même
champ peut figurer sur les deux faces. `bg_color` (fond de la zone,
transparent si `NULL`), `auto_fit` (réduction automatique du texte) et
`photo_shape` (`RECT`/`OVAL`, champ `PHOTO`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0026_badge_editor_v2"
down_revision: str | None = "0025_badge_field_text"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FACE = sa.Enum("FRONT", "BACK", name="badgeface", native_enum=False)
_PHOTO_SHAPE = sa.Enum("RECT", "OVAL", name="badgephotoshape", native_enum=False)
def upgrade() -> None:
op.add_column(
"badge_field",
sa.Column("face", _FACE, server_default="FRONT", nullable=False),
)
op.add_column("badge_field", sa.Column("bg_color", sa.String(), nullable=True))
op.add_column(
"badge_field",
sa.Column("auto_fit", sa.Boolean(), server_default="false", nullable=False),
)
op.add_column(
"badge_field",
sa.Column("photo_shape", _PHOTO_SHAPE, server_default="RECT", nullable=False),
)
op.drop_constraint("uq_badge_field_edition_type", "badge_field", type_="unique")
op.create_unique_constraint(
"uq_badge_field_edition_face_type",
"badge_field",
["edition_id", "face", "field_type"],
)
def downgrade() -> None:
# Le verso n'existait pas : ses champs sont perdus (sinon l'ancienne
# contrainte d'unicité échouerait sur un champ présent sur les deux faces).
op.execute("DELETE FROM badge_field WHERE face = 'BACK'")
op.drop_constraint("uq_badge_field_edition_face_type", "badge_field", type_="unique")
op.create_unique_constraint(
"uq_badge_field_edition_type", "badge_field", ["edition_id", "field_type"]
)
op.drop_column("badge_field", "photo_shape")
op.drop_column("badge_field", "auto_fit")
op.drop_column("badge_field", "bg_color")
op.drop_column("badge_field", "face")
@@ -0,0 +1,35 @@
"""Nombre de badges par bénévole
Revision ID: 0027_volunteer_badge_count
Revises: 0026_badge_editor_v2
Create Date: 2026-10-27
`volunteer.badge_count` : nombre d'exemplaires de son badge imprimés sur les
planches (`POST .../badges/generate`), 1 par défaut — les bénévoles existants
gardent un seul badge.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0027_volunteer_badge_count"
down_revision: str | None = "0026_badge_editor_v2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"volunteer",
sa.Column("badge_count", sa.Integer(), nullable=False, server_default="1"),
)
op.create_check_constraint(
"ck_volunteer_badge_count_positive", "volunteer", "badge_count >= 1"
)
def downgrade() -> None:
op.drop_constraint("ck_volunteer_badge_count_positive", "volunteer", type_="check")
op.drop_column("volunteer", "badge_count")
+4
View File
@@ -28,7 +28,9 @@ from app.models.domain import (
from app.models.enums import (
AssignmentSource,
BadgeAlign,
BadgeFace,
BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus,
FormFieldType,
PositionCategory,
@@ -41,8 +43,10 @@ __all__ = [
"Assignment",
"AssignmentSource",
"BadgeAlign",
"BadgeFace",
"BadgeField",
"BadgeFieldType",
"BadgePhotoShape",
"BadgeTemplate",
"DayPart",
"Edition",
+43 -5
View File
@@ -18,7 +18,9 @@ from app.models.base import TimestampMixin
from app.models.enums import (
AssignmentSource,
BadgeAlign,
BadgeFace,
BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus,
FormFieldType,
PositionCategory,
@@ -160,6 +162,10 @@ class Artist(TimestampMixin, table=True):
class Volunteer(TimestampMixin, table=True):
__table_args__ = (
CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
@@ -187,6 +193,11 @@ class Volunteer(TimestampMixin, table=True):
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
badge_text: str | None = None
# Nombre d'exemplaires de son badge à imprimer sur les planches (badge
# perdu, plusieurs accréditations…) — 1 par défaut.
badge_count: int = Field(
default=1, sa_column=Column(Integer, nullable=False, server_default="1")
)
# Cases à cocher du formulaire public — purement informatives : aucune
# logique n'y est attachée (pas de workflow de paiement).
self_declared_available: bool = Field(
@@ -429,16 +440,17 @@ class BadgeTemplate(TimestampMixin, table=True):
class BadgeField(TimestampMixin, table=True):
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie,
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`).
Au plus un champ par `field_type` et par édition : décocher un champ dans
"""Un champ positionné sur une face du badge (`face` : recto ou verso —
nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
Au plus un champ par `field_type` et par face pour une édition (le même
champ peut figurer au recto et au verso) : décocher un champ dans
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
__tablename__ = "badge_field"
__table_args__ = (
UniqueConstraint(
"edition_id", "field_type", name="uq_badge_field_edition_type"
"edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
),
)
@@ -446,6 +458,14 @@ class BadgeField(TimestampMixin, table=True):
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
face: BadgeFace = Field(
default=BadgeFace.FRONT,
sa_column=Column(
SAEnum(BadgeFace, native_enum=False),
nullable=False,
server_default=BadgeFace.FRONT.value,
),
)
field_type: BadgeFieldType = Field(
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
)
@@ -471,6 +491,24 @@ class BadgeField(TimestampMixin, table=True):
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
text: str | None = None
# Fond de la zone du champ (`#rrggbb`) ; `None` = transparent. Sans
# effet sur le champ `PHOTO`.
bg_color: str | None = None
# Réduit la taille du texte (par pas de 0,5 pt) jusqu'à ce que tout
# tienne dans le cadre, au lieu de tronquer les lignes en trop.
auto_fit: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
# Champ `PHOTO` seulement : rectangle ou ellipse inscrite dans le cadre.
photo_shape: BadgePhotoShape = Field(
default=BadgePhotoShape.RECT,
sa_column=Column(
SAEnum(BadgePhotoShape, native_enum=False),
nullable=False,
server_default=BadgePhotoShape.RECT.value,
),
)
# --------------------------------------------------------------------------- #
+17 -1
View File
@@ -31,7 +31,7 @@ class PositionCategory(enum.StrEnum):
class BadgeFieldType(enum.StrEnum):
"""Champ affichable sur le recto d'un badge (voir `app/badges/`)."""
"""Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`)."""
FULL_NAME = "FULL_NAME"
POSITIONS = "POSITIONS"
@@ -45,6 +45,22 @@ class BadgeFieldType(enum.StrEnum):
FREE_TEXT = "FREE_TEXT"
class BadgeFace(enum.StrEnum):
"""Face du badge sur laquelle un champ est imprimé : le verso est
personnalisable comme le recto (même champ possible sur les deux)."""
FRONT = "FRONT"
BACK = "BACK"
class BadgePhotoShape(enum.StrEnum):
"""Forme de découpe du champ `PHOTO` (cadre rectangulaire ou ellipse
inscrite dans le cadre)."""
RECT = "RECT"
OVAL = "OVAL"
class BadgeAlign(enum.StrEnum):
LEFT = "LEFT"
CENTER = "CENTER"
+151
View File
@@ -247,3 +247,154 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session):
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
def _field(**kw):
return {"x_mm": 0, "y_mm": 0, "width_mm": 10, "height_mm": 10, **kw}
def test_new_field_attributes_default_and_round_trip(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FULL_NAME"),
_field(
field_type="PHOTO", face="BACK", photo_shape="OVAL",
bg_color="#FFD23F", auto_fit=True,
),
]
},
)
assert resp.status_code == 200
by_face = {f["face"]: f for f in resp.json()}
front, back = by_face["FRONT"], by_face["BACK"]
assert front["field_type"] == "FULL_NAME"
assert front["bg_color"] is None
assert front["auto_fit"] is False
assert front["photo_shape"] == "RECT"
assert back["field_type"] == "PHOTO"
assert back["photo_shape"] == "OVAL"
assert back["bg_color"] == "#FFD23F"
assert back["auto_fit"] is True
again = client.get(f"{_badges_url(edition)}/template").json()["fields"]
assert {(f["face"], f["field_type"]) for f in again} == {
("FRONT", "FULL_NAME"), ("BACK", "PHOTO"),
}
def test_same_field_type_on_front_and_back_is_allowed(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FREE_TEXT", face="FRONT", text="Recto"),
_field(field_type="FREE_TEXT", face="BACK", text="Verso"),
]
},
)
assert resp.status_code == 200
assert {(f["face"], f["text"]) for f in resp.json()} == {
("FRONT", "Recto"), ("BACK", "Verso"),
}
def test_same_field_type_twice_on_the_back_is_rejected(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="ROLE", face="BACK"),
_field(field_type="ROLE", face="BACK"),
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
@pytest.mark.parametrize("color", ["red", "#FFF", "#GGGGGG", "FFD23F", "#FFD23F0"])
def test_invalid_background_color_is_rejected(client, edition, color):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={"fields": [_field(field_type="ROLE", bg_color=color)]},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.invalid_color"
def test_generating_with_back_fields(client, edition, session, monkeypatch):
import app.api.badges as badges_api
captured = {}
original = badges_api.generate_badge_sheets_pdf
def spy(badges, *args, **kwargs):
captured["badges"] = badges
return original(badges, *args, **kwargs)
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FULL_NAME", width_mm=60, font_size=12),
_field(field_type="ROLE", face="BACK", bg_color="#FFD23F", auto_fit=True),
_field(field_type="PHOTO", face="BACK", photo_shape="OVAL"),
]
},
)
assert resp.status_code == 200
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
(badge,) = captured["badges"]
assert [f.text for f in badge.fields] == ["Alex\nMartin"]
back = {f.is_photo: f for f in badge.back_fields}
assert back[False].text == "Bénévole"
assert back[False].valign == "MIDDLE"
assert back[False].bg_color == "#FFD23F"
assert back[False].auto_fit is True
assert back[True].photo_shape == "OVAL"
assert back[True].text == "AM" # initiales, faute de photo
def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch):
import app.api.badges as badges_api
captured = {}
original = badges_api.generate_badge_sheets_pdf
def spy(badges, *args, **kwargs):
captured["badges"] = badges
return original(badges, *args, **kwargs)
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
client.put(
f"{_badges_url(edition)}/template/fields",
json={"fields": [_field(field_type="FULL_NAME", width_mm=60, font_size=12)]},
)
many = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
resp = client.patch(f"/editions/{edition.id}/volunteers/{many.id}", json={"badge_count": 3})
assert resp.status_code == 200
assert resp.json()["badge_count"] == 3
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
names = [badge.fields[0].text for badge in captured["badges"]]
assert names == ["Alex\nMartin"] * 3 + ["Sam\nNuit"]
@pytest.mark.parametrize("count", [0, -1, 101])
def test_badge_count_out_of_range_is_rejected(client, edition, session, count):
volunteer = make_volunteer(session, edition)
resp = client.patch(
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
)
assert resp.status_code == 422
+18
View File
@@ -104,3 +104,21 @@ def test_field_text_free_text_falls_back_to_the_common_editor_text():
assert _field_text(field, own, {}, set()) == "Accès backstage"
assert _field_text(field, other, {}, set()) == "Staff 2026"
def test_field_text_full_name_is_first_name_then_last_name_on_two_lines():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FULL_NAME,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin"
def test_field_text_photo_is_the_initials_used_without_a_photo():
volunteer = _volunteer(first_name="alex", last_name="martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
assert _field_text(field, volunteer, {}, set()) == "AM"
+167
View File
@@ -236,3 +236,170 @@ def test_table_rows_beyond_the_frame_are_not_drawn():
few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None)
many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
assert abs(len(many) - len(few)) < 50
# --------------------------------------------------------------------------- #
# Éditeur v2 : verso, fond de zone, réduction automatique, photo #
# --------------------------------------------------------------------------- #
from app.badges import pdf as pdf_module # noqa: E402
from app.badges.pdf import fit_text, initials # noqa: E402
def _text_field(**kw) -> BadgeFieldSpec:
base = dict(
text="Alex", x_mm=5, y_mm=5, width_mm=60, height_mm=10,
font_size=12, align="LEFT", bold=False, color="#000000",
)
base.update(kw)
return BadgeFieldSpec(**base)
def test_cell_origin_mirrors_the_column_on_the_back_page():
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
# Retournement bord long : colonne 0 au recto ⇒ dernière colonne au
# verso, même rangée.
assert _cell_origin(0, cols, w, h, mirrored=True) == _cell_origin(cols - 1, cols, w, h)
assert _cell_origin(cols, cols, w, h, mirrored=True) == _cell_origin(
2 * cols - 1, cols, w, h
)
def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeypatch):
calls = []
original = pdf_module._draw_fields
def spy(c, fields, x0, y0, badge_height_mm):
calls.append((fields, x0, y0))
original(c, fields, x0, y0, badge_height_mm)
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
back = (_text_field(text="Verso Alex"),)
# Un seul badge : planche incomplète, le verso doit quand même être en
# miroir (colonne de droite).
generate_badge_sheets_pdf(
[BadgeSpec(fields=(_text_field(),), back_fields=back)], None, None
)
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
(front_fields, fx, fy), (back_fields, bx, by) = calls
assert (fx, fy) == _cell_origin(0, cols, w, h)
assert back_fields == back
assert (bx, by) == _cell_origin(cols - 1, cols, w, h)
def test_fit_text_without_auto_fit_drops_the_lines_that_overflow():
text = "\n".join(["ligne"] * 10)
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)
assert size == 12
assert 0 < len(lines) < 10
def test_fit_text_with_auto_fit_shrinks_until_every_line_fits():
text = "\n".join(["ligne"] * 4)
assert len(fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)[1]) < 4
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
assert 4 < size < 12
assert (size * 2) == int(size * 2) # pas de 0,5 pt
assert len(lines) == 4
def test_fit_text_auto_fit_stops_at_the_minimum_size():
text = "\n".join(["ligne"] * 200)
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
assert size == 4.0
assert len(lines) < 200
def test_fit_text_auto_fit_keeps_the_size_when_the_text_already_fits():
assert fit_text("Alex", "Helvetica", 12, 60, 10, auto_fit=True) == (12, ["Alex"])
def _table_lines_drawn(monkeypatch, auto_fit: bool) -> int:
drawn = []
monkeypatch.setattr(
pdf_module.canvas.Canvas, "drawString",
lambda self, x, y, text, *a, **k: drawn.append(text),
)
badge = _table_badge(4, height_mm=20)
field = badge.fields[0]
badge = BadgeSpec(fields=(BadgeFieldSpec(**{**field.__dict__, "auto_fit": auto_fit}),))
generate_badge_sheets_pdf([badge], None, None)
return sum(1 for text in drawn if text == "19:00–22:00")
def test_table_auto_fit_draws_every_row(monkeypatch):
# En-tête + 4 lignes : la dernière ne tient pas à 7 pt dans 20 mm.
assert _table_lines_drawn(monkeypatch, auto_fit=False) < 4
assert _table_lines_drawn(monkeypatch, auto_fit=True) == 4
def _filled_rects(monkeypatch) -> list:
fills = []
original = pdf_module.canvas.Canvas.rect
def spy(self, x, y, w, h, stroke=1, fill=0):
if fill:
fills.append((round(w / mm, 3), round(h / mm, 3)))
return original(self, x, y, w, h, stroke=stroke, fill=fill)
monkeypatch.setattr(pdf_module.canvas.Canvas, "rect", spy)
return fills
def test_zone_background_fills_the_field_frame(monkeypatch):
fills = _filled_rects(monkeypatch)
generate_badge_sheets_pdf(
[BadgeSpec(fields=(_text_field(bg_color="#FFD23F"),))], None, None
)
assert fills == [(60, 10)]
def test_no_zone_background_by_default(monkeypatch):
fills = _filled_rects(monkeypatch)
generate_badge_sheets_pdf([BadgeSpec(fields=(_text_field(),))], None, None)
assert fills == []
def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
baselines = []
monkeypatch.setattr(
pdf_module.canvas.Canvas, "drawString",
lambda self, x, y, text, *a, **k: baselines.append(y),
)
for valign in ("TOP", "MIDDLE"):
generate_badge_sheets_pdf(
[BadgeSpec(fields=(_text_field(height_mm=30, valign=valign),))], None, None
)
top, middle = baselines
assert middle < top
def test_initials_are_the_uppercase_first_letters():
assert initials("alex", "martin") == "AM"
assert initials("Élodie", "de la Tour") == "ÉD"
def test_missing_photo_prints_the_initials(monkeypatch):
drawn = []
monkeypatch.setattr(
pdf_module.canvas.Canvas, "drawCentredString",
lambda self, x, y, text, *a, **k: drawn.append(text),
)
for image in (None, b"pas une image"):
badge = BadgeSpec(
fields=(BadgeFieldSpec(**{**_photo_badge(image).fields[0].__dict__, "text": "AM"}),)
)
generate_badge_sheets_pdf([badge], None, None)
assert drawn == ["AM", "AM"]
def test_oval_photo_and_oval_initials_generate_a_valid_pdf():
for image in (_jpeg(400, 500), None):
field = _photo_badge(image).fields[0]
badge = BadgeSpec(
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL", "text": "AM"}),)
)
pdf = generate_badge_sheets_pdf([badge], None, None)
assert pdf.startswith(b"%PDF")