Badges : format dérivé de l'image importée, postes avec horaire et référent

- Le format du badge n'est plus fixé à CR80 : il est déduit des pixels de
  l'image du recto à l'import (résolution supposée 300 dpi,
  app/badges/pdf.py:image_size_mm), stocké sur BadgeTemplate (migration
  0012). L'éditeur (BadgesPage.tsx) et la génération PDF s'alignent sur ce
  format réel ; CR80 reste le repère par défaut tant qu'aucune image n'est
  importée. Le verso est ajusté sans déformation (preserveAspectRatio).

- Le champ « Postes affectés » liste désormais une ligne par créneau
  (triées chronologiquement) : nom du poste + horaire, et le référent du
  poste s'il y en a un et que ce n'est pas le bénévole badgé lui-même.
  Texte multi-lignes pris en charge par la génération PDF (découpe sur
  `\n`, hauteur de ligne calculée en mm) et par l'aperçu de l'éditeur.

- Corrige au passage un bug d'unités dans le positionnement vertical du
  texte (police en points confondue avec des millimètres).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-20 20:35:11 +02:00
co-authored by Claude Sonnet 5
parent b7fc80a7dc
commit 579ea5d9db
17 changed files with 534 additions and 87 deletions
+19 -7
View File
@@ -138,16 +138,28 @@ les tables sauf les tables d'association.
demande). `front_image`/`front_image_content_type` (fond du recto), demande). `front_image`/`front_image_content_type` (fond du recto),
`back_image`/`back_image_content_type` (verso — **le même pour tous les `back_image`/`back_image_content_type` (verso — **le même pour tous les
bénévoles**). Images stockées en base (`LargeBinary`), pas de volume de bénévoles**). Images stockées en base (`LargeBinary`), pas de volume de
fichiers à gérer. fichiers à gérer. **Le format du badge n'est pas fixe** :
`front_image_width_mm`/`front_image_height_mm` sont déduits des pixels de
l'image du recto à l'import (résolution supposée 300 dpi — voir
`app/badges/pdf.py:image_size_mm`), donc le badge prend la forme de
l'image importée ; `NULL` tant qu'aucune image n'est importée, l'éditeur
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
déformation, `preserveAspectRatio=True`) à ce même format.
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈ - **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE`, au plus un par édition — `FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE`, au plus un par édition —
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/ `PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge `width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge,
(format CR80, 85,6 × 54 mm), `font_size`, `align`, `bold`, `color`. Édité `font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer)
visuellement (glisser-déposer) depuis l'écran « Badges » du front-end depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`),
(`frontend/src/pages/BadgesPage.tsx`), qui envoie ces mêmes millimètres. qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel
`POSITIONS`/`CATEGORY` résolus depuis les affectations décidées du du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`).
bénévole, `ROLE` depuis son `Account.role` (`LEAD` ⇒ « Référent »). `POSITIONS` résout **une ligne par créneau affecté**, triée
chronologiquement : nom du poste + horaire (`HH:MM–HH:MM`), suivi du
référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a
un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes,
`\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent »).
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` — `POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
+66 -12
View File
@@ -7,11 +7,14 @@ catégorie, rôle) — voir `app/badges/pdf.py` pour la composition du PDF et
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle. `app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle.
""" """
import io
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api.deps import get_edition from app.api.deps import get_edition
@@ -22,7 +25,14 @@ from app.api.schemas import (
BadgeTemplateRead, BadgeTemplateRead,
) )
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf from app.badges.pdf import (
DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
generate_badge_sheets_pdf,
image_size_mm,
)
from app.db import get_session from app.db import get_session
from app.i18n import t from app.i18n import t
from app.models import ( from app.models import (
@@ -74,6 +84,8 @@ def _template_read(template: BadgeTemplate, fields: list[BadgeField]) -> BadgeTe
edition_id=template.edition_id, edition_id=template.edition_id,
has_front_image=template.front_image is not None, has_front_image=template.front_image is not None,
has_back_image=template.back_image is not None, has_back_image=template.back_image is not None,
badge_width_mm=template.front_image_width_mm or DEFAULT_BADGE_WIDTH_MM,
badge_height_mm=template.front_image_height_mm or DEFAULT_BADGE_HEIGHT_MM,
fields=fields, fields=fields,
) )
@@ -102,6 +114,16 @@ async def _read_image(file: UploadFile) -> tuple[bytes, str]:
return content, content_type return content, content_type
def _pixel_size(content: bytes) -> tuple[int, int]:
try:
with Image.open(io.BytesIO(content)) as img:
return img.size
except UnidentifiedImageError as exc:
raise HTTPException(
status_code=422, detail={"code": "badge.image_unreadable"}
) from exc
@router.put("/template/front-image", response_model=BadgeTemplateRead) @router.put("/template/front-image", response_model=BadgeTemplateRead)
async def set_front_image( async def set_front_image(
file: UploadFile = File(...), file: UploadFile = File(...),
@@ -109,10 +131,17 @@ async def set_front_image(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> BadgeTemplateRead: ) -> BadgeTemplateRead:
"""Le format du badge (`front_image_width_mm`/`height_mm`) est dérivé de
cette image (voir `app/badges/pdf.py:image_size_mm`) : le badge prend
désormais la forme de l'image importée plutôt qu'un format fixe imposé."""
template = _get_or_create_template(session, edition.id) template = _get_or_create_template(session, edition.id)
content, content_type = await _read_image(file) content, content_type = await _read_image(file)
width_px, height_px = _pixel_size(content)
width_mm, height_mm = image_size_mm(width_px, height_px)
template.front_image = content template.front_image = content
template.front_image_content_type = content_type template.front_image_content_type = content_type
template.front_image_width_mm = width_mm
template.front_image_height_mm = height_mm
session.add(template) session.add(template)
session.commit() session.commit()
return _template_read(template, _fields(session, edition.id)) return _template_read(template, _fields(session, edition.id))
@@ -193,6 +222,9 @@ def set_fields(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Génération du PDF # # Génération du PDF #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent)
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str: def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
return t("badge.role.referent") if volunteer_id in lead_volunteer_ids else t("badge.role.volunteer") return t("badge.role.referent") if volunteer_id in lead_volunteer_ids else t("badge.role.volunteer")
@@ -201,20 +233,32 @@ def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}") return t(f"position.category.{category.value.lower()}")
def _position_line(position: Position, shift: Shift, referent: Volunteer | None, volunteer_id: uuid.UUID) -> str:
"""Une ligne par créneau affecté : poste + horaire, et le référent du
poste s'il y en a un — sauf si le bénévole badgé est lui-même ce
référent (pas besoin de se l'indiquer à soi-même)."""
line = f"{position.name} {shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}"
if referent is not None and referent.id != volunteer_id:
line += f" ({t('badge.role.referent')} : {referent.first_name} {referent.last_name})"
return line
def _field_text( def _field_text(
field: BadgeField, field: BadgeField,
volunteer: Volunteer, volunteer: Volunteer,
positions_by_volunteer: dict[uuid.UUID, list[Position]], assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
lead_volunteer_ids: set[uuid.UUID], lead_volunteer_ids: set[uuid.UUID],
) -> str: ) -> str:
if field.field_type == BadgeFieldType.FULL_NAME: if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}" return f"{volunteer.first_name} {volunteer.last_name}"
positions = positions_by_volunteer.get(volunteer.id, []) assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS: if field.field_type == BadgeFieldType.POSITIONS:
names = sorted({p.name for p in positions}) return "\n".join(
return ", ".join(names) _position_line(position, shift, referent, volunteer.id)
for position, shift, referent in assignments
)
if field.field_type == BadgeFieldType.CATEGORY: if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted({_category_label(p.category) for p in positions}) categories = sorted({_category_label(position.category) for position, _shift, _r in assignments})
return ", ".join(categories) return ", ".join(categories)
if field.field_type == BadgeFieldType.ROLE: if field.field_type == BadgeFieldType.ROLE:
return _role_label(volunteer.id, lead_volunteer_ids) return _role_label(volunteer.id, lead_volunteer_ids)
@@ -241,15 +285,19 @@ def generate_badges(
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"}) raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
volunteer_ids = [v.id for v in volunteers] volunteer_ids = [v.id for v in volunteers]
Referent = aliased(Volunteer)
rows = session.exec( rows = session.exec(
select(Assignment.volunteer_id, Position) select(Assignment.volunteer_id, Position, Shift, Referent)
.join(Shift, Shift.id == Assignment.shift_id) .join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id) .join(Position, Position.id == Shift.position_id)
.join(Account, Account.id == Position.lead_account_id, isouter=True)
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
.where(Assignment.volunteer_id.in_(volunteer_ids)) .where(Assignment.volunteer_id.in_(volunteer_ids))
.order_by(Shift.starts_at)
).all() ).all()
positions_by_volunteer: dict[uuid.UUID, list[Position]] = defaultdict(list) assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
for volunteer_id, position in rows: for volunteer_id, position, shift, referent in rows:
positions_by_volunteer[volunteer_id].append(position) assignments_by_volunteer[volunteer_id].append((position, shift, referent))
lead_volunteer_ids = { lead_volunteer_ids = {
account.volunteer_id account.volunteer_id
@@ -267,7 +315,7 @@ def generate_badges(
BadgeSpec( BadgeSpec(
fields=tuple( fields=tuple(
BadgeFieldSpec( BadgeFieldSpec(
text=_field_text(field, volunteer, positions_by_volunteer, lead_volunteer_ids), text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
x_mm=field.x_mm, x_mm=field.x_mm,
y_mm=field.y_mm, y_mm=field.y_mm,
width_mm=field.width_mm, width_mm=field.width_mm,
@@ -283,7 +331,13 @@ def generate_badges(
for volunteer in volunteers for volunteer in volunteers
] ]
pdf_bytes = generate_badge_sheets_pdf(badges, template.front_image, template.back_image) pdf_bytes = generate_badge_sheets_pdf(
badges,
template.front_image,
template.back_image,
badge_width_mm=template.front_image_width_mm or DEFAULT_BADGE_WIDTH_MM,
badge_height_mm=template.front_image_height_mm or DEFAULT_BADGE_HEIGHT_MM,
)
return Response( return Response(
content=pdf_bytes, content=pdf_bytes,
media_type="application/pdf", media_type="application/pdf",
+4
View File
@@ -367,6 +367,10 @@ class BadgeTemplateRead(ApiModel):
edition_id: uuid.UUID edition_id: uuid.UUID
has_front_image: bool has_front_image: bool
has_back_image: bool has_back_image: bool
# Dérivée de l'image du recto (voir `app/badges/pdf.py:image_size_mm`) ;
# format CR80 par défaut tant qu'aucune image n'est importée.
badge_width_mm: float
badge_height_mm: float
fields: list[BadgeFieldRead] fields: list[BadgeFieldRead]
+16 -2
View File
@@ -1,3 +1,17 @@
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf from app.badges.pdf import (
DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
generate_badge_sheets_pdf,
image_size_mm,
)
__all__ = ["BadgeFieldSpec", "BadgeSpec", "generate_badge_sheets_pdf"] __all__ = [
"DEFAULT_BADGE_HEIGHT_MM",
"DEFAULT_BADGE_WIDTH_MM",
"BadgeFieldSpec",
"BadgeSpec",
"generate_badge_sheets_pdf",
"image_size_mm",
]
+77 -32
View File
@@ -4,8 +4,11 @@ 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 → ici — voir `app/api/badges.py` pour l'assemblage des données (bénévole →
champs résolus en texte). champs résolus en texte).
Format badge : CR80 (85,6 × 54 mm — taille standard des badges/cartes Format du badge : dérivé de l'image de fond du recto (pas de format fixe
d'événement). Planche : grille sur page A4 portrait. Le verso est identique 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 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 (personnalisées) puis, pour chacune, la page de verso correspondante (même
disposition, même image répétée à chaque emplacement rempli) — impression disposition, même image répétée à chaque emplacement rempli) — impression
@@ -21,19 +24,33 @@ from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
BADGE_WIDTH_MM = 85.6 DEFAULT_BADGE_WIDTH_MM = 85.6
BADGE_HEIGHT_MM = 54.0 DEFAULT_BADGE_HEIGHT_MM = 54.0
_MARGIN_MM = 10.0 _MARGIN_MM = 10.0
_GUTTER_MM = 4.0 _GUTTER_MM = 4.0
_LINE_HEIGHT_FACTOR = 1.25
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
# d'impression standard.
ASSUMED_DPI = 300.0
_PAGE_WIDTH_MM = A4[0] / mm _PAGE_WIDTH_MM = A4[0] / mm
_PAGE_HEIGHT_MM = A4[1] / 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) @dataclass(frozen=True)
class BadgeFieldSpec: class BadgeFieldSpec:
"""Un champ déjà résolu en texte, positionné depuis le coin haut-gauche """Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
du badge (comme dans l'éditeur visuel du front-end).""" 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)."""
text: str text: str
x_mm: float x_mm: float
@@ -53,64 +70,92 @@ class BadgeSpec:
fields: tuple[BadgeFieldSpec, ...] fields: tuple[BadgeFieldSpec, ...]
def _grid_size() -> tuple[int, int]: def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
usable_w = _PAGE_WIDTH_MM - 2 * _MARGIN_MM usable_w = _PAGE_WIDTH_MM - 2 * _MARGIN_MM
usable_h = _PAGE_HEIGHT_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))) 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))) rows = max(1, int((usable_h + _GUTTER_MM) // (badge_height_mm + _GUTTER_MM)))
return cols, rows return cols, rows
def _cell_origin(index: int, cols: int) -> tuple[float, float]: 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, """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."""
col, row = index % cols, index // cols col, row = index % cols, index // cols
x0 = _MARGIN_MM + col * (BADGE_WIDTH_MM + _GUTTER_MM) x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM)
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * BADGE_HEIGHT_MM - row * _GUTTER_MM y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM
return x0, y0 return x0, y0
def _draw_background(c: canvas.Canvas, image: bytes | None, x0_mm: float, y0_mm: float) -> None: 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: if not image:
return return
c.drawImage( c.drawImage(
ImageReader(io.BytesIO(image)), ImageReader(io.BytesIO(image)),
x0_mm * mm, x0_mm * mm,
y0_mm * mm, y0_mm * mm,
width=BADGE_WIDTH_MM * mm, width=badge_width_mm * mm,
height=BADGE_HEIGHT_MM * mm, height=badge_height_mm * mm,
preserveAspectRatio=False, # 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", mask="auto",
) )
def _draw_fields( def _draw_fields(
c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], x0_mm: float, y0_mm: float c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...],
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
) -> None: ) -> None:
for f in fields: for f in fields:
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size) c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size)
c.setFillColor(HexColor(f.color)) c.setFillColor(HexColor(f.color))
left_x = x0_mm + f.x_mm 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 # `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
# dessine depuis le BAS de la page. On approxime un alignement en # dessine depuis le BAS de la page.
# haut de la boîte plutôt qu'un centrage vertical exact. top_y = y0_mm + badge_height_mm - f.y_mm
baseline_y = y0_mm + BADGE_HEIGHT_MM - f.y_mm - f.font_size * 0.8 bottom_limit = top_y - f.height_mm
if f.align == "CENTER": for i, line in enumerate(f.text.split("\n")):
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, f.text) baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
elif f.align == "RIGHT": if baseline_y < bottom_limit:
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, f.text) break # la ligne déborderait de la boîte du champ
else: if f.align == "CENTER":
c.drawString(left_x * mm, baseline_y * mm, f.text) 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( def generate_badge_sheets_pdf(
badges: list[BadgeSpec], badges: list[BadgeSpec],
front_image: bytes | None, front_image: bytes | None,
back_image: bytes | None, back_image: bytes | None,
badge_width_mm: float = DEFAULT_BADGE_WIDTH_MM,
badge_height_mm: float = DEFAULT_BADGE_HEIGHT_MM,
) -> bytes: ) -> bytes:
"""`badges` : un par bénévole, dans l'ordre d'impression. Retourne les """`badges` : un par bénévole, dans l'ordre d'impression. Retourne les
octets du PDF (recto puis verso, planche par planche).""" octets du PDF (recto puis verso, planche par planche)."""
cols, rows = _grid_size() cols, rows = _grid_size(badge_width_mm, badge_height_mm)
per_page = cols * rows per_page = cols * rows
pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)] pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)]
@@ -118,14 +163,14 @@ def generate_badge_sheets_pdf(
c = canvas.Canvas(buffer, pagesize=A4) c = canvas.Canvas(buffer, pagesize=A4)
for page_badges in pages: for page_badges in pages:
for i, badge in enumerate(page_badges): for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols) x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
_draw_background(c, front_image, x0, y0) _draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.fields, x0, y0) _draw_fields(c, badge.fields, x0, y0, badge_height_mm)
c.showPage() c.showPage()
for i in range(len(page_badges)): for i in range(len(page_badges)):
x0, y0 = _cell_origin(i, cols) x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
_draw_background(c, back_image, x0, y0) _draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
c.showPage() c.showPage()
c.save() c.save()
return buffer.getvalue() return buffer.getvalue()
+1
View File
@@ -56,6 +56,7 @@
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.", "availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.", "availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.", "badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.image_unreadable": "L'image est illisible ou corrompue.",
"badge.empty_image": "L'image est vide.", "badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.", "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.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
@@ -0,0 +1,35 @@
"""Format du badge dérivé de l'image de fond du recto
Revision ID: 0012_badge_template_size
Revises: 0011_badge_template
Create Date: 2026-09-21
`badge_template.front_image_width_mm`/`front_image_height_mm` : taille
physique (mm) déduite des pixels de l'image importée (résolution supposée
300 dpi, voir `app/badges/pdf.py:image_size_mm`) — remplace le format CR80
fixe pour la génération du PDF (`app/api/badges.py`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0012_badge_template_size"
down_revision: str | None = "0011_badge_template"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"badge_template", sa.Column("front_image_width_mm", sa.Float(), nullable=True)
)
op.add_column(
"badge_template", sa.Column("front_image_height_mm", sa.Float(), nullable=True)
)
def downgrade() -> None:
op.drop_column("badge_template", "front_image_height_mm")
op.drop_column("badge_template", "front_image_width_mm")
+9 -1
View File
@@ -375,7 +375,13 @@ class BadgeTemplate(TimestampMixin, table=True):
"""Recto/verso du badge d'une édition. Une ligne par édition (créée à la """Recto/verso du badge d'une édition. Une ligne par édition (créée à la
demande). Le verso (`back_image`) est le même pour tous les bénévoles ; demande). Le verso (`back_image`) est le même pour tous les bénévoles ;
le recto combine `front_image` (fond) et les `BadgeField` positionnés le recto combine `front_image` (fond) et les `BadgeField` positionnés
dessus. Images stockées en base (pas de volume de fichiers à gérer).""" dessus. Images stockées en base (pas de volume de fichiers à gérer).
Le format du badge (`front_image_width_mm`/`height_mm`) est **dérivé de
l'image du recto** à l'import (voir `app/badges/pdf.py:image_size_mm`,
résolution supposée 300 dpi) plutôt qu'un format fixe imposé — `None`
tant qu'aucune image n'a été importée (l'éditeur retombe alors sur le
format CR80 par défaut, voir `DEFAULT_BADGE_WIDTH_MM`)."""
__tablename__ = "badge_template" __tablename__ = "badge_template"
@@ -385,6 +391,8 @@ class BadgeTemplate(TimestampMixin, table=True):
) )
front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
front_image_content_type: str | None = None front_image_content_type: str | None = None
front_image_width_mm: float | None = None
front_image_height_mm: float | None = None
back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
back_image_content_type: str | None = None back_image_content_type: str | None = None
+16
View File
@@ -2,6 +2,8 @@
import base64 import base64
import pytest
from app.models import Role from app.models import Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer from tests.factories import dt_at, make_position, make_shift, make_volunteer
@@ -49,6 +51,20 @@ def test_template_starts_empty(client, edition):
assert body["has_front_image"] is False assert body["has_front_image"] is False
assert body["has_back_image"] is False assert body["has_back_image"] is False
assert body["fields"] == [] assert body["fields"] == []
# Format CR80 par défaut tant qu'aucune image n'a été importée.
assert body["badge_width_mm"] == 85.6
assert body["badge_height_mm"] == 54.0
def test_uploading_front_image_derives_the_badge_format_from_its_pixels(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/front-image",
files={"file": ("front.png", _PNG, "image/png")},
)
body = resp.json()
# PNG 1×1 px, résolution supposée 300 dpi ⇒ 25,4/300 mm de côté.
assert body["badge_width_mm"] == pytest.approx(25.4 / 300)
assert body["badge_height_mm"] == pytest.approx(25.4 / 300)
def test_uploading_front_and_back_images(client, edition): def test_uploading_front_and_back_images(client, edition):
+98
View File
@@ -0,0 +1,98 @@
"""Texte résolu des champs du badge — fonctions pures (pas de base)."""
import datetime as dt
import uuid
from app.api.badges import _field_text, _position_line
from app.models import (
BadgeField,
BadgeFieldType,
Position,
PositionCategory,
Shift,
Volunteer,
)
def _volunteer(**kw) -> Volunteer:
return Volunteer(id=uuid.uuid4(), edition_id=uuid.uuid4(), **kw)
def _position(**kw) -> Position:
return Position(id=uuid.uuid4(), edition_id=uuid.uuid4(), category=PositionCategory.BAR, **kw)
def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
return Shift(id=uuid.uuid4(), edition_id=uuid.uuid4(), position_id=uuid.uuid4(), starts_at=start, ends_at=end)
def test_position_line_shows_the_schedule():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
line = _position_line(position, shift, referent=None, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00"
def test_position_line_shows_the_referent_when_there_is_one():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
referent = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(position, shift, referent=referent, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00 (Référent : Sam Nuit)"
def test_position_line_hides_the_referent_when_it_is_the_badged_volunteer():
# Pas besoin de se dire à soi-même qu'on est referent de son propre poste.
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
referent=volunteer,
volunteer_id=volunteer.id,
)
assert "Référent" not in line
def test_field_text_positions_lists_one_line_per_shift_in_chronological_order():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.POSITIONS,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
morning = (
_position(name="Accueil"),
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)),
None,
)
evening = (
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
None,
)
text = _field_text(
field, volunteer,
assignments_by_volunteer={volunteer.id: [morning, evening]},
lead_volunteer_ids=set(),
)
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00"
def test_field_text_role_is_referent_only_for_lead_accounts():
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.ROLE,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole"
assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent"
+43 -6
View File
@@ -1,11 +1,14 @@
"""Génération des planches de badges (module pur, sans base).""" """Génération des planches de badges (module pur, sans base)."""
from app.badges.pdf import ( from app.badges.pdf import (
DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec, BadgeFieldSpec,
BadgeSpec, BadgeSpec,
_cell_origin, _cell_origin,
_grid_size, _grid_size,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm,
) )
@@ -27,18 +30,30 @@ def _badge(text="Alex Martin") -> BadgeSpec:
) )
def test_image_size_mm_uses_the_assumed_print_resolution():
# 300 dpi (résolution d'impression standard) : 1013 px / 300 * 25,4 mm.
width_mm, height_mm = image_size_mm(1013, 638, dpi=300)
assert width_mm == 1013 / 300 * 25.4
assert height_mm == 638 / 300 * 25.4
def test_grid_size_fits_a4_with_cr80_badges(): def test_grid_size_fits_a4_with_cr80_badges():
# A4 (210 × 297 mm), marge 10 mm, gouttière 4 mm, badge CR80 85,6 × 54 mm # A4 (210 × 297 mm), marge 10 mm, gouttière 4 mm, badge CR80 85,6 × 54 mm
# ⇒ 2 colonnes (190 mm utiles / 89,6 mm par badge+gouttière) x 4 rangées # ⇒ 2 colonnes (190 mm utiles / 89,6 mm par badge+gouttière) x 4 rangées
# (277 mm utiles / 58 mm par badge+gouttière). # (277 mm utiles / 58 mm par badge+gouttière).
assert _grid_size() == (2, 4) assert _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM) == (2, 4)
def test_grid_size_adapts_to_a_different_badge_format():
# Badge carré 50 × 50 mm : plus de colonnes/rangées qu'un CR80.
assert _grid_size(50, 50) == (3, 5)
def test_cell_origin_places_badges_left_to_right_top_to_bottom(): def test_cell_origin_places_badges_left_to_right_top_to_bottom():
cols, _ = _grid_size() cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
x0, y0 = _cell_origin(0, cols) x0, y0 = _cell_origin(0, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
x1, _ = _cell_origin(1, cols) x1, _ = _cell_origin(1, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
_, y_row2 = _cell_origin(cols, cols) _, y_row2 = _cell_origin(cols, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
assert x1 > x0 # la 2e colonne est à droite de la 1re assert x1 > x0 # la 2e colonne est à droite de la 1re
assert y_row2 < y0 # la 2e rangée est plus bas (repère PDF : Y croît vers le haut) assert y_row2 < y0 # la 2e rangée est plus bas (repère PDF : Y croît vers le haut)
@@ -49,8 +64,30 @@ def test_generates_a_valid_pdf_for_a_single_badge():
assert pdf.rstrip().endswith(b"%%EOF") assert pdf.rstrip().endswith(b"%%EOF")
def test_generates_a_valid_pdf_with_a_custom_badge_format():
pdf = generate_badge_sheets_pdf(
[_badge()], front_image=None, back_image=None,
badge_width_mm=50, badge_height_mm=50,
)
assert pdf.startswith(b"%PDF")
def test_multiline_field_text_does_not_crash_generation():
badge = BadgeSpec(
fields=(
BadgeFieldSpec(
text="Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
x_mm=5, y_mm=5, width_mm=70, height_mm=20,
font_size=9, align="LEFT", bold=False, color="#000000",
),
)
)
pdf = generate_badge_sheets_pdf([badge], front_image=None, back_image=None)
assert pdf.startswith(b"%PDF")
def test_more_badges_than_one_sheet_produces_a_larger_pdf(): def test_more_badges_than_one_sheet_produces_a_larger_pdf():
cols, rows = _grid_size() cols, rows = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
per_page = cols * rows per_page = cols * rows
one_page = generate_badge_sheets_pdf( one_page = generate_badge_sheets_pdf(
[_badge() for _ in range(per_page)], front_image=None, back_image=None [_badge() for _ in range(per_page)], front_image=None, back_image=None
+75
View File
@@ -108,10 +108,12 @@ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "openpyxl" }, { name = "openpyxl" },
{ name = "ortools" }, { name = "ortools" },
{ name = "pillow" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "reportlab" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@@ -131,10 +133,12 @@ requires-dist = [
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "openpyxl", specifier = ">=3.1" }, { name = "openpyxl", specifier = ">=3.1" },
{ name = "ortools", specifier = ">=9.11" }, { name = "ortools", specifier = ">=9.11" },
{ name = "pillow", specifier = ">=11.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "pydantic-settings", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pyjwt", specifier = ">=2.9" }, { name = "pyjwt", specifier = ">=2.9" },
{ name = "python-multipart", specifier = ">=0.0.12" }, { name = "python-multipart", specifier = ">=0.0.12" },
{ name = "reportlab", specifier = ">=4.2" },
{ name = "sqlmodel", specifier = ">=0.0.22" }, { name = "sqlmodel", specifier = ">=0.0.22" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
] ]
@@ -156,6 +160,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 }, { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 },
] ]
[[package]]
name = "charset-normalizer"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456 },
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530 },
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200 },
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222 },
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951 },
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801 },
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070 },
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110 },
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836 },
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712 },
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977 },
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207 },
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562 },
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507 },
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551 },
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700 },
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467 },
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057 },
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930 },
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822 },
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037 },
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097 },
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166 },
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821 },
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529 },
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348 },
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234 },
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917 },
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846 },
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216 },
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764 },
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318 },
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658 },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.5.0" version = "8.5.0"
@@ -433,6 +478,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796 }, { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796 },
] ]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969 },
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323 },
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838 },
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830 },
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383 },
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934 },
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684 },
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137 },
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267 },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -652,6 +714,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
] ]
[[package]]
name = "reportlab"
version = "5.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "pillow" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258 },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.16.6" version = "0.16.6"
+4
View File
@@ -43,6 +43,8 @@ export interface MockState {
badgeTemplate: { badgeTemplate: {
frontImage: string | null; // data URL frontImage: string | null; // data URL
frontImageType: string | null; frontImageType: string | null;
frontImageWidthMm: number | null;
frontImageHeightMm: number | null;
backImage: string | null; // data URL backImage: string | null; // data URL
backImageType: string | null; backImageType: string | null;
fields: BadgeFieldRead[]; fields: BadgeFieldRead[];
@@ -423,6 +425,8 @@ export function buildMockState(): MockState {
badgeTemplate: { badgeTemplate: {
frontImage: null, frontImage: null,
frontImageType: null, frontImageType: null,
frontImageWidthMm: null,
frontImageHeightMm: null,
backImage: null, backImage: null,
backImageType: null, backImageType: null,
fields: [], fields: [],
+24
View File
@@ -86,6 +86,21 @@ const notFound = (): never => {
throw new ApiError(404, 'error.not_found'); throw new ApiError(404, 'error.not_found');
}; };
// Format CR80 (85,6 × 54 mm) par défaut tant qu'aucune image de recto n'est
// importée — sinon le format du badge est dérivé de ses pixels (voir
// `imageSizeMm`), comme côté back-end (`app/badges/pdf.py:image_size_mm`).
const DEFAULT_BADGE_WIDTH_MM = 85.6;
const DEFAULT_BADGE_HEIGHT_MM = 54;
const BADGE_ASSUMED_DPI = 300;
const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: number }> => {
const bitmap = await createImageBitmap(file);
const width_mm = (bitmap.width / BADGE_ASSUMED_DPI) * 25.4;
const height_mm = (bitmap.height / BADGE_ASSUMED_DPI) * 25.4;
bitmap.close();
return { width_mm, height_mm };
};
const fileToDataUrl = (file: File): Promise<string> => const fileToDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -1333,15 +1348,22 @@ export function createMockApi(): PlanningApi {
edition_id: state.edition.id, edition_id: state.edition.id,
has_front_image: state.badgeTemplate.frontImage !== null, has_front_image: state.badgeTemplate.frontImage !== null,
has_back_image: state.badgeTemplate.backImage !== null, has_back_image: state.badgeTemplate.backImage !== null,
badge_width_mm: state.badgeTemplate.frontImageWidthMm ?? DEFAULT_BADGE_WIDTH_MM,
badge_height_mm: state.badgeTemplate.frontImageHeightMm ?? DEFAULT_BADGE_HEIGHT_MM,
fields: state.badgeTemplate.fields, fields: state.badgeTemplate.fields,
}), }),
uploadFrontImage: async (_edition, file: File) => { uploadFrontImage: async (_edition, file: File) => {
const { width_mm, height_mm } = await imageSizeMm(file);
state.badgeTemplate.frontImage = await fileToDataUrl(file); state.badgeTemplate.frontImage = await fileToDataUrl(file);
state.badgeTemplate.frontImageType = file.type; state.badgeTemplate.frontImageType = file.type;
state.badgeTemplate.frontImageWidthMm = width_mm;
state.badgeTemplate.frontImageHeightMm = height_mm;
return delay<BadgeTemplateRead>({ return delay<BadgeTemplateRead>({
edition_id: state.edition.id, edition_id: state.edition.id,
has_front_image: true, has_front_image: true,
has_back_image: state.badgeTemplate.backImage !== null, has_back_image: state.badgeTemplate.backImage !== null,
badge_width_mm: width_mm,
badge_height_mm: height_mm,
fields: state.badgeTemplate.fields, fields: state.badgeTemplate.fields,
}); });
}, },
@@ -1352,6 +1374,8 @@ export function createMockApi(): PlanningApi {
edition_id: state.edition.id, edition_id: state.edition.id,
has_front_image: state.badgeTemplate.frontImage !== null, has_front_image: state.badgeTemplate.frontImage !== null,
has_back_image: true, has_back_image: true,
badge_width_mm: state.badgeTemplate.frontImageWidthMm ?? DEFAULT_BADGE_WIDTH_MM,
badge_height_mm: state.badgeTemplate.frontImageHeightMm ?? DEFAULT_BADGE_HEIGHT_MM,
fields: state.badgeTemplate.fields, fields: state.badgeTemplate.fields,
}); });
}, },
+4
View File
@@ -447,6 +447,10 @@ export interface BadgeTemplateRead {
edition_id: string; edition_id: string;
has_front_image: boolean; has_front_image: boolean;
has_back_image: boolean; has_back_image: boolean;
/** Dérivée de l'image du recto (300 dpi supposés) ; format CR80 par défaut
* tant qu'aucune image n'est importée. */
badge_width_mm: number;
badge_height_mm: number;
fields: BadgeFieldRead[]; fields: BadgeFieldRead[];
} }
+4 -4
View File
@@ -327,20 +327,20 @@
"badges.tab.customize": "PERSONNALISER", "badges.tab.customize": "PERSONNALISER",
"badges.tab.generate": "GÉNÉRER", "badges.tab.generate": "GÉNÉRER",
"badges.front": "Recto", "badges.front": "Recto",
"badges.front.help": "Fond personnalisable + champs positionnables (nom, poste, catégorie, rôle). Propre à cette édition.", "badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
"badges.back": "Verso", "badges.back": "Verso",
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition.", "badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.",
"badges.fields": "Champs du recto", "badges.fields": "Champs du recto",
"badges.field.fullName": "Prénom + nom", "badges.field.fullName": "Prénom + nom",
"badges.field.fullName.placeholder": "Prénom Nom", "badges.field.fullName.placeholder": "Prénom Nom",
"badges.field.positions": "Poste(s) affecté(s)", "badges.field.positions": "Poste(s) affecté(s)",
"badges.field.positions.placeholder": "Bar central, Accueil", "badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
"badges.field.category": "Catégorie", "badges.field.category": "Catégorie",
"badges.field.category.placeholder": "Bar", "badges.field.category.placeholder": "Bar",
"badges.field.role": "Rôle (référent / bénévole)", "badges.field.role": "Rôle (référent / bénévole)",
"badges.field.role.placeholder": "Référent", "badges.field.role.placeholder": "Référent",
"badges.field.size": "Taille", "badges.field.size": "Taille",
"badges.editor.hint": "Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.", "badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
"badges.image.choose": "Choisir une image", "badges.image.choose": "Choisir une image",
"badges.image.replace": "Remplacer l'image", "badges.image.replace": "Remplacer l'image",
"badges.uploading": "Envoi…", "badges.uploading": "Envoi…",
+39 -23
View File
@@ -16,11 +16,12 @@ import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
// Badge format CR80 (85,6 × 54 mm — cartes/badges d'événement standard), voir // Le format du badge est dérivé de l'image de fond du recto (voir
// `backend/app/badges/pdf.py` (même format, mêmes millimètres). `SCALE` fixe // `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant
// la taille d'édition à l'écran ; les coordonnées enregistrées restent en mm. // qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à
const BADGE_W_MM = 85.6; // l'écran ; les coordonnées enregistrées restent en mm.
const BADGE_H_MM = 54; const DEFAULT_BADGE_W_MM = 85.6;
const DEFAULT_BADGE_H_MM = 54;
const SCALE = 4.6; // px par mm à l'écran const SCALE = 4.6; // px par mm à l'écran
type FieldState = BadgeFieldIn & { enabled: boolean }; type FieldState = BadgeFieldIn & { enabled: boolean };
@@ -42,23 +43,26 @@ const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
}; };
// Position/taille par défaut à l'activation d'un champ — empilés // Position/taille par défaut à l'activation d'un champ — empilés
// verticalement pour ne pas se superposer, taille de police décroissante. // verticalement pour ne pas se superposer (repères pour un badge CR80 ;
// l'utilisateur ajuste ensuite à la souris pour son propre format). Postes
// affectés sur plusieurs lignes (un par créneau, avec horaire) : plus haut
// que les autres champs.
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = { const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
FULL_NAME: { FULL_NAME: {
x_mm: 5, y_mm: 6, width_mm: 70, height_mm: 12, x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10,
font_size: 13, align: 'LEFT', bold: true, color: '#000000', font_size: 13, align: 'LEFT', bold: true, color: '#000000',
}, },
POSITIONS: { POSITIONS: {
x_mm: 5, y_mm: 20, width_mm: 70, height_mm: 10, x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22,
font_size: 9, align: 'LEFT', bold: false, color: '#000000', font_size: 8, align: 'LEFT', bold: false, color: '#000000',
}, },
CATEGORY: { CATEGORY: {
x_mm: 5, y_mm: 32, width_mm: 70, height_mm: 9, x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7,
font_size: 9, align: 'LEFT', bold: false, color: '#000000', font_size: 8, align: 'LEFT', bold: false, color: '#000000',
}, },
ROLE: { ROLE: {
x_mm: 5, y_mm: 43, width_mm: 70, height_mm: 9, x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
font_size: 9, align: 'LEFT', bold: true, color: '#a1442b', font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
}, },
}; };
@@ -123,6 +127,9 @@ function BadgeCustomizeTab() {
if (template.isLoading || !fields) return <Loading />; if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />; if (template.error) return <ErrorNote error={template.error} />;
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => { const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev)); setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
}; };
@@ -165,8 +172,8 @@ function BadgeCustomizeTab() {
<div <div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none" className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{ style={{
width: BADGE_W_MM * SCALE, width: badgeW * SCALE,
height: BADGE_H_MM * SCALE, height: badgeH * SCALE,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined, backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}} }}
onPointerDown={() => setSelected(null)} onPointerDown={() => setSelected(null)}
@@ -175,6 +182,8 @@ function BadgeCustomizeTab() {
<FieldBox <FieldBox
key={type} key={type}
field={fields[type]} field={fields[type]}
badgeW={badgeW}
badgeH={badgeH}
selected={selected === type} selected={selected === type}
onSelect={() => setSelected(type)} onSelect={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)} onChange={(patch) => updateField(type, patch)}
@@ -182,7 +191,10 @@ function BadgeCustomizeTab() {
))} ))}
</div> </div>
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60"> <p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60">
{t('badges.editor.hint')} {t('badges.editor.hint', {
width: Math.round(badgeW * 10) / 10,
height: Math.round(badgeH * 10) / 10,
})}
</p> </p>
</div> </div>
</Panel> </Panel>
@@ -201,7 +213,7 @@ function BadgeCustomizeTab() {
/> />
<div <div
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center" className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
style={{ width: BADGE_W_MM * SCALE, height: BADGE_H_MM * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }} style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/> />
</div> </div>
</Panel> </Panel>
@@ -276,11 +288,15 @@ function ImageUploadRow({
function FieldBox({ function FieldBox({
field, field,
badgeW,
badgeH,
selected, selected,
onSelect, onSelect,
onChange, onChange,
}: { }: {
field: FieldState; field: FieldState;
badgeW: number;
badgeH: number;
selected: boolean; selected: boolean;
onSelect: () => void; onSelect: () => void;
onChange: (patch: Partial<FieldState>) => void; onChange: (patch: Partial<FieldState>) => void;
@@ -305,8 +321,8 @@ function FieldBox({
const dxMm = (e.clientX - d.pointerXPx) / SCALE; const dxMm = (e.clientX - d.pointerXPx) / SCALE;
const dyMm = (e.clientY - d.pointerYPx) / SCALE; const dyMm = (e.clientY - d.pointerYPx) / SCALE;
onChange({ onChange({
x_mm: clamp(d.startXMm + dxMm, 0, BADGE_W_MM - field.width_mm), x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
y_mm: clamp(d.startYMm + dyMm, 0, BADGE_H_MM - field.height_mm), y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
}); });
}; };
@@ -323,8 +339,8 @@ function FieldBox({
const dwMm = (e.clientX - d.pointerXPx) / SCALE; const dwMm = (e.clientX - d.pointerXPx) / SCALE;
const dhMm = (e.clientY - d.pointerYPx) / SCALE; const dhMm = (e.clientY - d.pointerYPx) / SCALE;
onChange({ onChange({
width_mm: clamp(d.startWMm + dwMm, 10, BADGE_W_MM - field.x_mm), width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, BADGE_H_MM - field.y_mm), height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
}); });
}; };
@@ -334,7 +350,7 @@ function FieldBox({
onPointerMove={onDragMove} onPointerMove={onDragMove}
onPointerUp={() => (dragRef.current = null)} onPointerUp={() => (dragRef.current = null)}
className={cx( className={cx(
'absolute flex cursor-move items-center overflow-hidden border-[1.5px] bg-white/55 px-1', 'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
selected ? 'border-blue-strong' : 'border-ink/40', selected ? 'border-blue-strong' : 'border-ink/40',
)} )}
style={{ style={{
@@ -345,7 +361,7 @@ function FieldBox({
}} }}
> >
<span <span
className="pointer-events-none truncate font-sans" className="pointer-events-none whitespace-pre-line font-sans leading-tight"
style={{ style={{
fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge
fontWeight: field.bold ? 800 : 400, fontWeight: field.bold ? 800 : 400,