"""Génération des planches de badges bénévoles (PDF). Module isolé, fonction pure (`generate_badge_sheets_pdf`) : aucun accès base ici — voir `app/api/badges.py` pour l'assemblage des données (bénévole → champs résolus en texte). Format du badge : dérivé de l'image de fond du recto (pas de format fixe imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée, `DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la taille standard des badges/cartes d'événement) servent de repère à l'éditeur. Planche : grille sur page A4 portrait. Le verso est identique pour tous les bénévoles : la planche imprime toutes les pages de recto (personnalisées) puis, pour chacune, la page de verso correspondante (même disposition, même image répétée à chaque emplacement rempli) — impression recto-verso classique, page de verso n directement après la page de recto n. """ import io from dataclasses import dataclass from PIL import Image, ImageOps, UnidentifiedImageError from reportlab.lib.colors import HexColor from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas DEFAULT_BADGE_WIDTH_MM = 85.6 DEFAULT_BADGE_HEIGHT_MM = 54.0 _MARGIN_MM = 10.0 _GUTTER_MM = 4.0 _LINE_HEIGHT_FACTOR = 1.25 # Résolution supposée d'une image de fond importée, pour convertir ses pixels # en millimètres réels (donc en taille de badge) — 300 dpi est la résolution # d'impression standard. ASSUMED_DPI = 300.0 _PAGE_WIDTH_MM = A4[0] / mm _PAGE_HEIGHT_MM = A4[1] / mm def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tuple[float, float]: """Taille physique (mm) d'une image, en supposant qu'elle a été exportée à `dpi` — c'est cette taille qui devient celle du badge (voir `app/api/badges.py:set_front_image`).""" return (width_px / dpi * 25.4, height_px / dpi * 25.4) @dataclass(frozen=True) class BadgeFieldSpec: """Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n` possibles — ex. un poste par ligne avec son horaire), positionné depuis le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end). `is_photo` : le champ est la photo du bénévole (`image`, ses octets bruts tels que stockés) plutôt qu'un texte — `text` et le style sont alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.""" text: str x_mm: float y_mm: float width_mm: float height_mm: float font_size: float align: str # "LEFT" | "CENTER" | "RIGHT" bold: bool color: str # "#rrggbb" is_photo: bool = False image: bytes | None = None @dataclass(frozen=True) class BadgeSpec: """Un badge = un bénévole, avec ses champs déjà résolus.""" fields: tuple[BadgeFieldSpec, ...] def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]: usable_w = _PAGE_WIDTH_MM - 2 * _MARGIN_MM usable_h = _PAGE_HEIGHT_MM - 2 * _MARGIN_MM cols = max(1, int((usable_w + _GUTTER_MM) // (badge_width_mm + _GUTTER_MM))) rows = max(1, int((usable_h + _GUTTER_MM) // (badge_height_mm + _GUTTER_MM))) return cols, rows def _cell_origin( index: int, cols: int, badge_width_mm: float, badge_height_mm: float ) -> tuple[float, float]: """Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page, en mm depuis le coin bas-gauche de la page.""" col, row = index % cols, index // cols x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM) y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM return x0, y0 def _draw_background( c: canvas.Canvas, image: bytes | None, x0_mm: float, y0_mm: float, badge_width_mm: float, badge_height_mm: float, ) -> None: if not image: return c.drawImage( ImageReader(io.BytesIO(image)), x0_mm * mm, y0_mm * mm, width=badge_width_mm * mm, height=badge_height_mm * mm, # Le format du badge est désormais dérivé de l'image du recto (voir # `image_size_mm`), donc déjà au bon ratio ; `True` évite en plus # toute déformation du verso s'il n'a pas exactement le même ratio. preserveAspectRatio=True, anchor="c", mask="auto", ) def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None: """Recadre la photo au ratio du cadre (centrée, sans déformation — une photo de téléphone n'a jamais le ratio du cadre) et la réduit à la résolution d'impression : une photo de plusieurs Mo répétée sur 200 badges alourdirait inutilement le PDF. Applique l'orientation EXIF (une photo prise en portrait au téléphone est souvent stockée couchée). `None` si l'image est illisible : le badge s'imprime sans photo plutôt que de faire échouer toute la planche.""" target = ( max(1, round(width_mm / 25.4 * ASSUMED_DPI)), max(1, round(height_mm / 25.4 * ASSUMED_DPI)), ) try: with Image.open(io.BytesIO(image)) as img: fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target) except (UnidentifiedImageError, OSError): return None out = io.BytesIO() fitted.save(out, format="JPEG", quality=88) return out.getvalue() def _draw_photo( c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float ) -> None: if not f.image: return photo = _fit_photo(f.image, f.width_mm, f.height_mm) if photo is None: return c.drawImage( ImageReader(io.BytesIO(photo)), (x0_mm + f.x_mm) * mm, # `f.y_mm` est le haut du cadre depuis le HAUT du badge ; reportlab # place l'image par son coin bas-gauche, depuis le BAS de la page. (y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm, width=f.width_mm * mm, height=f.height_mm * mm, ) def _draw_fields( c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], x0_mm: float, y0_mm: float, badge_height_mm: float, ) -> None: for f in fields: if f.is_photo: _draw_photo(c, f, x0_mm, y0_mm, badge_height_mm) continue c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size) c.setFillColor(HexColor(f.color)) left_x = x0_mm + f.x_mm # `f.font_size` est en points (unité reportlab) ; le reste du calcul # travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant # ici le facteur de conversion pt/mm de reportlab). font_size_mm = f.font_size / mm line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR # `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab # dessine depuis le BAS de la page. top_y = y0_mm + badge_height_mm - f.y_mm bottom_limit = top_y - f.height_mm for i, line in enumerate(f.text.split("\n")): baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm if baseline_y < bottom_limit: break # la ligne déborderait de la boîte du champ if f.align == "CENTER": c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line) elif f.align == "RIGHT": c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line) else: c.drawString(left_x * mm, baseline_y * mm, line) def generate_badge_sheets_pdf( badges: list[BadgeSpec], front_image: bytes | None, back_image: bytes | None, badge_width_mm: float = DEFAULT_BADGE_WIDTH_MM, badge_height_mm: float = DEFAULT_BADGE_HEIGHT_MM, ) -> bytes: """`badges` : un par bénévole, dans l'ordre d'impression. Retourne les octets du PDF (recto puis verso, planche par planche).""" cols, rows = _grid_size(badge_width_mm, badge_height_mm) per_page = cols * rows pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)] buffer = io.BytesIO() c = canvas.Canvas(buffer, pagesize=A4) for page_badges in pages: for i, badge in enumerate(page_badges): x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) _draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm) _draw_fields(c, badge.fields, x0, y0, badge_height_mm) c.showPage() for i in range(len(page_badges)): x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) _draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm) c.showPage() c.save() return buffer.getvalue()