diff --git a/CLAUDE.md b/CLAUDE.md index d79e229..1f66ddd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,11 +171,15 @@ les tables sauf les tables d'association. depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`), qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`). - `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 ; + `POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`, + dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés, + **regroupés par jour de festival** (§ 3 — une soirée qui passe minuit + reste sur le jour où elle a commencé) : le jour en en-tête sur toute la + largeur (« Samedi 11 »), puis une ligne par créneau, colonne horaire + (`HH:MM–HH:MM`, à sa largeur naturelle) et colonne poste (le reste, + repliée si besoin), dans l'ordre chronologique. Pas de référent sur le + badge (choix de la coordination). Une ligne qui ne tient plus dans la + hauteur du cadre n'est pas imprimée ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ; `ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ; `FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les @@ -185,7 +189,12 @@ les tables sauf les tables d'association. à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou photo illisible, le cadre reste vide plutôt que de faire échouer la - planche. L'éditeur ramène tout champ à l'intérieur du badge + planche. Le texte d'un champ passe à la ligne pour tenir dans la largeur + de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au + milieu d'un mot trop long seul ; les `\n` restent des retours à la + ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme + l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque + ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge (`BadgesPage.tsx:fitToBadge`) et met le champ sélectionné au premier plan : les positions par défaut visent un CR80 paysage, et sur un badge plus étroit un champ pouvait tomber hors du badge, avec des bornes de @@ -942,6 +951,8 @@ Aucune dépendance locale requise hors Docker. | 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider | | 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider | | 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider | +| 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider | +| 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/badges.py b/backend/app/api/badges.py index dd66fcc..6ea548a 100644 --- a/backend/app/api/badges.py +++ b/backend/app/api/badges.py @@ -8,6 +8,7 @@ pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle. """ +import datetime as dt import io import uuid from collections import defaultdict @@ -15,7 +16,6 @@ from collections import defaultdict from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile from PIL import Image, UnidentifiedImageError from sqlalchemy import delete -from sqlalchemy.orm import aliased from sqlmodel import Session, select from app.api.deps import get_edition @@ -32,6 +32,8 @@ from app.badges.pdf import ( DEFAULT_BADGE_WIDTH_MM, BadgeFieldSpec, BadgeSpec, + BadgeTable, + BadgeTableSection, generate_badge_sheets_pdf, image_size_mm, ) @@ -43,12 +45,14 @@ from app.models import ( BadgeField, BadgeFieldType, BadgeTemplate, + DayPart, Edition, Position, Role, Shift, Volunteer, ) +from app.validation.engine import day_boundary, festival_day router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"]) @@ -246,7 +250,7 @@ def set_size( # --------------------------------------------------------------------------- # # Génération du PDF # # --------------------------------------------------------------------------- # -AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent) +AssignmentRow = tuple[Position, Shift] def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str: @@ -259,16 +263,29 @@ def _category_label(category) -> str: 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 _day_label(day: dt.date) -> str: + """En-tête d'un jour dans le tableau des créneaux : « Samedi 11 ».""" + return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day) + + +def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None: + """Créneaux affectés regroupés par jour de festival (une soirée qui + passe minuit reste sur le jour où elle a commencé — `festival_day`) : + le jour en en-tête, puis une ligne horaire / poste par créneau, dans + l'ordre chronologique. `None` sans aucun créneau (rien à dessiner).""" + by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list) + for position, shift in assignments: + by_day[festival_day(shift.starts_at, boundary)].append( + (f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name) + ) + if not by_day: + return None + return BadgeTable( + sections=tuple( + BadgeTableSection(title=_day_label(day), rows=tuple(rows)) + for day, rows in sorted(by_day.items()) + ) + ) def _field_text( @@ -277,6 +294,8 @@ def _field_text( assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]], lead_volunteer_ids: set[uuid.UUID], ) -> str: + """Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau + (`_positions_table`).""" if field.field_type == BadgeFieldType.FULL_NAME: return f"{volunteer.first_name} {volunteer.last_name}" if field.field_type == BadgeFieldType.FREE_TEXT: @@ -284,14 +303,9 @@ def _field_text( # le « Texte du badge » de sa fiche s'il en a un. return volunteer.badge_text or field.text or "" assignments = assignments_by_volunteer.get(volunteer.id, []) - if field.field_type == BadgeFieldType.POSITIONS: - return "\n".join( - _position_line(position, shift, referent, volunteer.id) - for position, shift, referent in assignments - ) if field.field_type == BadgeFieldType.CATEGORY: categories = sorted( - {_category_label(position.category) for position, _shift, _r in assignments} + {_category_label(position.category) for position, _shift in assignments} ) return ", ".join(categories) if field.field_type == BadgeFieldType.ROLE: @@ -319,20 +333,20 @@ def generate_badges( raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"}) volunteer_ids = [v.id for v in volunteers] - Referent = aliased(Volunteer) rows = session.exec( - select(Assignment.volunteer_id, Position, Shift, Referent) + select(Assignment.volunteer_id, Position, Shift) .join(Shift, Shift.id == Assignment.shift_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)) .order_by(Shift.starts_at) ).all() assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list) - for volunteer_id, position, shift, referent in rows: - assignments_by_volunteer[volunteer_id].append((position, shift, referent)) + for volunteer_id, position, shift in rows: + assignments_by_volunteer[volunteer_id].append((position, shift)) + boundary = day_boundary( + session.exec(select(DayPart).where(DayPart.edition_id == edition.id)) + ) lead_volunteer_ids = { account.volunteer_id for account in session.exec( @@ -364,6 +378,11 @@ def generate_badges( image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None, + table=_positions_table( + assignments_by_volunteer.get(volunteer.id, []), boundary + ) + if field.field_type == BadgeFieldType.POSITIONS + else None, ) for field in fields ) diff --git a/backend/app/badges/pdf.py b/backend/app/badges/pdf.py index 92f7008..4acc54a 100644 --- a/backend/app/badges/pdf.py +++ b/backend/app/badges/pdf.py @@ -23,6 +23,7 @@ from reportlab.lib.colors import HexColor from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.lib.utils import ImageReader +from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfgen import canvas DEFAULT_BADGE_WIDTH_MM = 85.6 @@ -30,6 +31,11 @@ DEFAULT_BADGE_HEIGHT_MM = 54.0 _MARGIN_MM = 10.0 _GUTTER_MM = 4.0 _LINE_HEIGHT_FACTOR = 1.25 +# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur +# des filets. +_CELL_PAD_X_MM = 0.8 +_CELL_PAD_Y_MM = 0.5 +_TABLE_LINE_WIDTH_PT = 0.4 # Résolution supposée d'une image de fond importée, pour convertir ses pixels # en millimètres réels (donc en taille de badge) — 300 dpi est la résolution @@ -47,6 +53,23 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu return (width_px / dpi * 25.4, height_px / dpi * 25.4) +@dataclass(frozen=True) +class BadgeTableSection: + """Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le + jour), puis ses lignes (horaire, poste).""" + + title: str + rows: tuple[tuple[str, ...], ...] + + +@dataclass(frozen=True) +class BadgeTable: + """Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les + créneaux du bénévole regroupés par jour.""" + + sections: tuple[BadgeTableSection, ...] + + @dataclass(frozen=True) class BadgeFieldSpec: """Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n` @@ -55,7 +78,9 @@ class BadgeFieldSpec: `is_photo` : le champ est la photo du bénévole (`image`, ses octets bruts tels que stockés) plutôt qu'un texte — `text` et le style sont - alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.""" + alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné. + + `table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.""" text: str x_mm: float @@ -68,6 +93,7 @@ class BadgeFieldSpec: color: str # "#rrggbb" is_photo: bool = False image: bytes | None = None + table: BadgeTable | None = None @dataclass(frozen=True) @@ -162,6 +188,130 @@ def _draw_photo( ) +def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]: + """Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n` + du texte restent des retours à la ligne, puis chaque ligne trop longue + passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne + tient pas (nom très long dans un cadre étroit). Sans cela, un prénom + + nom ou une ligne de poste avec horaire débordait du cadre du champ.""" + + def fits(candidate: str) -> bool: + return stringWidth(candidate, font_name, font_size) <= max_width_pt + + lines: list[str] = [] + for paragraph in text.split("\n"): + current = "" + # `split(" ")` (et non `split()`) garde les doubles espaces voulus, + # ex. entre le nom du poste et son horaire. + for word in paragraph.split(" "): + candidate = f"{current} {word}" if current else word + if fits(candidate): + current = candidate + continue + if current: + lines.append(current.rstrip()) + current = word + while current and not fits(current): + cut = len(current) - 1 + while cut > 1 and not fits(current[:cut]): + cut -= 1 + lines.append(current[:cut]) + current = current[cut:] + lines.append(current.rstrip()) + return lines + + +def _column_widths(natural: list[float], available: float) -> list[float]: + """Largeurs des colonnes d'un tableau dans `available` : une colonne + courte (jour, horaire) garde sa largeur naturelle, les plus longues + (poste, référent) se partagent le reste à parts égales et passent à la + ligne. S'il reste de la place, la dernière colonne la prend.""" + widths = [0.0] * len(natural) + pending = sorted(range(len(natural)), key=lambda i: natural[i]) + remaining = available + while pending: + share = remaining / len(pending) + if natural[pending[0]] > share: + for i in pending: + widths[i] = share + return widths + i = pending.pop(0) + widths[i] = natural[i] + remaining -= natural[i] + widths[-1] += remaining + return widths + + +def _draw_table( + c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float +) -> None: + """Tableau dans le cadre du champ : pour chaque section, une ligne + d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ; + filets à la couleur du champ, texte de chaque cellule replié dans sa + colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du + cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ + texte.""" + table = f.table + if table is None or not table.sections: + return + regular, bold = "Helvetica", "Helvetica-Bold" + pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm + line_height = f.font_size * _LINE_HEIGHT_FACTOR + body = [row for section in table.sections for row in section.rows] + columns = max(len(row) for row in body) if body else 1 + natural = [ + max(stringWidth(row[col], regular, f.font_size) for row in body) + 2 * pad_x + for col in range(columns) + ] if body else [0.0] + widths = _column_widths(natural, f.width_mm * mm) + total_width = sum(widths) + + # (cellules, police, en-tête de section ?) — un en-tête n'a qu'une + # cellule, sur toute la largeur. + lines_to_draw = [] + for section in table.sections: + lines_to_draw.append(((section.title,), bold, True)) + lines_to_draw.extend((row, regular, False) for row in section.rows) + + left = (x0_mm + f.x_mm) * mm + top = (y0_mm + badge_height_mm - f.y_mm) * mm + bottom_limit = top - f.height_mm * mm + color = HexColor(f.color) + c.setFillColor(color) + c.setStrokeColor(color) + c.setLineWidth(_TABLE_LINE_WIDTH_PT) + + y = top + for cells, font, is_title in lines_to_draw: + cell_widths = [total_width] if is_title else widths + wrapped = [ + wrap_text(text, font, f.font_size, max(1.0, cell_widths[col] - 2 * pad_x)) + for col, text in enumerate(cells) + ] + row_height = max(len(lines) for lines in wrapped) * line_height + 2 * pad_y + if y - row_height < bottom_limit - 0.01: + break # la ligne déborderait du cadre du champ + c.setFont(font, f.font_size) + x = left + for col, lines in enumerate(wrapped): + for i, line in enumerate(lines): + baseline = y - pad_y - f.font_size * 0.85 - i * line_height + c.drawString(x + pad_x, baseline, line) + x += cell_widths[col] + if not is_title: + # Séparations de colonnes, sur la hauteur de cette ligne seulement. + x = left + for width in widths[:-1]: + x += width + c.line(x, y, x, y - row_height) + y -= row_height + c.line(left, y, left + total_width, y) + + if y == top: + return # pas même le premier en-tête ne tient + c.rect(left, y, total_width, top - y, stroke=1, fill=0) + + def _draw_fields( c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], @@ -173,7 +323,11 @@ def _draw_fields( if f.is_photo: _draw_photo(c, f, x0_mm, y0_mm, badge_height_mm) continue - c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size) + if f.table is not None: + _draw_table(c, f, x0_mm, y0_mm, badge_height_mm) + continue + font_name = "Helvetica-Bold" if f.bold else "Helvetica" + c.setFont(font_name, f.font_size) c.setFillColor(HexColor(f.color)) left_x = x0_mm + f.x_mm # `f.font_size` est en points (unité reportlab) ; le reste du calcul @@ -185,7 +339,8 @@ def _draw_fields( # dessine depuis le BAS de la page. top_y = y0_mm + badge_height_mm - f.y_mm bottom_limit = top_y - f.height_mm - for i, line in enumerate(f.text.split("\n")): + lines = wrap_text(f.text, font_name, f.font_size, f.width_mm * mm) + for i, line in enumerate(lines): baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm if baseline_y < bottom_limit: break # la ligne déborderait de la boîte du champ diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index b5944c8..104d00e 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -65,6 +65,14 @@ "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", "badge.role.referent": "Référent", "badge.role.volunteer": "Bénévole", + "badge.table.day_label": "{weekday} {day}", + "badge.weekday.0": "Lundi", + "badge.weekday.1": "Mardi", + "badge.weekday.2": "Mercredi", + "badge.weekday.3": "Jeudi", + "badge.weekday.4": "Vendredi", + "badge.weekday.5": "Samedi", + "badge.weekday.6": "Dimanche", "position.category.bar": "Bar", "position.category.ticketing": "Billetterie", "position.category.welcome": "Accueil", diff --git a/backend/tests/test_badges_fields.py b/backend/tests/test_badges_fields.py index 7ad4331..a426580 100644 --- a/backend/tests/test_badges_fields.py +++ b/backend/tests/test_badges_fields.py @@ -3,7 +3,7 @@ import datetime as dt import uuid -from app.api.badges import _field_text, _position_line +from app.api.badges import _field_text, _positions_table from app.models import ( BadgeField, BadgeFieldType, @@ -29,65 +29,45 @@ def _shift(start: dt.datetime, end: dt.datetime) -> Shift: ) -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" +_BOUNDARY = dt.time(2, 0) # journée de festival de 02:00 à 02: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 = ( +def test_positions_table_groups_shifts_under_their_day(): + accueil = ( _position(name="Accueil"), - _shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)), - None, + _shift(dt.datetime(2026, 7, 11, 9, 0), dt.datetime(2026, 7, 11, 13, 0)), ) - evening = ( + bar = ( _position(name="Bar central"), - _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)), - None, + _shift(dt.datetime(2026, 7, 11, 19, 0), dt.datetime(2026, 7, 11, 22, 0)), + ) + demontage = ( + _position(name="Démontage"), + _shift(dt.datetime(2026, 7, 12, 14, 0), dt.datetime(2026, 7, 12, 18, 0)), ) - text = _field_text( - field, volunteer, - assignments_by_volunteer={volunteer.id: [morning, evening]}, - lead_volunteer_ids=set(), + table = _positions_table([accueil, bar, demontage], _BOUNDARY) + + assert [(s.title, s.rows) for s in table.sections] == [ + ("Samedi 11", (("09:00–13:00", "Accueil"), ("19:00–22:00", "Bar central"))), + ("Dimanche 12", (("14:00–18:00", "Démontage"),)), + ] + + +def test_positions_table_keeps_a_night_shift_on_the_day_it_started(): + night = ( + _position(name="Bar central"), + _shift(dt.datetime(2026, 7, 12, 0, 0), dt.datetime(2026, 7, 12, 1, 0)), ) - assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00" + table = _positions_table([night], _BOUNDARY) + + assert table.sections[0].title == "Samedi 11" + assert table.sections[0].rows == (("00:00–01:00", "Bar central"),) + + +def test_positions_table_is_empty_without_any_shift(): + assert _positions_table([], _BOUNDARY) is None def test_field_text_role_is_referent_only_for_lead_accounts(): diff --git a/backend/tests/test_badges_pdf.py b/backend/tests/test_badges_pdf.py index 96dbaf8..0fdcd71 100644 --- a/backend/tests/test_badges_pdf.py +++ b/backend/tests/test_badges_pdf.py @@ -3,6 +3,8 @@ import io from PIL import Image +from reportlab.lib.units import mm +from reportlab.pdfbase.pdfmetrics import stringWidth from app.badges.pdf import ( ASSUMED_DPI, @@ -10,11 +12,15 @@ from app.badges.pdf import ( DEFAULT_BADGE_WIDTH_MM, BadgeFieldSpec, BadgeSpec, + BadgeTable, + BadgeTableSection, _cell_origin, + _column_widths, _fit_photo, _grid_size, generate_badge_sheets_pdf, image_size_mm, + wrap_text, ) @@ -151,3 +157,82 @@ def test_unreadable_photo_does_not_crash_generation(): [_photo_badge(b"pas une image")], front_image=None, back_image=None ) assert pdf.startswith(b"%PDF") + + +def test_wrap_text_keeps_a_short_text_on_one_line(): + assert wrap_text("Alex Martin", "Helvetica", 12, 60 * mm) == ["Alex Martin"] + + +def test_wrap_text_breaks_between_words_to_fit_the_width(): + # Assez large pour « Marie-Christine » seul, trop étroit pour le tout. + width = 40 * mm + lines = wrap_text("Marie-Christine de La Fontaine", "Helvetica-Bold", 12, width) + + assert len(lines) > 1 + assert " ".join(lines) == "Marie-Christine de La Fontaine" + assert all(stringWidth(line, "Helvetica-Bold", 12) <= width for line in lines) + + +def test_wrap_text_keeps_explicit_line_breaks_and_wraps_each_line(): + text = "Bar central 19:00–22:00 (Référent : Sam Nuit)\nAccueil 09:00–13:00" + width = 40 * mm + lines = wrap_text(text, "Helvetica", 8, width) + + assert lines[0].startswith("Bar central 19:00–22:00") + assert "Accueil 09:00–13:00" in lines + assert all(stringWidth(line, "Helvetica", 8) <= width for line in lines) + + +def test_wrap_text_cuts_a_single_word_too_long_for_the_frame(): + width = 10 * mm + lines = wrap_text("Anticonstitutionnellement", "Helvetica", 12, width) + + assert "".join(lines) == "Anticonstitutionnellement" + assert all(stringWidth(line, "Helvetica", 12) <= width for line in lines) + + +def _table_badge(rows: int, height_mm: float = 30) -> BadgeSpec: + return BadgeSpec( + fields=( + BadgeFieldSpec( + text="", x_mm=5, y_mm=5, width_mm=60, height_mm=height_mm, + font_size=7, align="LEFT", bold=False, color="#000000", + table=BadgeTable( + sections=( + BadgeTableSection( + title="Samedi 11", + rows=tuple( + ("19:00–22:00", "Bar central très très long") + for _ in range(rows) + ), + ), + ) + ), + ), + ) + ) + + +def test_column_widths_keep_short_columns_and_share_the_rest(): + widths = _column_widths([20, 30, 200, 100], available=200) + + assert widths[:2] == [20, 30] + assert widths[2] == widths[3] == 75 + assert sum(widths) == 200 + + +def test_column_widths_give_the_spare_room_to_the_last_column(): + assert _column_widths([20, 30, 40], available=150) == [20, 30, 100] + + +def test_table_field_generates_a_valid_pdf(): + pdf = generate_badge_sheets_pdf([_table_badge(3)], front_image=None, back_image=None) + assert pdf.startswith(b"%PDF") + + +def test_table_rows_beyond_the_frame_are_not_drawn(): + # Cadre trop petit pour 50 lignes : celles qui débordent sont omises, + # la planche fait la même taille qu'avec quelques lignes seulement. + few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None) + many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None) + assert abs(len(many) - len(few)) < 50 diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 4307ad3..3bbd004 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -355,8 +355,12 @@ "badges.fields": "Champs du recto", "badges.field.fullName": "Prénom + nom", "badges.field.fullName.placeholder": "Prénom Nom", - "badges.field.positions": "Poste(s) affecté(s)", + "badges.field.positions": "Créneaux affectés (tableau)", "badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)", + "badges.field.positions.sample1.day": "Vendredi 10", + "badges.field.positions.sample1.rows": "18:00–22:00|Bar central", + "badges.field.positions.sample2.day": "Samedi 11", + "badges.field.positions.sample2.rows": "09:00–13:00|Accueil\n23:00–01:00|Bar central", "badges.field.category": "Catégorie", "badges.field.category.placeholder": "Bar", "badges.field.role": "Rôle (référent / bénévole)", diff --git a/frontend/src/pages/BadgesPage.tsx b/frontend/src/pages/BadgesPage.tsx index 60882b3..a39fb6c 100644 --- a/frontend/src/pages/BadgesPage.tsx +++ b/frontend/src/pages/BadgesPage.tsx @@ -506,7 +506,9 @@ function FieldBox({ zIndex: selected ? 10 : undefined, }} > - {field.field_type === 'PHOTO' ? ( + {field.field_type === 'POSITIONS' ? ( + + ) : field.field_type === 'PHOTO' ? ( {t(FIELD_PLACEHOLDER_KEY.PHOTO)} @@ -536,6 +538,47 @@ function FieldBox({ ); } +/** Aperçu du champ « postes » : le jour en en-tête, puis une ligne + * horaire / poste par créneau, comme dans le PDF + * (`backend/app/badges/pdf.py:_draw_table`), avec des lignes d'exemple. */ +function PositionsTablePreview({ field, scale }: { field: FieldState; scale: number }) { + const fontSize = field.font_size * (scale / 2.83); // pt -> px approx. à l'échelle du badge + const sections = [1, 2].map((n) => ({ + title: t(`badges.field.positions.sample${n}.day`), + rows: t(`badges.field.positions.sample${n}.rows`) + .split('\n') + .map((row) => row.split('|')), + })); + const cell = 'border px-[0.8mm] py-[0.5mm] text-left align-top'; + const border = { borderColor: field.color }; + return ( + + {sections.map((section) => ( + + + + + {section.rows.map(([time, position]) => ( + + + + + ))} + + ))} +
+ {section.title} +
+ {time} + + {position} +
+ ); +} + function FieldEditorRow({ type, field,