Créateur de badges refait d'après la maquette : champs sur le recto et le verso (BadgeField.face, verso placé en miroir dans le PDF pour le recto-verso), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération. Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut, migration 0027) : champ sur la fiche, autant d'exemplaires sur les planches, total affiché à la génération, colonne dans l'export Excel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
539 lines
21 KiB
Python
539 lines
21 KiB
Python
"""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. Chaque page de recto est
|
||
suivie de sa page de verso (même image de fond à chaque emplacement rempli,
|
||
plus les champs du verso de chaque bénévole) — impression recto-verso
|
||
classique, retournement sur le bord long : sur la page de verso, chaque
|
||
badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée)
|
||
pour tomber exactement derrière son recto — voir `_cell_origin`.
|
||
"""
|
||
|
||
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.pdfbase.pdfmetrics import stringWidth
|
||
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
|
||
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
|
||
# des filets.
|
||
_CELL_PAD_X_MM = 0.8
|
||
_CELL_PAD_Y_MM = 0.5
|
||
_TABLE_LINE_WIDTH_PT = 0.4
|
||
# Réduction automatique de la taille du texte (`auto_fit`) : pas et plancher.
|
||
_AUTO_FIT_STEP_PT = 0.5
|
||
_AUTO_FIT_MIN_PT = 4.0
|
||
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
|
||
# la largeur du cadre).
|
||
_INITIALS_BG = "#E7E0CE"
|
||
_INITIALS_SIZE_RATIO = 0.28
|
||
|
||
# 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 BadgeTableSection:
|
||
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
|
||
jour), puis ses lignes (horaire, poste)."""
|
||
|
||
title: str
|
||
rows: tuple[tuple[str, ...], ...]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class BadgeTable:
|
||
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
|
||
créneaux du bénévole regroupés par jour."""
|
||
|
||
sections: tuple[BadgeTableSection, ...]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class BadgeFieldSpec:
|
||
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
|
||
possibles — ex. prénom puis nom), 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, découpée selon
|
||
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), les
|
||
initiales du bénévole (`text`) sont imprimées à la place, sur un fond
|
||
neutre de la même forme.
|
||
|
||
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
|
||
|
||
`bg_color` : fond plein de la zone du champ (`None` = transparent ;
|
||
ignoré pour la photo). `auto_fit` : réduit la taille du texte jusqu'à ce
|
||
que tout tienne dans le cadre (sinon, les lignes en trop ne sont pas
|
||
imprimées). `valign` : `TOP` ou `MIDDLE` (centré verticalement)."""
|
||
|
||
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
|
||
table: BadgeTable | None = None
|
||
bg_color: str | None = None
|
||
auto_fit: bool = False
|
||
valign: str = "TOP" # "TOP" | "MIDDLE"
|
||
photo_shape: str = "RECT" # "RECT" | "OVAL"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class BadgeSpec:
|
||
"""Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour
|
||
le recto, `back_fields` pour le verso."""
|
||
|
||
fields: tuple[BadgeFieldSpec, ...]
|
||
back_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,
|
||
mirrored: bool = False,
|
||
) -> 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.
|
||
|
||
`mirrored` : page de verso. Une feuille retournée sur son bord long
|
||
inverse gauche et droite : le verso du badge de la colonne `col` doit
|
||
être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber
|
||
derrière son recto — y compris sur une dernière planche incomplète."""
|
||
col, row = index % cols, index // cols
|
||
if mirrored:
|
||
col = cols - 1 - col
|
||
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 _frame_rect(
|
||
f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||
) -> tuple[float, float, float, float]:
|
||
"""Cadre du champ en points, repère PDF : (gauche, bas, largeur,
|
||
hauteur). `f.y_mm` est le haut du cadre depuis le HAUT du badge ;
|
||
reportlab dessine depuis le BAS de la page."""
|
||
return (
|
||
(x0_mm + f.x_mm) * mm,
|
||
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
|
||
f.width_mm * mm,
|
||
f.height_mm * mm,
|
||
)
|
||
|
||
|
||
def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: float):
|
||
path = c.beginPath()
|
||
if shape == "OVAL":
|
||
path.ellipse(x, y, w, h)
|
||
else:
|
||
path.rect(x, y, w, h)
|
||
return path
|
||
|
||
|
||
def initials(first_name: str, last_name: str) -> str:
|
||
"""Initiales imprimées à la place d'une photo absente : première lettre
|
||
du prénom et du nom, en majuscules."""
|
||
return "".join(part.strip()[:1] for part in (first_name, last_name)).upper()
|
||
|
||
|
||
def _draw_initials(c: canvas.Canvas, f: BadgeFieldSpec, x: float, y: float, w: float, h: float):
|
||
c.setFillColor(HexColor(_INITIALS_BG))
|
||
c.drawPath(_shape_path(c, f.photo_shape, x, y, w, h), stroke=0, fill=1)
|
||
if not f.text:
|
||
return
|
||
size = max(_AUTO_FIT_MIN_PT, w * _INITIALS_SIZE_RATIO)
|
||
c.setFont("Helvetica-Bold", size)
|
||
c.setFillColor(HexColor(f.color))
|
||
# Ligne de base décalée d'environ un tiers de la taille : centre
|
||
# visuel des majuscules (hauteur de capitale ≈ 0,7 × la taille).
|
||
c.drawCentredString(x + w / 2, y + h / 2 - size * 0.35, f.text)
|
||
|
||
|
||
def _draw_photo(
|
||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||
) -> None:
|
||
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
|
||
photo = _fit_photo(f.image, f.width_mm, f.height_mm) if f.image else None
|
||
c.saveState()
|
||
if photo is None:
|
||
_draw_initials(c, f, x, y, w, h)
|
||
else:
|
||
if f.photo_shape == "OVAL":
|
||
# Découpe en ellipse inscrite dans le cadre.
|
||
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
|
||
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
|
||
c.restoreState()
|
||
|
||
|
||
def _draw_zone_background(
|
||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||
) -> None:
|
||
if not f.bg_color:
|
||
return
|
||
c.saveState()
|
||
c.setFillColor(HexColor(f.bg_color))
|
||
c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1)
|
||
c.restoreState()
|
||
|
||
|
||
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
|
||
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
|
||
du texte restent des retours à la ligne, puis chaque ligne trop longue
|
||
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
|
||
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
|
||
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
|
||
|
||
def fits(candidate: str) -> bool:
|
||
return stringWidth(candidate, font_name, font_size) <= max_width_pt
|
||
|
||
lines: list[str] = []
|
||
for paragraph in text.split("\n"):
|
||
current = ""
|
||
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
|
||
# ex. entre le nom du poste et son horaire.
|
||
for word in paragraph.split(" "):
|
||
candidate = f"{current} {word}" if current else word
|
||
if fits(candidate):
|
||
current = candidate
|
||
continue
|
||
if current:
|
||
lines.append(current.rstrip())
|
||
current = word
|
||
while current and not fits(current):
|
||
cut = len(current) - 1
|
||
while cut > 1 and not fits(current[:cut]):
|
||
cut -= 1
|
||
lines.append(current[:cut])
|
||
current = current[cut:]
|
||
lines.append(current.rstrip())
|
||
return lines
|
||
|
||
|
||
def _column_widths(natural: list[float], available: float) -> list[float]:
|
||
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
|
||
courte (jour, horaire) garde sa largeur naturelle, les plus longues
|
||
(poste, référent) se partagent le reste à parts égales et passent à la
|
||
ligne. S'il reste de la place, la dernière colonne la prend."""
|
||
widths = [0.0] * len(natural)
|
||
pending = sorted(range(len(natural)), key=lambda i: natural[i])
|
||
remaining = available
|
||
while pending:
|
||
share = remaining / len(pending)
|
||
if natural[pending[0]] > share:
|
||
for i in pending:
|
||
widths[i] = share
|
||
return widths
|
||
i = pending.pop(0)
|
||
widths[i] = natural[i]
|
||
remaining -= natural[i]
|
||
widths[-1] += remaining
|
||
return widths
|
||
|
||
|
||
_TableLine = tuple[list[list[str]], str, bool, float]
|
||
|
||
|
||
def _table_layout(
|
||
table: BadgeTable, width_pt: float, font_size: float
|
||
) -> tuple[list[float], list[_TableLine]]:
|
||
"""Largeurs des colonnes et lignes du tableau à `font_size` : pour
|
||
chaque ligne, (texte replié de chaque cellule, police, en-tête de
|
||
section ?, hauteur en points)."""
|
||
regular, bold = "Helvetica", "Helvetica-Bold"
|
||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||
body = [row for section in table.sections for row in section.rows]
|
||
columns = max(len(row) for row in body) if body else 1
|
||
natural = [
|
||
max(stringWidth(row[col], regular, font_size) for row in body) + 2 * pad_x
|
||
for col in range(columns)
|
||
] if body else [0.0]
|
||
widths = _column_widths(natural, width_pt)
|
||
total_width = sum(widths)
|
||
|
||
# Un en-tête n'a qu'une cellule, sur toute la largeur.
|
||
cells_to_draw = []
|
||
for section in table.sections:
|
||
cells_to_draw.append(((section.title,), bold, True))
|
||
cells_to_draw.extend((row, regular, False) for row in section.rows)
|
||
|
||
lines: list[_TableLine] = []
|
||
for cells, font, is_title in cells_to_draw:
|
||
cell_widths = [total_width] if is_title else widths
|
||
wrapped = [
|
||
wrap_text(text, font, font_size, max(1.0, cell_widths[col] - 2 * pad_x))
|
||
for col, text in enumerate(cells)
|
||
]
|
||
row_height = max(len(w) for w in wrapped) * line_height + 2 * pad_y
|
||
lines.append((wrapped, font, is_title, row_height))
|
||
return widths, lines
|
||
|
||
|
||
def _draw_table(
|
||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||
) -> None:
|
||
"""Tableau dans le cadre du champ : pour chaque section, une ligne
|
||
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
|
||
filets à la couleur du champ, texte de chaque cellule replié dans sa
|
||
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
|
||
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
|
||
texte — sauf avec `auto_fit`, qui réduit la taille du texte jusqu'à ce
|
||
que toutes les lignes tiennent (plancher `_AUTO_FIT_MIN_PT`)."""
|
||
table = f.table
|
||
if table is None or not table.sections:
|
||
return
|
||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||
width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
|
||
font_size = f.font_size
|
||
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||
if f.auto_fit:
|
||
while (
|
||
sum(line[3] for line in lines_to_draw) > height_pt + 0.01
|
||
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||
):
|
||
font_size -= _AUTO_FIT_STEP_PT
|
||
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||
total_width = sum(widths)
|
||
|
||
left = (x0_mm + f.x_mm) * mm
|
||
top = (y0_mm + badge_height_mm - f.y_mm) * mm
|
||
bottom_limit = top - height_pt
|
||
color = HexColor(f.color)
|
||
c.setFillColor(color)
|
||
c.setStrokeColor(color)
|
||
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
|
||
|
||
y = top
|
||
for wrapped, font, is_title, row_height in lines_to_draw:
|
||
cell_widths = [total_width] if is_title else widths
|
||
if y - row_height < bottom_limit - 0.01:
|
||
break # la ligne déborderait du cadre du champ
|
||
c.setFont(font, font_size)
|
||
x = left
|
||
for col, lines in enumerate(wrapped):
|
||
for i, line in enumerate(lines):
|
||
baseline = y - pad_y - font_size * 0.85 - i * line_height
|
||
c.drawString(x + pad_x, baseline, line)
|
||
x += cell_widths[col]
|
||
if not is_title:
|
||
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
|
||
x = left
|
||
for width in widths[:-1]:
|
||
x += width
|
||
c.line(x, y, x, y - row_height)
|
||
y -= row_height
|
||
c.line(left, y, left + total_width, y)
|
||
|
||
if y == top:
|
||
return # pas même le premier en-tête ne tient
|
||
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
|
||
|
||
|
||
def _lines_that_fit(font_size: float, height_mm: float) -> int:
|
||
"""Nombre de lignes de texte qui tiennent dans `height_mm` (première
|
||
ligne de base à 0,85 × la taille sous le haut du cadre, puis un
|
||
interligne par ligne)."""
|
||
font_size_mm = font_size / mm
|
||
first = font_size_mm * 0.85
|
||
if first > height_mm + 1e-9:
|
||
return 0
|
||
return 1 + int((height_mm - first) / (font_size_mm * _LINE_HEIGHT_FACTOR) + 1e-9)
|
||
|
||
|
||
def fit_text(
|
||
text: str, font_name: str, font_size: float, width_mm: float, height_mm: float,
|
||
auto_fit: bool,
|
||
) -> tuple[float, list[str]]:
|
||
"""Taille retenue et lignes à imprimer d'un champ texte. Sans
|
||
`auto_fit` : la taille du champ, lignes en trop retirées. Avec : taille
|
||
réduite par pas de `_AUTO_FIT_STEP_PT` (plancher `_AUTO_FIT_MIN_PT`)
|
||
jusqu'à ce que toutes les lignes repliées tiennent dans le cadre."""
|
||
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||
if auto_fit:
|
||
while (
|
||
len(lines) > _lines_that_fit(font_size, height_mm)
|
||
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||
):
|
||
font_size -= _AUTO_FIT_STEP_PT
|
||
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||
return font_size, lines[: _lines_that_fit(font_size, height_mm)]
|
||
|
||
|
||
def _draw_text(
|
||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||
) -> None:
|
||
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
|
||
font_size, lines = fit_text(
|
||
f.text, font_name, f.font_size, f.width_mm, f.height_mm, f.auto_fit
|
||
)
|
||
if not lines:
|
||
return
|
||
c.setFont(font_name, font_size)
|
||
c.setFillColor(HexColor(f.color))
|
||
left_x = x0_mm + f.x_mm
|
||
# `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 = 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
|
||
if f.valign == "MIDDLE":
|
||
# Hauteur du bloc : interlignes entre les lignes + une hauteur de
|
||
# caractère pour la dernière.
|
||
block = (len(lines) - 1) * line_height_mm + font_size_mm
|
||
top_y -= max(0.0, (f.height_mm - block) / 2)
|
||
for i, line in enumerate(lines):
|
||
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
|
||
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 _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
|
||
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
|
||
if f.table is not None:
|
||
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
|
||
else:
|
||
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
|
||
|
||
|
||
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, badge in enumerate(page_badges):
|
||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
|
||
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
|
||
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm)
|
||
c.showPage()
|
||
c.save()
|
||
return buffer.getvalue()
|