import initial
This commit is contained in:
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user