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>
437 lines
16 KiB
Python
437 lines
16 KiB
Python
"""Génération des planches de badges (module pur, sans base)."""
|
||
|
||
import io
|
||
|
||
import pytest
|
||
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,
|
||
_spacing,
|
||
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
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Éditeur v2 : verso, fond de zone, réduction automatique, photo #
|
||
# --------------------------------------------------------------------------- #
|
||
from app.badges import pdf as pdf_module # noqa: E402
|
||
from app.badges.pdf import fit_text # noqa: E402
|
||
|
||
|
||
def _text_field(**kw) -> BadgeFieldSpec:
|
||
base = dict(
|
||
text="Alex", x_mm=5, y_mm=5, width_mm=60, height_mm=10,
|
||
font_size=12, align="LEFT", bold=False, color="#000000",
|
||
)
|
||
base.update(kw)
|
||
return BadgeFieldSpec(**base)
|
||
|
||
|
||
def test_cell_origin_mirrors_the_column_on_the_back_page():
|
||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||
# Retournement bord long : colonne 0 au recto ⇒ dernière colonne au
|
||
# verso, même rangée.
|
||
assert _cell_origin(0, cols, w, h, mirrored=True) == _cell_origin(cols - 1, cols, w, h)
|
||
assert _cell_origin(cols, cols, w, h, mirrored=True) == _cell_origin(
|
||
2 * cols - 1, cols, w, h
|
||
)
|
||
|
||
|
||
def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeypatch):
|
||
calls = []
|
||
original = pdf_module._draw_fields
|
||
|
||
def spy(c, fields, x0, y0, badge_height_mm, photos):
|
||
calls.append((fields, x0, y0))
|
||
original(c, fields, x0, y0, badge_height_mm, photos)
|
||
|
||
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
|
||
back = (_text_field(text="Verso Alex"),)
|
||
# Un seul badge : planche incomplète, le verso doit quand même être en
|
||
# miroir (colonne de droite).
|
||
generate_badge_sheets_pdf(
|
||
[BadgeSpec(fields=(_text_field(),), back_fields=back)], None, None
|
||
)
|
||
|
||
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||
(front_fields, fx, fy), (back_fields, bx, by) = calls
|
||
assert (fx, fy) == _cell_origin(0, cols, w, h)
|
||
assert back_fields == back
|
||
assert (bx, by) == _cell_origin(cols - 1, cols, w, h)
|
||
|
||
|
||
def test_fit_text_without_auto_fit_drops_the_lines_that_overflow():
|
||
text = "\n".join(["ligne"] * 10)
|
||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)
|
||
assert size == 12
|
||
assert 0 < len(lines) < 10
|
||
|
||
|
||
def test_fit_text_with_auto_fit_shrinks_until_every_line_fits():
|
||
text = "\n".join(["ligne"] * 4)
|
||
assert len(fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)[1]) < 4
|
||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||
assert 4 < size < 12
|
||
assert (size * 2) == int(size * 2) # pas de 0,5 pt
|
||
assert len(lines) == 4
|
||
|
||
|
||
def test_fit_text_auto_fit_stops_at_the_minimum_size():
|
||
text = "\n".join(["ligne"] * 200)
|
||
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||
assert size == 4.0
|
||
assert len(lines) < 200
|
||
|
||
|
||
def test_fit_text_auto_fit_keeps_the_size_when_the_text_already_fits():
|
||
assert fit_text("Alex", "Helvetica", 12, 60, 10, auto_fit=True) == (12, ["Alex"])
|
||
|
||
|
||
def _table_lines_drawn(monkeypatch, auto_fit: bool) -> int:
|
||
drawn = []
|
||
monkeypatch.setattr(
|
||
pdf_module.canvas.Canvas, "drawString",
|
||
lambda self, x, y, text, *a, **k: drawn.append(text),
|
||
)
|
||
badge = _table_badge(4, height_mm=20)
|
||
field = badge.fields[0]
|
||
badge = BadgeSpec(fields=(BadgeFieldSpec(**{**field.__dict__, "auto_fit": auto_fit}),))
|
||
generate_badge_sheets_pdf([badge], None, None)
|
||
return sum(1 for text in drawn if text == "19:00–22:00")
|
||
|
||
|
||
def test_table_auto_fit_draws_every_row(monkeypatch):
|
||
# En-tête + 4 lignes : la dernière ne tient pas à 7 pt dans 20 mm.
|
||
assert _table_lines_drawn(monkeypatch, auto_fit=False) < 4
|
||
assert _table_lines_drawn(monkeypatch, auto_fit=True) == 4
|
||
|
||
|
||
def _filled_rects(monkeypatch) -> list:
|
||
fills = []
|
||
original = pdf_module.canvas.Canvas.rect
|
||
|
||
def spy(self, x, y, w, h, stroke=1, fill=0):
|
||
if fill:
|
||
fills.append((round(w / mm, 3), round(h / mm, 3)))
|
||
return original(self, x, y, w, h, stroke=stroke, fill=fill)
|
||
|
||
monkeypatch.setattr(pdf_module.canvas.Canvas, "rect", spy)
|
||
return fills
|
||
|
||
|
||
def test_zone_background_fills_the_field_frame(monkeypatch):
|
||
fills = _filled_rects(monkeypatch)
|
||
generate_badge_sheets_pdf(
|
||
[BadgeSpec(fields=(_text_field(bg_color="#FFD23F"),))], None, None
|
||
)
|
||
assert fills == [(60, 10)]
|
||
|
||
|
||
def test_no_zone_background_by_default(monkeypatch):
|
||
fills = _filled_rects(monkeypatch)
|
||
generate_badge_sheets_pdf([BadgeSpec(fields=(_text_field(),))], None, None)
|
||
assert fills == []
|
||
|
||
|
||
def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
|
||
baselines = []
|
||
monkeypatch.setattr(
|
||
pdf_module.canvas.Canvas, "drawString",
|
||
lambda self, x, y, text, *a, **k: baselines.append(y),
|
||
)
|
||
for valign in ("TOP", "MIDDLE"):
|
||
generate_badge_sheets_pdf(
|
||
[BadgeSpec(fields=(_text_field(height_mm=30, valign=valign),))], None, None
|
||
)
|
||
top, middle = baselines
|
||
assert middle < top
|
||
|
||
|
||
def test_missing_photo_prints_nothing(monkeypatch):
|
||
calls = []
|
||
for name in ("drawImage", "drawPath", "drawCentredString", "drawString"):
|
||
monkeypatch.setattr(
|
||
pdf_module.canvas.Canvas, name,
|
||
lambda self, *a, _name=name, **k: calls.append(_name),
|
||
)
|
||
for image in (None, b"pas une image"):
|
||
generate_badge_sheets_pdf([_photo_badge(image)], None, None)
|
||
assert calls == []
|
||
|
||
|
||
def test_oval_photo_and_missing_oval_photo_generate_a_valid_pdf():
|
||
for image in (_jpeg(400, 500), None):
|
||
field = _photo_badge(image).fields[0]
|
||
badge = BadgeSpec(
|
||
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL"}),)
|
||
)
|
||
pdf = generate_badge_sheets_pdf([badge], None, None)
|
||
assert pdf.startswith(b"%PDF")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"size", [(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM), (54, 85.6), (50, 50), (74, 105)]
|
||
)
|
||
def test_grid_is_centred_with_equal_gaps_and_back_falls_behind_front(size):
|
||
from app.badges.pdf import _PAGE_HEIGHT_MM, _PAGE_WIDTH_MM
|
||
|
||
w, h = size
|
||
cols, rows = _grid_size(w, h)
|
||
xs = sorted({_cell_origin(i, cols, w, h)[0] for i in range(cols * rows)})
|
||
ys = sorted({_cell_origin(i, cols, w, h)[1] for i in range(cols * rows)})
|
||
left, right = xs[0], _PAGE_WIDTH_MM - (xs[-1] + w)
|
||
bottom, top = ys[0], _PAGE_HEIGHT_MM - (ys[-1] + h)
|
||
# Symétrique : même marge à gauche et à droite, en haut et en bas.
|
||
assert left == pytest.approx(right)
|
||
assert top == pytest.approx(bottom)
|
||
# Écarts entre badges tous égaux, et égaux aux marges quand c'est possible.
|
||
gaps_x = {round(b - a - w, 6) for a, b in zip(xs, xs[1:], strict=False)}
|
||
gaps_y = {round(b - a - h, 6) for a, b in zip(ys, ys[1:], strict=False)}
|
||
assert len(gaps_x) <= 1 and len(gaps_y) <= 1
|
||
_, gap_x = _spacing(cols, _PAGE_WIDTH_MM, w)
|
||
if gap_x >= 10 and cols > 1:
|
||
assert left == pytest.approx(gap_x)
|
||
# Verso : exactement derrière le recto une fois la feuille retournée
|
||
# sur son bord long (miroir horizontal).
|
||
for i in range(cols * rows):
|
||
fx, fy = _cell_origin(i, cols, w, h)
|
||
bx, by = _cell_origin(i, cols, w, h, mirrored=True)
|
||
assert bx == pytest.approx(_PAGE_WIDTH_MM - fx - w)
|
||
assert by == pytest.approx(fy)
|
||
|
||
|
||
def test_cr80_gaps_are_even():
|
||
# 2 × 85,6 mm sur 210 mm : 12,9 mm à gauche, entre les deux, à droite.
|
||
assert _spacing(2, 210, 85.6) == pytest.approx((12.9333, 12.9333), abs=1e-3)
|