diff --git a/CLAUDE.md b/CLAUDE.md index 4c5f907..cab739e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,7 +273,14 @@ les tables sauf les tables d'association. champs `BACK` de chaque bénévole) — impression recto-verso classique (retournement bord long) : sur la page de verso, chaque badge est placé **en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored= - True)`) pour tomber derrière son recto. Indispensable depuis que le verso + True)`) pour tomber derrière son recto. La grille est **centrée et symétrique** sur la + page (`app/badges/pdf.py:_spacing`) : l'espace libre est réparti à parts + égales — même écart à gauche, entre les badges et à droite, et de même en + haut, entre les rangées et en bas (CR80 sur A4 : 12,9 mm et 16,2 mm), + marge d'impression de 10 mm au moins ; une fois la feuille retournée, + chaque verso tombe exactement derrière son recto. Avant l'étape 71, la + grille partait du coin haut-gauche et l'espace restant tombait à droite + et en bas, d'où un verso décalé (bug corrigé une fois). Indispensable depuis que le verso est personnalisé ; avant, un verso identique pour tous masquait le décalage. - **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) : @@ -1119,6 +1126,7 @@ Aucune dépendance locale requise hors Docker. | 68 | L'email de test est le récapitulatif de planning compilé tel qu'envoyé aux bénévoles (modèle en cours d'édition, variables remplies avec le planning d'un bénévole au choix, option lien de connexion — sans vrai jeton —, sujet « [Test] … ») | à valider | | 69 | Récapitulatif de planning : `{creneaux}` devient un tableau par jour de festival (horaire, poste, référent avec son téléphone), en HTML dans l'email et en lignes lisibles dans la version texte — envoi réel et email de test | à valider | | 70 | `{lien_connexion}` devient un lien de connexion directe permanent (réutilisable, sans expiration, signé HMAC, rien de stocké — avant : lien magique de 30 min à usage unique, ou simple lien vers le site sans compte) ; compte bénévole créé à l'envoi s'il manque ; révocation par « Renouveler le lien de connexion » sur la fiche (`Account.login_link_version`, migration `0031`) ; jamais pour la coordination | à valider | +| 71 | Planches de badges centrées : espace libre réparti à parts égales (mêmes écarts à gauche, entre les badges, à droite ; en haut, entre les rangées, en bas), grille symétrique — le verso, imprimé en miroir, tombe exactement derrière le recto au lieu d'être décalé de l'espace restant | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/badges/pdf.py b/backend/app/badges/pdf.py index e22c405..0d778a7 100644 --- a/backend/app/badges/pdf.py +++ b/backend/app/badges/pdf.py @@ -29,6 +29,9 @@ from reportlab.pdfgen import canvas DEFAULT_BADGE_WIDTH_MM = 85.6 DEFAULT_BADGE_HEIGHT_MM = 54.0 +# Minimums pour compter combien de badges tiennent sur la page (marge +# d'impression, écart entre deux badges) ; l'espace effectivement laissé est +# ensuite réparti à parts égales — voir `_spacing`. _MARGIN_MM = 10.0 _GUTTER_MM = 4.0 _LINE_HEIGHT_FACTOR = 1.25 @@ -129,6 +132,23 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int] return cols, rows +def _spacing(count: int, page_mm: float, size_mm: float) -> tuple[float, float]: + """`(marge extérieure, écart entre badges)` sur un axe, en mm : l'espace + libre est réparti **à parts égales** — même écart à gauche, entre chaque + badge et à droite (resp. en haut, entre les rangées et en bas) — pour une + grille centrée et symétrique. Si cet écart passait sous la marge + d'impression (`_MARGIN_MM`), la marge est gardée de chaque côté et le + reste réparti entre les badges : toujours symétrique. Avant, la grille + partait du coin haut-gauche et tout l'espace restant tombait à droite et + en bas ; une fois la feuille retournée, le verso se décalait d'autant par + rapport au recto (bug corrigé une fois).""" + free = page_mm - count * size_mm + even = free / (count + 1) + if even >= _MARGIN_MM or count == 1: + return (even, even) if count > 1 else (free / 2, 0.0) + return _MARGIN_MM, (free - 2 * _MARGIN_MM) / (count - 1) + + def _cell_origin( index: int, cols: int, @@ -146,8 +166,11 @@ def _cell_origin( col, row = index % cols, index // cols if mirrored: col = cols - 1 - col - x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM) - y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM + rows = _grid_size(badge_width_mm, badge_height_mm)[1] + outer_x, gap_x = _spacing(cols, _PAGE_WIDTH_MM, badge_width_mm) + outer_y, gap_y = _spacing(rows, _PAGE_HEIGHT_MM, badge_height_mm) + x0 = outer_x + col * (badge_width_mm + gap_x) + y0 = _PAGE_HEIGHT_MM - outer_y - (row + 1) * badge_height_mm - row * gap_y return x0, y0 diff --git a/backend/tests/test_badges_pdf.py b/backend/tests/test_badges_pdf.py index 8b9b4eb..064621a 100644 --- a/backend/tests/test_badges_pdf.py +++ b/backend/tests/test_badges_pdf.py @@ -2,6 +2,7 @@ import io +import pytest from PIL import Image from reportlab.lib.units import mm from reportlab.pdfbase.pdfmetrics import stringWidth @@ -18,6 +19,7 @@ from app.badges.pdf import ( _column_widths, _fit_photo, _grid_size, + _spacing, generate_badge_sheets_pdf, image_size_mm, wrap_text, @@ -396,3 +398,39 @@ def test_oval_photo_and_missing_oval_photo_generate_a_valid_pdf(): ) pdf = generate_badge_sheets_pdf([badge], None, None) assert pdf.startswith(b"%PDF") + + +@pytest.mark.parametrize( + "size", [(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM), (54, 85.6), (50, 50), (74, 105)] +) +def test_grid_is_centred_with_equal_gaps_and_back_falls_behind_front(size): + from app.badges.pdf import _PAGE_HEIGHT_MM, _PAGE_WIDTH_MM + + w, h = size + cols, rows = _grid_size(w, h) + xs = sorted({_cell_origin(i, cols, w, h)[0] for i in range(cols * rows)}) + ys = sorted({_cell_origin(i, cols, w, h)[1] for i in range(cols * rows)}) + left, right = xs[0], _PAGE_WIDTH_MM - (xs[-1] + w) + bottom, top = ys[0], _PAGE_HEIGHT_MM - (ys[-1] + h) + # Symétrique : même marge à gauche et à droite, en haut et en bas. + assert left == pytest.approx(right) + assert top == pytest.approx(bottom) + # Écarts entre badges tous égaux, et égaux aux marges quand c'est possible. + gaps_x = {round(b - a - w, 6) for a, b in zip(xs, xs[1:], strict=False)} + gaps_y = {round(b - a - h, 6) for a, b in zip(ys, ys[1:], strict=False)} + assert len(gaps_x) <= 1 and len(gaps_y) <= 1 + _, gap_x = _spacing(cols, _PAGE_WIDTH_MM, w) + if gap_x >= 10 and cols > 1: + assert left == pytest.approx(gap_x) + # Verso : exactement derrière le recto une fois la feuille retournée + # sur son bord long (miroir horizontal). + for i in range(cols * rows): + fx, fy = _cell_origin(i, cols, w, h) + bx, by = _cell_origin(i, cols, w, h, mirrored=True) + assert bx == pytest.approx(_PAGE_WIDTH_MM - fx - w) + assert by == pytest.approx(fy) + + +def test_cr80_gaps_are_even(): + # 2 × 85,6 mm sur 210 mm : 12,9 mm à gauche, entre les deux, à droite. + assert _spacing(2, 210, 85.6) == pytest.approx((12.9333, 12.9333), abs=1e-3)