12 KiB
Image on Solution Page Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Afficher l'image originale à côté de la grille solution sur la page solution du PDF, dans une disposition deux colonnes égales en portrait.
Architecture: NonogramPuzzle reçoit un champ image_bytes: bytes | None = None. Le use case passe image_data.content lors de la construction du puzzle. Le PDF exporter détecte la présence d'image_bytes et divise la page solution en deux colonnes égales.
Tech Stack: Python, reportlab (drawImage, ImageReader), Pillow (pour générer des images dans les tests)
Fichiers concernés
| Action | Fichier | Rôle |
|---|---|---|
| Modifier | src/logimage/domain/entities/puzzle.py |
Ajout champ image_bytes et paramètre from_grid |
| Modifier | src/logimage/application/use_cases/generate_puzzles.py |
Passe image_data.content à from_grid |
| Modifier | src/logimage/infrastructure/pdf/reportlab_exporter.py |
Layout deux colonnes sur page solution |
| Modifier | tests/domain/test_puzzle.py |
Tests du nouveau champ |
| Modifier | tests/application/test_generate_puzzles.py |
Test que les bytes sont transmis |
| Modifier | tests/infrastructure/test_reportlab_exporter.py |
Tests du rendu image |
Task 1 : NonogramPuzzle.image_bytes
Fichiers :
-
Modifier :
src/logimage/domain/entities/puzzle.py -
Modifier :
tests/domain/test_puzzle.py -
Step 1 : Écrire les tests failing
Ajouter à la fin de tests/domain/test_puzzle.py :
def test_puzzle_image_bytes_defaults_to_none() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test")
assert puzzle.image_bytes is None
def test_puzzle_image_bytes_stored() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test", b"fake-image-bytes")
assert puzzle.image_bytes == b"fake-image-bytes"
- Step 2 : Vérifier que les tests échouent
pytest tests/domain/test_puzzle.py::test_puzzle_image_bytes_defaults_to_none tests/domain/test_puzzle.py::test_puzzle_image_bytes_stored -v
Attendu : TypeError: from_grid() takes 3 positional arguments but 4 were given ou similar.
- Step 3 : Implémenter
Remplacer le contenu de src/logimage/domain/entities/puzzle.py par :
from dataclasses import dataclass
from logimage.domain.value_objects.grid import Grid
from logimage.domain.value_objects.clue import Clue
@dataclass(frozen=True)
class NonogramPuzzle:
grid: Grid
row_clues: tuple[Clue, ...]
col_clues: tuple[Clue, ...]
title: str
image_bytes: bytes | None = None
@classmethod
def from_grid(
cls,
grid: Grid,
title: str,
image_bytes: bytes | None = None,
) -> "NonogramPuzzle":
row_clues = tuple(Clue.from_row(grid.row(i)) for i in range(grid.height))
col_clues = tuple(Clue.from_row(grid.col(j)) for j in range(grid.width))
return cls(
grid=grid,
row_clues=row_clues,
col_clues=col_clues,
title=title,
image_bytes=image_bytes,
)
- Step 4 : Vérifier que les tests passent
pytest tests/domain/test_puzzle.py -v
Attendu : 8 tests PASSED.
- Step 5 : Commit
git add src/logimage/domain/entities/puzzle.py tests/domain/test_puzzle.py
git commit -m "feat: add image_bytes field to NonogramPuzzle"
Task 2 : Use case — transmission des bytes image
Fichiers :
-
Modifier :
src/logimage/application/use_cases/generate_puzzles.py -
Modifier :
tests/application/test_generate_puzzles.py -
Step 1 : Écrire le test failing
Ajouter à la fin de tests/application/test_generate_puzzles.py :
def test_puzzle_image_bytes_comes_from_image_source() -> None:
source = FakeImageSource(content=b"real-image-bytes")
exporter = FakePdfExporter()
use_case = GeneratePuzzlesUseCase(source, FakeImageConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest())
assert exporter.exported_puzzles[0].image_bytes == b"real-image-bytes"
- Step 2 : Vérifier que le test échoue
pytest tests/application/test_generate_puzzles.py::test_puzzle_image_bytes_comes_from_image_source -v
Attendu : AssertionError: assert None == b'real-image-bytes'
- Step 3 : Implémenter
Dans src/logimage/application/use_cases/generate_puzzles.py, modifier la ligne qui construit le puzzle (actuellement ligne 41) :
# Avant :
puzzle = NonogramPuzzle.from_grid(grid, image_data.title)
# Après :
puzzle = NonogramPuzzle.from_grid(grid, image_data.title, image_data.content)
- Step 4 : Vérifier que le test passe
pytest tests/application/test_generate_puzzles.py -v
Attendu : 12 tests PASSED.
- Step 5 : Commit
git add src/logimage/application/use_cases/generate_puzzles.py tests/application/test_generate_puzzles.py
git commit -m "feat: pass image bytes to NonogramPuzzle in use case"
Task 3 : PDF exporter — layout deux colonnes avec image
Fichiers :
-
Modifier :
src/logimage/infrastructure/pdf/reportlab_exporter.py -
Modifier :
tests/infrastructure/test_reportlab_exporter.py -
Step 1 : Écrire les tests failing
Ajouter en haut de tests/infrastructure/test_reportlab_exporter.py (après les imports existants) :
import io
from PIL import Image as PilImage
Ajouter un helper et deux tests à la fin du fichier :
def _make_puzzle_with_image(rows: int = 10, cols: int = 10) -> NonogramPuzzle:
buf = io.BytesIO()
PilImage.new("RGB", (40, 40), color=(100, 150, 200)).save(buf, format="JPEG")
cells = [[i % 3 == 0] * cols for i in range(rows)]
grid = Grid.from_list(cells)
return NonogramPuzzle.from_grid(grid, "Test With Image", buf.getvalue())
def test_solution_with_image_bytes_creates_larger_file() -> None:
exporter = ReportLabPdfExporter()
puzzle_no_img = _make_puzzle()
puzzle_with_img = _make_puzzle_with_image()
with tempfile.TemporaryDirectory() as tmp:
path_no_img = Path(tmp) / "no_img.pdf"
path_with_img = Path(tmp) / "with_img.pdf"
exporter.export([puzzle_no_img], path_no_img, with_solution=True)
exporter.export([puzzle_with_img], path_with_img, with_solution=True)
assert path_with_img.stat().st_size > path_no_img.stat().st_size
def test_solution_without_image_bytes_still_produces_valid_pdf() -> None:
exporter = ReportLabPdfExporter()
puzzle = _make_puzzle() # image_bytes is None
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sol.pdf"
exporter.export([puzzle], path, with_solution=True)
assert path.read_bytes()[:4] == b"%PDF"
- Step 2 : Vérifier que les tests passent déjà ou échouent selon le cas
pytest tests/infrastructure/test_reportlab_exporter.py::test_solution_with_image_bytes_creates_larger_file tests/infrastructure/test_reportlab_exporter.py::test_solution_without_image_bytes_still_produces_valid_pdf -v
Attendu : test_solution_without_image_bytes_still_produces_valid_pdf PASS (comportement inchangé), test_solution_with_image_bytes_creates_larger_file FAIL (tailles égales, image non rendue).
- Step 3 : Implémenter le layout deux colonnes dans le PDF exporter
Remplacer le contenu complet de src/logimage/infrastructure/pdf/reportlab_exporter.py par :
from io import BytesIO
from pathlib import Path
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen.canvas import Canvas
from logimage.domain.entities.puzzle import NonogramPuzzle
from logimage.domain.ports.pdf_exporter import PdfExporter
_PAGE_W, _PAGE_H = A4
_MARGIN = 20 * mm
_TITLE_H = 12 * mm
_CLUE_CELL_W = 6 * mm
_CLUE_CELL_H = 5 * mm
_CELL_SIZE_MAX = 14 * mm
_FONT_SIZE = 7
_GROUP_SIZE = 5
_GUTTER = 10 * mm
class ReportLabPdfExporter(PdfExporter):
def export(
self,
puzzles: list[NonogramPuzzle],
path: Path,
with_solution: bool = False,
) -> None:
c = Canvas(str(path), pagesize=A4)
for puzzle in puzzles:
_draw_page(c, puzzle, filled=False)
c.showPage()
if with_solution:
_draw_page(c, puzzle, filled=True)
c.showPage()
c.save()
def _draw_page(c: Canvas, puzzle: NonogramPuzzle, filled: bool) -> None:
grid = puzzle.grid
max_row_clues = max(len(cl.values) for cl in puzzle.row_clues)
max_col_clues = max(len(cl.values) for cl in puzzle.col_clues)
row_clue_w = max(max_row_clues * _CLUE_CELL_W, 10 * mm)
col_clue_h = max(max_col_clues * _CLUE_CELL_H, 5 * mm)
has_image = filled and puzzle.image_bytes is not None
if has_image:
avail_w = (_PAGE_W - 2 * _MARGIN - _GUTTER) / 2 - row_clue_w
else:
avail_w = _PAGE_W - 2 * _MARGIN - row_clue_w
avail_h = _PAGE_H - 2 * _MARGIN - _TITLE_H - col_clue_h
cell_size = min(avail_w / grid.width, avail_h / grid.height, _CELL_SIZE_MAX)
grid_w = cell_size * grid.width
grid_h = cell_size * grid.height
block_w = row_clue_w + grid_w
block_h = col_clue_h + grid_h
origin_x = _MARGIN if has_image else (_PAGE_W - block_w) / 2
origin_y = (_PAGE_H - _TITLE_H - block_h) / 2 + _TITLE_H / 2
grid_x = origin_x + row_clue_w
grid_y = origin_y
label = f"{puzzle.title} — SOLUTION" if filled else puzzle.title
c.setFont("Helvetica-Bold", 13)
c.drawCentredString(_PAGE_W / 2, origin_y + block_h + _MARGIN / 2, label)
for row in range(grid.height):
for col in range(grid.width):
x = grid_x + col * cell_size
y = grid_y + (grid.height - 1 - row) * cell_size
if filled and grid.row(row)[col]:
c.setFillColorRGB(0, 0, 0)
c.rect(x, y, cell_size, cell_size, fill=1, stroke=0)
c.setFillColorRGB(0, 0, 0)
lw = 1.5 if col % _GROUP_SIZE == 0 or col == grid.width - 1 else 0.3
c.setLineWidth(lw)
c.rect(x, y, cell_size, cell_size, fill=0, stroke=1)
row_lw = 1.5 if row % _GROUP_SIZE == 0 or row == grid.height - 1 else 0.3
c.setLineWidth(row_lw)
y_line = grid_y + (grid.height - row) * cell_size
c.line(grid_x, y_line, grid_x + grid_w, y_line)
c.setFont("Helvetica", _FONT_SIZE)
for row, clue in enumerate(puzzle.row_clues):
cell_mid_y = grid_y + (grid.height - row - 0.5) * cell_size - _FONT_SIZE * 0.35
text = " ".join(str(v) for v in clue.values) if clue.values else "0"
c.drawRightString(grid_x - 2, cell_mid_y, text)
for col, clue in enumerate(puzzle.col_clues):
cell_mid_x = grid_x + (col + 0.5) * cell_size
values = list(clue.values) if clue.values else [0]
n = len(values)
for i, val in enumerate(values):
distance_from_bottom = (n - 1 - i) * _CLUE_CELL_H
val_y = grid_y + grid_h + distance_from_bottom + _CLUE_CELL_H / 2 - _FONT_SIZE * 0.35
c.drawCentredString(cell_mid_x, val_y, str(val))
if has_image:
img_x = _MARGIN + (_PAGE_W - 2 * _MARGIN - _GUTTER) / 2 + _GUTTER
img_w = (_PAGE_W - 2 * _MARGIN - _GUTTER) / 2
reader = ImageReader(BytesIO(puzzle.image_bytes)) # type: ignore[arg-type]
c.drawImage(reader, img_x, grid_y, width=img_w, height=grid_h, preserveAspectRatio=True, anchor="c")
- Step 4 : Vérifier que les tests passent
pytest tests/infrastructure/test_reportlab_exporter.py -v
Attendu : 6 tests PASSED.
- Step 5 : Lancer la suite complète
pytest tests/ -v --ignore=tests/infrastructure/test_pexels_source.py --ignore=tests/infrastructure/test_unsplash_source.py
Attendu : tous les tests passent.
- Step 6 : Commit
git add src/logimage/infrastructure/pdf/reportlab_exporter.py tests/infrastructure/test_reportlab_exporter.py
git commit -m "feat: display original image next to solution grid on solution page"