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:
co-authored by
Claude Sonnet 5
parent
b7fc80a7dc
commit
579ea5d9db
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
+74
-29
@@ -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
|
||||||
|
for i, line in enumerate(f.text.split("\n")):
|
||||||
|
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":
|
if f.align == "CENTER":
|
||||||
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, f.text)
|
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
|
||||||
elif f.align == "RIGHT":
|
elif f.align == "RIGHT":
|
||||||
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, f.text)
|
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
|
||||||
else:
|
else:
|
||||||
c.drawString(left_x * mm, baseline_y * mm, f.text)
|
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()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
Generated
+75
@@ -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"
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user