Badges : retour à la ligne et créneaux en tableau par jour
Le texte des champs passe à la ligne pour tenir dans la largeur de son cadre (wrap_text) au lieu de déborder. Les créneaux affectés s'affichent en tableau : le jour de festival en en-tête, puis une colonne horaire et une colonne poste (plus de référent sur le badge) ; même tableau dans l'aperçu de l'éditeur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cfed76c239
commit
1a23881e81
+158
-3
@@ -23,6 +23,7 @@ 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
|
||||
@@ -30,6 +31,11 @@ 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é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
|
||||
@@ -47,6 +53,23 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
|
||||
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`
|
||||
@@ -55,7 +78,9 @@ class BadgeFieldSpec:
|
||||
|
||||
`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é."""
|
||||
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.
|
||||
|
||||
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`."""
|
||||
|
||||
text: str
|
||||
x_mm: float
|
||||
@@ -68,6 +93,7 @@ class BadgeFieldSpec:
|
||||
color: str # "#rrggbb"
|
||||
is_photo: bool = False
|
||||
image: bytes | None = None
|
||||
table: BadgeTable | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -162,6 +188,130 @@ def _draw_photo(
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
table = f.table
|
||||
if table is None or not table.sections:
|
||||
return
|
||||
regular, bold = "Helvetica", "Helvetica-Bold"
|
||||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||
line_height = f.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, f.font_size) for row in body) + 2 * pad_x
|
||||
for col in range(columns)
|
||||
] if body else [0.0]
|
||||
widths = _column_widths(natural, f.width_mm * mm)
|
||||
total_width = sum(widths)
|
||||
|
||||
# (cellules, police, en-tête de section ?) — un en-tête n'a qu'une
|
||||
# cellule, sur toute la largeur.
|
||||
lines_to_draw = []
|
||||
for section in table.sections:
|
||||
lines_to_draw.append(((section.title,), bold, True))
|
||||
lines_to_draw.extend((row, regular, False) for row in section.rows)
|
||||
|
||||
left = (x0_mm + f.x_mm) * mm
|
||||
top = (y0_mm + badge_height_mm - f.y_mm) * mm
|
||||
bottom_limit = top - f.height_mm * mm
|
||||
color = HexColor(f.color)
|
||||
c.setFillColor(color)
|
||||
c.setStrokeColor(color)
|
||||
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
|
||||
|
||||
y = top
|
||||
for cells, font, is_title in lines_to_draw:
|
||||
cell_widths = [total_width] if is_title else widths
|
||||
wrapped = [
|
||||
wrap_text(text, font, f.font_size, max(1.0, cell_widths[col] - 2 * pad_x))
|
||||
for col, text in enumerate(cells)
|
||||
]
|
||||
row_height = max(len(lines) for lines in wrapped) * line_height + 2 * pad_y
|
||||
if y - row_height < bottom_limit - 0.01:
|
||||
break # la ligne déborderait du cadre du champ
|
||||
c.setFont(font, f.font_size)
|
||||
x = left
|
||||
for col, lines in enumerate(wrapped):
|
||||
for i, line in enumerate(lines):
|
||||
baseline = y - pad_y - f.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 _draw_fields(
|
||||
c: canvas.Canvas,
|
||||
fields: tuple[BadgeFieldSpec, ...],
|
||||
@@ -173,7 +323,11 @@ def _draw_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)
|
||||
if f.table is not None:
|
||||
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
continue
|
||||
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
|
||||
c.setFont(font_name, 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
|
||||
@@ -185,7 +339,8 @@ def _draw_fields(
|
||||
# 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")):
|
||||
lines = wrap_text(f.text, font_name, f.font_size, f.width_mm * mm)
|
||||
for i, line in enumerate(lines):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user