Espace libre réparti à parts égales (mêmes écarts à gauche, entre les badges et à droite ; en haut, entre les rangées et en bas) : grille symétrique, le verso imprimé en miroir tombe exactement derrière le recto au lieu d'être décalé de l'espace restant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
563 lines
22 KiB
Python
563 lines
22 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
|
||
# Minimums pour compter combien de badges tiennent sur la page (marge
|
||
# d'impression, écart entre deux badges) ; l'espace effectivement laissé est
|
||
# ensuite réparti à parts égales — voir `_spacing`.
|
||
_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).
|
||
|
||
# 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), rien
|
||
n'est imprimé : le cadre reste vide.
|
||
|
||
`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 _spacing(count: int, page_mm: float, size_mm: float) -> tuple[float, float]:
|
||
"""`(marge extérieure, écart entre badges)` sur un axe, en mm : l'espace
|
||
libre est réparti **à parts égales** — même écart à gauche, entre chaque
|
||
badge et à droite (resp. en haut, entre les rangées et en bas) — pour une
|
||
grille centrée et symétrique. Si cet écart passait sous la marge
|
||
d'impression (`_MARGIN_MM`), la marge est gardée de chaque côté et le
|
||
reste réparti entre les badges : toujours symétrique. Avant, la grille
|
||
partait du coin haut-gauche et tout l'espace restant tombait à droite et
|
||
en bas ; une fois la feuille retournée, le verso se décalait d'autant par
|
||
rapport au recto (bug corrigé une fois)."""
|
||
free = page_mm - count * size_mm
|
||
even = free / (count + 1)
|
||
if even >= _MARGIN_MM or count == 1:
|
||
return (even, even) if count > 1 else (free / 2, 0.0)
|
||
return _MARGIN_MM, (free - 2 * _MARGIN_MM) / (count - 1)
|
||
|
||
|
||
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
|
||
rows = _grid_size(badge_width_mm, badge_height_mm)[1]
|
||
outer_x, gap_x = _spacing(cols, _PAGE_WIDTH_MM, badge_width_mm)
|
||
outer_y, gap_y = _spacing(rows, _PAGE_HEIGHT_MM, badge_height_mm)
|
||
x0 = outer_x + col * (badge_width_mm + gap_x)
|
||
y0 = _PAGE_HEIGHT_MM - outer_y - (row + 1) * badge_height_mm - row * gap_y
|
||
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:
|
||
# JPEG : décodage directement à taille réduite (bien plus rapide
|
||
# qu'une photo de téléphone décodée en pleine résolution).
|
||
img.draft("RGB", (target[0] * 2, target[1] * 2))
|
||
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
|
||
|
||
|
||
# Photos déjà recadrées pendant une génération, par (photo, largeur,
|
||
# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
|
||
# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
|
||
# fois.
|
||
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
|
||
|
||
|
||
def _draw_photo(
|
||
c: canvas.Canvas,
|
||
f: BadgeFieldSpec,
|
||
x0_mm: float,
|
||
y0_mm: float,
|
||
badge_height_mm: float,
|
||
cache: PhotoCache,
|
||
) -> None:
|
||
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
|
||
photo = None
|
||
if f.image:
|
||
key = (f.image, f.width_mm, f.height_mm)
|
||
if key not in cache:
|
||
cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm)
|
||
photo = cache[key]
|
||
if photo is None:
|
||
# Pas de photo (ou illisible) : cadre laissé vide, choix de la
|
||
# coordination — avant, les initiales étaient imprimées à la place.
|
||
return
|
||
c.saveState()
|
||
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,
|
||
photos: PhotoCache,
|
||
) -> None:
|
||
for f in fields:
|
||
if f.is_photo:
|
||
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
|
||
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)
|
||
photos: PhotoCache = {}
|
||
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, photos)
|
||
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, photos)
|
||
c.showPage()
|
||
c.save()
|
||
return buffer.getvalue()
|