Files
logimage-generator/docs/superpowers/plans/2026-05-20-image-on-solution-page.md
T
Vincent Bourdon 5a03f8a38d import initial
2026-06-10 10:21:18 +02:00

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"