"""Génération des planches de badges (module pur, sans base).""" 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, DEFAULT_BADGE_HEIGHT_MM, 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, ) 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") 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