Files
benevoles/backend/tests/test_badges_pdf.py
T
AdamThomasandClaude Opus 5.5 54cba467fc Badges : photo du bénévole et texte libre
Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au
cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans
photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte
du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024),
repris dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:12:09 +01:00

154 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Génération des planches de badges (module pur, sans base)."""
import io
from PIL import Image
from app.badges.pdf import (
ASSUMED_DPI,
DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec,
BadgeSpec,
_cell_origin,
_fit_photo,
_grid_size,
generate_badge_sheets_pdf,
image_size_mm,
)
def _badge(text="Alex Martin") -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text=text,
x_mm=5,
y_mm=5,
width_mm=60,
height_mm=10,
font_size=12,
align="LEFT",
bold=True,
color="#000000",
),
)
)
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():
# 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
# (277 mm utiles / 58 mm par badge+gouttière).
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():
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
x0, y0 = _cell_origin(0, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
x1, _ = _cell_origin(1, cols, DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
_, 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 y_row2 < y0 # la 2e rangée est plus bas (repère PDF : Y croît vers le haut)
def test_generates_a_valid_pdf_for_a_single_badge():
pdf = generate_badge_sheets_pdf([_badge()], front_image=None, back_image=None)
assert pdf.startswith(b"%PDF")
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():
cols, rows = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
per_page = cols * rows
one_page = generate_badge_sheets_pdf(
[_badge() for _ in range(per_page)], front_image=None, back_image=None
)
# Un badge de plus déborde sur une 2e planche (recto + verso en plus).
two_pages = generate_badge_sheets_pdf(
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
)
assert len(two_pages) > len(one_page)
def _jpeg(width_px: int, height_px: int) -> bytes:
out = io.BytesIO()
Image.new("RGB", (width_px, height_px), "red").save(out, format="JPEG")
return out.getvalue()
def _photo_badge(image: bytes | None) -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text="", x_mm=60, y_mm=5, width_mm=20, height_mm=25,
font_size=8, align="LEFT", bold=False, color="#000000",
is_photo=True, image=image,
),
)
)
def test_fit_photo_crops_to_the_frame_ratio_at_print_resolution():
# Photo paysage 4000×3000 dans un cadre portrait 20×25 mm : recadrée
# (pas déformée) et réduite à 300 dpi.
fitted = _fit_photo(_jpeg(4000, 3000), width_mm=20, height_mm=25)
with Image.open(io.BytesIO(fitted)) as img:
assert img.size == (round(20 / 25.4 * ASSUMED_DPI), round(25 / 25.4 * ASSUMED_DPI))
def test_fit_photo_returns_none_for_an_unreadable_image():
assert _fit_photo(b"pas une image", width_mm=20, height_mm=25) is None
def test_photo_field_embeds_the_volunteer_photo():
without = generate_badge_sheets_pdf([_photo_badge(None)], front_image=None, back_image=None)
with_photo = generate_badge_sheets_pdf(
[_photo_badge(_jpeg(400, 500))], front_image=None, back_image=None
)
assert without.startswith(b"%PDF")
assert b"/Subtype /Image" in with_photo
assert b"/Subtype /Image" not in without
def test_unreadable_photo_does_not_crash_generation():
pdf = generate_badge_sheets_pdf(
[_photo_badge(b"pas une image")], front_image=None, back_image=None
)
assert pdf.startswith(b"%PDF")