From 5c2cc0a6c3fa920ee25337d41e95fefbd35f598e Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 28 Oct 2026 19:22:46 +0100 Subject: [PATCH] =?UTF-8?q?Feuilles=20de=20poste=20compact=C3=A9es=20:=20u?= =?UTF-8?q?ne=20page=20par=20poste=20et=20par=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés), titres, en-têtes de créneau et marges resserrés — 6 feuilles réalistes : 11 pages ⇒ 6. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- backend/app/sheets/pdf.py | 96 ++++++++++++++++------- backend/tests/test_position_sheets_pdf.py | 26 +++++- 3 files changed, 97 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 860f7a4..50f1d12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,13 +280,16 @@ les tables sauf les tables d'association. téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour avec les bénévoles affectés et leur téléphone, pour affichage sur place. `POST /editions/{id}/position-sheets/generate` (COORDINATION, - `{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune + `{position_ids?}` — absent = tous). Mise en page compacte, pour tenir + sur une page par poste et par jour autant que possible : bénévoles **deux + par ligne** (vignette de 10 mm, nom en gras et téléphone dessous), + en-têtes et marges resserrés ; un créneau à besoin 0/0 sans aucune affectation n'apparaît pas (rien à imprimer). Le **message de l'organisation** (`Edition.volunteer_message`, s'il y en a un) est imprimé en pied de **chaque** page, suite d'une feuille comprise (`SheetFooter`, marge du bas agrandie de sa hauteur pour que le contenu ne passe jamais dessous). Photo de chaque bénévole en - vignette de 14 mm : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`, + vignette (10 mm) : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`, décodage JPEG à taille réduite via `draft`) une seule fois par PDF, une photo illisible laissant la case vide. Avant l'étape 62, la photo d'origine (plusieurs Mo, prise au téléphone) était intégrée telle quelle @@ -1062,6 +1065,7 @@ Aucune dépendance locale requise hors Docker. | 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider | | 63 | Feuilles de poste : message de l'organisation en bas de chaque page (titre + texte, retours à la ligne conservés), rien si aucun message | à valider | | 64 | Vue par bénévole : barre de recherche sur le nom du bénévole ou d'un de ses postes (sans accents ni casse), postes trouvés cernés d'orange, cumulable avec les filtres et le tri | à valider | +| 65 | Feuilles de poste compactées : bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés), titres, en-têtes de créneau et marges resserrés — sur un jeu réaliste de 6 feuilles, 11 pages ⇒ 6 (une par poste et par jour) | à 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/sheets/pdf.py b/backend/app/sheets/pdf.py index 4d7975f..c00b023 100644 --- a/backend/app/sheets/pdf.py +++ b/backend/app/sheets/pdf.py @@ -30,9 +30,12 @@ from reportlab.platypus import ( TableStyle, ) -_PHOTO_BOX_MM = 14 * mm -# Côté de la vignette en pixels : 14 mm à 300 dpi. -_PHOTO_BOX_PX = round(14 / 25.4 * 300) +# Mise en page compacte (une feuille = une page autant que possible) : +# vignette de 10 mm, deux bénévoles par ligne. +_PHOTO_BOX_MM = 10 * mm +# Côté de la vignette en pixels : 10 mm à 300 dpi. +_PHOTO_BOX_PX = round(10 / 25.4 * 300) +_PER_ROW = 2 _WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"] _MONTHS_FR = [ @@ -94,26 +97,36 @@ def _styles() -> dict[str, ParagraphStyle]: base = getSampleStyleSheet() return { "title": ParagraphStyle( - "SheetTitle", parent=base["Title"], alignment=0, fontSize=22, spaceAfter=2 + "SheetTitle", parent=base["Title"], alignment=0, + fontSize=18, leading=21, spaceBefore=0, spaceAfter=1, ), "subtitle": ParagraphStyle( "SheetSubtitle", parent=base["Normal"], - fontSize=12, textColor=colors.grey, spaceAfter=4, + fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2, ), "referent": ParagraphStyle( "SheetReferent", parent=base["Normal"], - fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6, + fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3, ), "artists": ParagraphStyle( "SheetArtists", parent=base["Normal"], - fontSize=9.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=16, + fontSize=8.5, leading=10.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=4, ), "shiftHeader": ParagraphStyle( - "ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4 + "ShiftHeader", parent=base["Heading3"], + fontSize=11, leading=13, spaceBefore=7, spaceAfter=3, ), "shiftHeaderWarning": ParagraphStyle( "ShiftHeaderWarning", parent=base["Heading3"], - fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"), + fontSize=11, leading=13, spaceBefore=7, spaceAfter=3, + textColor=colors.HexColor("#b23a1f"), + ), + "volunteerName": ParagraphStyle( + "SheetVolunteerName", parent=base["Normal"], + fontName="Helvetica-Bold", fontSize=9.5, leading=11.5, + ), + "volunteerPhone": ParagraphStyle( + "SheetVolunteerPhone", parent=base["Normal"], fontSize=9, leading=11, ), "footerTitle": ParagraphStyle( "SheetFooterTitle", parent=base["Normal"], @@ -136,8 +149,9 @@ class SheetFooter: message: str -_MARGIN_X = 18 * mm -_MARGIN_BOTTOM = 16 * mm +_MARGIN_X = 15 * mm +_MARGIN_TOP = 12 * mm +_MARGIN_BOTTOM = 12 * mm # Écart entre le bas du contenu et le filet du pied de page. _FOOTER_GAP = 5 * mm @@ -186,25 +200,47 @@ def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale) -def _shift_table(shift: SheetShift, cache: dict[bytes, tuple[bytes, int, int] | None]) -> Table: - rows = [["", "Bénévole", "Téléphone"]] + [ - [_photo_cell(v.photo, cache), v.name, v.phone or "—"] +def _shift_table( + shift: SheetShift, + cache: dict[bytes, tuple[bytes, int, int] | None], + styles: dict[str, ParagraphStyle], + width: float, +) -> Table: + """Bénévoles du créneau, deux par ligne : vignette, puis nom (gras) et + téléphone l'un sous l'autre. Deux fois moins de lignes qu'une liste à + une personne par ligne (avant l'étape 65, souvent deux pages par + feuille).""" + photo_w = _PHOTO_BOX_MM + 3 * mm + text_w = width / _PER_ROW - photo_w + cells = [ + [ + _photo_cell(v.photo, cache), + [ + Paragraph(escape(v.name), styles["volunteerName"]), + Paragraph(escape(v.phone or "—"), styles["volunteerPhone"]), + ], + ] for v in shift.volunteers ] - table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm]) - table.setStyle( - TableStyle( - [ - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("FONTSIZE", (0, 0), (-1, -1), 10.5), - ("TOPPADDING", (0, 0), (-1, -1), 5), - ("BOTTOMPADDING", (0, 0), (-1, -1), 5), - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black), - ("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey), - ] - ) - ) + rows = [] + for i in range(0, len(cells), _PER_ROW): + chunk = cells[i : i + _PER_ROW] + chunk += [["", ""]] * (_PER_ROW - len(chunk)) + rows.append([cell for pair in chunk for cell in pair]) + table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW) + style = [ + ("TOPPADDING", (0, 0), (-1, -1), 2), + ("BOTTOMPADDING", (0, 0), (-1, -1), 2), + ("LEFTPADDING", (0, 0), (-1, -1), 3), + ("RIGHTPADDING", (0, 0), (-1, -1), 3), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LINEBELOW", (0, 0), (-1, -1), 0.25, colors.lightgrey), + ("LINEABOVE", (0, 0), (-1, 0), 0.25, colors.lightgrey), + ] + # Filet vertical entre les deux bénévoles d'une même ligne. + for col in range(2, 2 * _PER_ROW, 2): + style.append(("LINEBEFORE", (col, 0), (col, -1), 0.25, colors.lightgrey)) + table.setStyle(TableStyle(style)) return table @@ -221,7 +257,7 @@ def generate_position_sheets_pdf( footer_height = sum(p.wrap(width, A4[1])[1] for p in paragraphs) doc = SimpleDocTemplate( buffer, pagesize=A4, - leftMargin=_MARGIN_X, rightMargin=_MARGIN_X, topMargin=16 * mm, + leftMargin=_MARGIN_X, rightMargin=_MARGIN_X, topMargin=_MARGIN_TOP, bottomMargin=_MARGIN_BOTTOM + (footer_height + _FOOTER_GAP if paragraphs else 0), ) @@ -279,7 +315,7 @@ def generate_position_sheets_pdf( style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"] story.append(Paragraph(header, style)) if shift.volunteers: - story.append(_shift_table(shift, thumbnails)) + story.append(_shift_table(shift, thumbnails, styles, width)) else: story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"])) story.append(Spacer(1, 0)) diff --git a/backend/tests/test_position_sheets_pdf.py b/backend/tests/test_position_sheets_pdf.py index a810508..b7496fb 100644 --- a/backend/tests/test_position_sheets_pdf.py +++ b/backend/tests/test_position_sheets_pdf.py @@ -186,7 +186,7 @@ def test_footer_message_is_printed_at_the_bottom_of_every_page(): from app.sheets.pdf import SheetFooter # Deux feuilles, dont une assez longue pour déborder sur une 2e page. - many = tuple(SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=None) for i in range(40)) + many = tuple(SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=None) for i in range(120)) pdf = generate_position_sheets_pdf( [_sheet(volunteers=many), _sheet()], SheetFooter(title="Message de l'organisation", message="Repas à 18h30\nBadges à l'accueil"), @@ -202,3 +202,27 @@ def test_footer_message_is_printed_at_the_bottom_of_every_page(): def test_no_footer_without_message(): pages = _page_texts(generate_position_sheets_pdf([_sheet()])) assert "Message de l'organisation" not in pages[0] + + +def test_volunteers_are_listed_two_per_row(): + from app.sheets.pdf import _shift_table, _styles + + shift = _sheet( + volunteers=tuple(SheetVolunteer(name=f"B{i}", phone=None, photo=None) for i in range(5)) + ).shifts[0] + table = _shift_table(shift, {}, _styles(), 180) + # 5 bénévoles, deux par ligne : 3 lignes (la dernière à moitié vide). + assert len(table._cellvalues) == 3 + + +def test_a_typical_sheet_fits_on_one_page(): + volunteers = tuple( + SheetVolunteer(name=f"Prénom{i} Nom{i}", phone="06 12 34 56 78", photo=_PNG) + for i in range(8) + ) + shift = _sheet(volunteers=volunteers).shifts[0] + sheet = _sheet(volunteers=volunteers) + many_shifts = PositionDaySheet( + **{**sheet.__dict__, "shifts": (shift, shift, shift, shift)} + ) + assert len(_page_texts(generate_position_sheets_pdf([many_shifts]))) == 1