359 lines
12 KiB
Markdown
359 lines
12 KiB
Markdown
# 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"
|
|
```
|