# 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` : ```python 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** ```bash 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 : ```python 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** ```bash pytest tests/domain/test_puzzle.py -v ``` Attendu : 8 tests PASSED. - [ ] **Step 5 : Commit** ```bash 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` : ```python 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** ```bash 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) : ```python # 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** ```bash pytest tests/application/test_generate_puzzles.py -v ``` Attendu : 12 tests PASSED. - [ ] **Step 5 : Commit** ```bash 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) : ```python import io from PIL import Image as PilImage ``` Ajouter un helper et deux tests à la fin du fichier : ```python 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** ```bash 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 : ```python 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** ```bash pytest tests/infrastructure/test_reportlab_exporter.py -v ``` Attendu : 6 tests PASSED. - [ ] **Step 5 : Lancer la suite complète** ```bash 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** ```bash 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" ```