Planches de badges centrées, verso aligné sur le recto
Espace libre réparti à parts égales (mêmes écarts à gauche, entre les badges et à droite ; en haut, entre les rangées et 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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
05cdb0276b
commit
3754cfc69a
@@ -273,7 +273,14 @@ les tables sauf les tables d'association.
|
|||||||
champs `BACK` de chaque bénévole) — impression recto-verso classique
|
champs `BACK` de chaque bénévole) — impression recto-verso classique
|
||||||
(retournement bord long) : sur la page de verso, chaque badge est placé
|
(retournement bord long) : sur la page de verso, chaque badge est placé
|
||||||
**en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored=
|
**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
|
est personnalisé ; avant, un verso identique pour tous masquait le
|
||||||
décalage.
|
décalage.
|
||||||
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
|
- **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 |
|
| 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 |
|
| 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 |
|
| 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
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, IA. Frontend : squelette + 6 écrans +
|
de formulaire, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ from reportlab.pdfgen import canvas
|
|||||||
|
|
||||||
DEFAULT_BADGE_WIDTH_MM = 85.6
|
DEFAULT_BADGE_WIDTH_MM = 85.6
|
||||||
DEFAULT_BADGE_HEIGHT_MM = 54.0
|
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
|
_MARGIN_MM = 10.0
|
||||||
_GUTTER_MM = 4.0
|
_GUTTER_MM = 4.0
|
||||||
_LINE_HEIGHT_FACTOR = 1.25
|
_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
|
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(
|
def _cell_origin(
|
||||||
index: int,
|
index: int,
|
||||||
cols: int,
|
cols: int,
|
||||||
@@ -146,8 +166,11 @@ def _cell_origin(
|
|||||||
col, row = index % cols, index // cols
|
col, row = index % cols, index // cols
|
||||||
if mirrored:
|
if mirrored:
|
||||||
col = cols - 1 - col
|
col = cols - 1 - col
|
||||||
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM)
|
rows = _grid_size(badge_width_mm, badge_height_mm)[1]
|
||||||
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM
|
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
|
return x0, y0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||||
@@ -18,6 +19,7 @@ from app.badges.pdf import (
|
|||||||
_column_widths,
|
_column_widths,
|
||||||
_fit_photo,
|
_fit_photo,
|
||||||
_grid_size,
|
_grid_size,
|
||||||
|
_spacing,
|
||||||
generate_badge_sheets_pdf,
|
generate_badge_sheets_pdf,
|
||||||
image_size_mm,
|
image_size_mm,
|
||||||
wrap_text,
|
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)
|
pdf = generate_badge_sheets_pdf([badge], None, None)
|
||||||
assert pdf.startswith(b"%PDF")
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user