import initial

This commit is contained in:
Vincent Bourdon
2026-06-10 10:21:18 +02:00
commit 5a03f8a38d
59 changed files with 4777 additions and 0 deletions
@@ -0,0 +1,358 @@
# 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"
```