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"
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,341 @@
# Pexels Image Source 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:** Ajouter `PexelsImageSource` et router automatiquement vers Pexels ou Unsplash selon les clés d'API disponibles dans l'environnement.
**Architecture:** Le port `ImageSource` (ABC) existe déjà. On ajoute une implémentation `PexelsImageSource` dans l'infrastructure, puis on modifie `main.py` pour instancier le bon provider selon `PEXELS_API_KEY` (prioritaire) ou `UNSPLASH_ACCESS_KEY`.
**Tech Stack:** Python, httpx, pytest, pytest-mock
---
## Fichiers concernés
| Action | Fichier | Rôle |
|----------|------------------------------------------------------------------|-------------------------------------|
| Créer | `src/logimage/infrastructure/image/pexels_source.py` | Implémentation Pexels de ImageSource |
| Créer | `tests/infrastructure/test_pexels_source.py` | Tests d'intégration Pexels |
| Créer | `tests/cli/__init__.py` | Package tests CLI |
| Créer | `tests/cli/test_main_routing.py` | Tests unitaires du routing CLI |
| Modifier | `src/logimage/cli/main.py` | Routing selon les vars d'env |
---
### Task 1 : PexelsImageSource
**Fichiers :**
- Créer : `src/logimage/infrastructure/image/pexels_source.py`
- Créer : `tests/infrastructure/test_pexels_source.py`
- [ ] **Step 1 : Écrire les tests d'intégration (failing)**
Créer `tests/infrastructure/test_pexels_source.py` :
```python
import os
import pytest
from logimage.infrastructure.image.pexels_source import PexelsImageSource
from logimage.domain.value_objects.image_data import ImageData
@pytest.mark.integration
def test_fetch_returns_image_data() -> None:
api_key = os.environ.get("PEXELS_API_KEY")
if not api_key:
pytest.skip("PEXELS_API_KEY not set")
source = PexelsImageSource(api_key=api_key)
result = source.fetch()
assert isinstance(result, ImageData)
assert len(result.content) > 1000
assert isinstance(result.title, str)
@pytest.mark.integration
def test_fetch_with_theme_returns_image_data() -> None:
api_key = os.environ.get("PEXELS_API_KEY")
if not api_key:
pytest.skip("PEXELS_API_KEY not set")
source = PexelsImageSource(api_key=api_key)
result = source.fetch(theme="forest")
assert isinstance(result, ImageData)
assert len(result.content) > 1000
assert isinstance(result.title, str)
```
- [ ] **Step 2 : Vérifier que les tests échouent**
```bash
pytest tests/infrastructure/test_pexels_source.py -v -m integration
```
Attendu : `ImportError: cannot import name 'PexelsImageSource'`
- [ ] **Step 3 : Implémenter PexelsImageSource**
Créer `src/logimage/infrastructure/image/pexels_source.py` :
```python
import httpx
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.value_objects.image_data import ImageData
_SEARCH_URL = "https://api.pexels.com/v1/search"
_CURATED_URL = "https://api.pexels.com/v1/curated"
class PexelsImageSource(ImageSource):
def __init__(self, api_key: str) -> None:
self._api_key = api_key
def fetch(self, theme: str | None = None) -> ImageData:
headers = {"Authorization": self._api_key}
params: dict[str, str | int] = {"per_page": 1}
if theme:
url = _SEARCH_URL
params["query"] = theme
else:
url = _CURATED_URL
with httpx.Client(timeout=30.0) as client:
response = client.get(url, headers=headers, params=params)
response.raise_for_status()
photos = response.json()["photos"]
if not photos:
raise ValueError(f"No photos found for theme={theme!r}")
photo = photos[0]
image_url = photo["src"]["large"]
title: str = photo.get("alt") or theme or "logimage"
image_response = client.get(image_url, timeout=60.0)
image_response.raise_for_status()
return ImageData(content=image_response.content, title=title)
```
- [ ] **Step 4 : Vérifier que les tests passent**
```bash
pytest tests/infrastructure/test_pexels_source.py -v -m integration
```
Attendu : 2 tests PASSED (ou SKIPPED si `PEXELS_API_KEY` non définie)
- [ ] **Step 5 : Commit**
```bash
git add src/logimage/infrastructure/image/pexels_source.py tests/infrastructure/test_pexels_source.py
git commit -m "feat: add PexelsImageSource"
```
---
### Task 2 : Routing CLI
**Fichiers :**
- Modifier : `src/logimage/cli/main.py`
- Créer : `tests/cli/__init__.py`
- Créer : `tests/cli/test_main_routing.py`
- [ ] **Step 1 : Écrire les tests unitaires du routing (failing)**
Créer `tests/cli/__init__.py` (vide).
Créer `tests/cli/test_main_routing.py` :
```python
import pytest
from unittest.mock import patch
from logimage.cli.main import main
from logimage.infrastructure.image.pexels_source import PexelsImageSource
from logimage.infrastructure.image.unsplash_source import UnsplashImageSource
def test_pexels_key_uses_pexels_source(monkeypatch) -> None:
monkeypatch.setenv("PEXELS_API_KEY", "pexels-test-key")
monkeypatch.delenv("UNSPLASH_ACCESS_KEY", raising=False)
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], PexelsImageSource)
def test_unsplash_key_uses_unsplash_source(monkeypatch) -> None:
monkeypatch.delenv("PEXELS_API_KEY", raising=False)
monkeypatch.setenv("UNSPLASH_ACCESS_KEY", "unsplash-test-key")
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], UnsplashImageSource)
def test_pexels_takes_priority_over_unsplash(monkeypatch) -> None:
monkeypatch.setenv("PEXELS_API_KEY", "pexels-test-key")
monkeypatch.setenv("UNSPLASH_ACCESS_KEY", "unsplash-test-key")
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], PexelsImageSource)
def test_no_key_exits_with_error(monkeypatch, capsys) -> None:
monkeypatch.delenv("PEXELS_API_KEY", raising=False)
monkeypatch.delenv("UNSPLASH_ACCESS_KEY", raising=False)
with patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "PEXELS_API_KEY" in captured.err
assert "UNSPLASH_ACCESS_KEY" in captured.err
```
- [ ] **Step 2 : Vérifier que les tests échouent**
```bash
pytest tests/cli/test_main_routing.py -v
```
Attendu : tests échouent (la logique de routing n'est pas encore implémentée)
- [ ] **Step 3 : Modifier `main.py` pour le routing**
Remplacer dans `src/logimage/cli/main.py` le bloc d'import et de construction du `image_source` :
```python
import argparse
import sys
from pathlib import Path
from dotenv import load_dotenv
import os
from logimage.application.use_cases.generate_puzzles import (
GeneratePuzzlesRequest,
GeneratePuzzlesUseCase,
)
from logimage.infrastructure.image.pexels_source import PexelsImageSource
from logimage.infrastructure.image.unsplash_source import UnsplashImageSource
from logimage.infrastructure.image.pillow_converter import PillowImageConverter
from logimage.infrastructure.pdf.reportlab_exporter import ReportLabPdfExporter
def _parse_size(value: str) -> tuple[int, int]:
parts = value.lower().split("x")
if len(parts) != 2:
raise argparse.ArgumentTypeError(f"Size must be WxH (e.g. 20x15), got '{value}'")
try:
w, h = int(parts[0]), int(parts[1])
except ValueError:
raise argparse.ArgumentTypeError(f"Width and height must be integers, got '{value}'")
return w, h
def main() -> None:
load_dotenv()
parser = argparse.ArgumentParser(
description="Generate nonogram (logimage) puzzles as a printable PDF."
)
parser.add_argument("--theme", help="Image search keyword (default: random)")
parser.add_argument(
"--difficulty",
choices=["easy", "medium", "hard"],
default="medium",
help="Grid size preset: easy=10×10, medium=15×15, hard=20×20 (default: medium)",
)
parser.add_argument(
"--size",
type=_parse_size,
metavar="WxH",
help="Custom grid size, e.g. 20x25 (overrides --difficulty)",
)
parser.add_argument("--count", type=int, default=1, help="Number of puzzles (default: 1)")
parser.add_argument(
"--solution",
action="store_true",
help="Append a solution page after each puzzle",
)
parser.add_argument(
"--output",
type=Path,
default=Path("puzzles.pdf"),
metavar="PATH",
help="Output PDF path (default: puzzles.pdf)",
)
args = parser.parse_args()
pexels_key = os.environ.get("PEXELS_API_KEY")
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY")
if pexels_key:
image_source = PexelsImageSource(api_key=pexels_key)
elif unsplash_key:
image_source = UnsplashImageSource(api_key=unsplash_key)
else:
print(
"Error: no image API key found.\n"
"Set PEXELS_API_KEY or UNSPLASH_ACCESS_KEY in your .env file.",
file=sys.stderr,
)
sys.exit(1)
use_case = GeneratePuzzlesUseCase(
image_source=image_source,
image_converter=PillowImageConverter(),
pdf_exporter=ReportLabPdfExporter(),
)
request = GeneratePuzzlesRequest(
count=args.count,
difficulty=args.difficulty,
size=args.size,
theme=args.theme,
output_path=args.output,
with_solution=args.solution,
)
print(f"Generating {args.count} puzzle(s)…")
use_case.execute(request)
print(f"Done! PDF saved to: {args.output}")
```
- [ ] **Step 4 : Vérifier que les tests passent**
```bash
pytest tests/cli/test_main_routing.py -v
```
Attendu : 4 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 (les tests d'intégration réseau sont exclus)
- [ ] **Step 6 : Commit**
```bash
git add src/logimage/cli/main.py tests/cli/__init__.py tests/cli/test_main_routing.py
git commit -m "feat: route image source based on available API key (Pexels first, Unsplash fallback)"
```
@@ -0,0 +1,583 @@
# Pipeline de conversion amélioré — 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:** Remplacer le pipeline Canny+blend+Otsu par un pipeline bilateral+posterize+Otsu+cleanup morphologique qui garantit ≤ 6 blocs par ligne/colonne.
**Architecture:** Le `PillowImageConverter` reçoit des paramètres configurables (`max_clue_blocks`, `min_component_size`, `max_cleanup_iterations`) et applique une boucle de nettoyage morphologique jusqu'à ce que la complexité des clues soit acceptable. Une `LocalFileImageSource` permet de tester avec des images locales sans appel API.
**Tech Stack:** Python 3.12, OpenCV (`cv2`), NumPy, Pillow
---
## Fichiers
- Modifier: `src/logimage/infrastructure/image/pillow_converter.py`
- Créer: `src/logimage/infrastructure/image/local_file_source.py`
- Modifier: `src/logimage/cli/main.py`
- Modifier: `tests/infrastructure/test_pillow_converter.py`
- Créer: `tests/fixtures/simple_icon.png` (généré dans les tests)
---
### Task 1 : Nouveau pipeline `PillowImageConverter`
**Files:**
- Modify: `src/logimage/infrastructure/image/pillow_converter.py`
- Modify: `tests/infrastructure/test_pillow_converter.py`
- [ ] **Step 1: Écrire les tests qui couvrent le nouveau comportement**
Remplacer le contenu de `tests/infrastructure/test_pillow_converter.py` par :
```python
import io
import numpy as np
from PIL import Image, ImageDraw
import pytest
from logimage.infrastructure.image.pillow_converter import PillowImageConverter
from logimage.domain.value_objects.grid import Grid
def _make_jpeg_bytes(width: int = 100, height: int = 100, color: tuple[int, int, int] = (128, 64, 192)) -> bytes:
img = Image.new("RGB", (width, height), color=color)
buf = io.BytesIO()
img.save(buf, format="JPEG")
return buf.getvalue()
def _make_silhouette_bytes(width: int = 100, height: int = 100) -> bytes:
"""Image avec un carré noir centré sur fond blanc."""
img = Image.new("RGB", (width, height), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
margin = width // 4
draw.rectangle([margin, margin, width - margin, height - margin], fill=(0, 0, 0))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def test_output_grid_has_correct_dimensions() -> None:
converter = PillowImageConverter()
grid = converter.to_grid(_make_jpeg_bytes(), width=10, height=10)
assert grid.width == 10
assert grid.height == 10
def test_output_is_grid_instance() -> None:
converter = PillowImageConverter()
result = converter.to_grid(_make_jpeg_bytes(), width=15, height=15)
assert isinstance(result, Grid)
def test_output_cells_are_booleans() -> None:
converter = PillowImageConverter()
grid = converter.to_grid(_make_jpeg_bytes(), width=10, height=10)
for row_idx in range(grid.height):
for cell in grid.row(row_idx):
assert isinstance(cell, bool)
def test_black_image_produces_all_true_cells() -> None:
img = Image.new("RGB", (100, 100), color=(0, 0, 0))
buf = io.BytesIO()
img.save(buf, format="JPEG")
converter = PillowImageConverter()
grid = converter.to_grid(buf.getvalue(), width=10, height=10)
total_true = sum(cell for row in range(grid.height) for cell in grid.row(row))
assert total_true > 0
def test_white_image_produces_mostly_false_cells() -> None:
img = Image.new("RGB", (100, 100), color=(255, 255, 255))
buf = io.BytesIO()
img.save(buf, format="JPEG")
converter = PillowImageConverter()
grid = converter.to_grid(buf.getvalue(), width=10, height=10)
total_true = sum(cell for row in range(grid.height) for cell in grid.row(row))
assert total_true < grid.width * grid.height
def test_max_clue_blocks_respected_on_silhouette() -> None:
"""Une silhouette simple doit produire ≤ max_clue_blocks blocs par ligne/colonne."""
converter = PillowImageConverter(max_clue_blocks=6)
grid = converter.to_grid(_make_silhouette_bytes(), width=20, height=20)
for i in range(grid.height):
blocks = _count_blocks(grid.row(i))
assert blocks <= 6, f"Ligne {i} a {blocks} blocs (max=6)"
for j in range(grid.width):
blocks = _count_blocks(grid.col(j))
assert blocks <= 6, f"Colonne {j} a {blocks} blocs (max=6)"
def test_custom_max_clue_blocks() -> None:
"""max_clue_blocks=3 produit une grille encore plus simple."""
converter = PillowImageConverter(max_clue_blocks=3)
grid = converter.to_grid(_make_silhouette_bytes(), width=15, height=15)
for i in range(grid.height):
assert _count_blocks(grid.row(i)) <= 3
for j in range(grid.width):
assert _count_blocks(grid.col(j)) <= 3
def test_accepts_png_bytes() -> None:
converter = PillowImageConverter()
grid = converter.to_grid(_make_silhouette_bytes(), width=10, height=10)
assert grid.width == 10
def _count_blocks(line: tuple[bool, ...]) -> int:
blocks = 0
in_block = False
for cell in line:
if cell:
if not in_block:
blocks += 1
in_block = True
else:
in_block = False
return blocks
```
- [ ] **Step 2: Vérifier que les nouveaux tests échouent**
```bash
pytest tests/infrastructure/test_pillow_converter.py -v
```
Attendu : `test_max_clue_blocks_respected_on_silhouette` et `test_custom_max_clue_blocks` FAIL (le pipeline actuel ne garantit pas le seuil).
- [ ] **Step 3: Implémenter le nouveau pipeline**
Remplacer le contenu de `src/logimage/infrastructure/image/pillow_converter.py` par :
```python
import io
import cv2
import numpy as np
from PIL import Image
from logimage.domain.ports.image_converter import ImageConverter
from logimage.domain.value_objects.grid import Grid
class PillowImageConverter(ImageConverter):
def __init__(
self,
max_clue_blocks: int = 6,
min_component_size: int = 2,
max_cleanup_iterations: int = 3,
) -> None:
self.max_clue_blocks = max_clue_blocks
self.min_component_size = min_component_size
self.max_cleanup_iterations = max_cleanup_iterations
def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid:
img = Image.open(io.BytesIO(image_bytes)).convert("L")
img = img.resize((width, height), Image.LANCZOS)
arr = np.array(img, dtype=np.uint8)
filtered = cv2.bilateralFilter(arr, d=9, sigmaColor=75, sigmaSpace=75)
median = int(np.median(filtered))
posterized = np.where(filtered < median, np.uint8(0), np.uint8(255))
_, binary = cv2.threshold(
posterized, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
binary = self._remove_small_components(binary)
kernel_size = 3
for _ in range(self.max_cleanup_iterations):
if self._max_blocks(binary) <= self.max_clue_blocks:
break
kernel_size += 1
k = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_size, kernel_size))
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, k)
binary = self._remove_small_components(binary)
cells = [
[bool(binary[y, x]) for x in range(width)]
for y in range(height)
]
return Grid.from_list(cells)
def _remove_small_components(self, binary: np.ndarray) -> np.ndarray:
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
binary, connectivity=8
)
result = np.zeros_like(binary)
for label in range(1, num_labels):
if stats[label, cv2.CC_STAT_AREA] >= self.min_component_size:
result[labels == label] = 255
return result
def _max_blocks(self, binary: np.ndarray) -> int:
max_b = 0
for row in binary:
max_b = max(max_b, self._count_blocks(row))
for col in binary.T:
max_b = max(max_b, self._count_blocks(col))
return max_b
def _count_blocks(self, line: np.ndarray) -> int:
blocks = 0
in_block = False
for pixel in line:
if pixel > 0:
if not in_block:
blocks += 1
in_block = True
else:
in_block = False
return blocks
```
- [ ] **Step 4: Vérifier que tous les tests passent**
```bash
pytest tests/infrastructure/test_pillow_converter.py -v
```
Attendu : tous PASS.
- [ ] **Step 5: Lancer la suite complète**
```bash
pytest -v
```
Attendu : tous PASS.
- [ ] **Step 6: Commit**
```bash
git add src/logimage/infrastructure/image/pillow_converter.py tests/infrastructure/test_pillow_converter.py
git commit -m "feat: improve conversion pipeline with bilateral filter, posterize and morphological cleanup"
```
---
### Task 2 : `LocalFileImageSource`
**Files:**
- Create: `src/logimage/infrastructure/image/local_file_source.py`
- Create: `tests/infrastructure/test_local_file_source.py`
- [ ] **Step 1: Écrire le test**
Créer `tests/infrastructure/test_local_file_source.py` :
```python
import io
import pytest
from pathlib import Path
from PIL import Image
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
from logimage.domain.value_objects.image_data import ImageData
def test_fetch_returns_image_data(tmp_path: Path) -> None:
img = Image.new("RGB", (50, 50), color=(100, 150, 200))
path = tmp_path / "test_icon.png"
img.save(path, format="PNG")
source = LocalFileImageSource(path)
result = source.fetch()
assert isinstance(result, ImageData)
assert result.content == path.read_bytes()
assert result.title == "test_icon"
def test_fetch_title_is_stem(tmp_path: Path) -> None:
img = Image.new("RGB", (10, 10))
path = tmp_path / "my_cool_image.jpg"
img.save(path, format="JPEG")
source = LocalFileImageSource(path)
result = source.fetch()
assert result.title == "my_cool_image"
def test_fetch_raises_if_file_not_found() -> None:
source = LocalFileImageSource(Path("/nonexistent/image.png"))
with pytest.raises(FileNotFoundError):
source.fetch()
```
- [ ] **Step 2: Vérifier que les tests échouent**
```bash
pytest tests/infrastructure/test_local_file_source.py -v
```
Attendu : FAIL (module inexistant).
- [ ] **Step 3: Implémenter `LocalFileImageSource`**
Créer `src/logimage/infrastructure/image/local_file_source.py` :
```python
from pathlib import Path
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.value_objects.image_data import ImageData
class LocalFileImageSource(ImageSource):
def __init__(self, path: Path) -> None:
self._path = path
def fetch(self, theme: str | None = None) -> ImageData:
if not self._path.exists():
raise FileNotFoundError(f"Image not found: {self._path}")
return ImageData(content=self._path.read_bytes(), title=self._path.stem)
```
- [ ] **Step 4: Vérifier que les tests passent**
```bash
pytest tests/infrastructure/test_local_file_source.py -v
```
Attendu : tous PASS.
- [ ] **Step 5: Lancer la suite complète**
```bash
pytest -v
```
Attendu : tous PASS.
- [ ] **Step 6: Commit**
```bash
git add src/logimage/infrastructure/image/local_file_source.py tests/infrastructure/test_local_file_source.py
git commit -m "feat: add LocalFileImageSource for local image input"
```
---
### Task 3 : Option `--local-image` dans le CLI
**Files:**
- Modify: `src/logimage/cli/main.py`
- Modify: `tests/cli/test_main_routing.py`
- [ ] **Step 1: Lire les tests CLI existants pour comprendre le pattern**
```bash
cat -n tests/cli/test_main_routing.py
```
- [ ] **Step 2: Écrire le test pour `--local-image`**
Ajouter à `tests/cli/test_main_routing.py` (après les imports existants, ajouter l'import et les tests suivants) :
```python
# Ajouter en haut du fichier si pas déjà présent :
# from pathlib import Path
# import io
# from PIL import Image
def _make_png_file(tmp_path: Path) -> Path:
img = Image.new("RGB", (50, 50), color=(200, 100, 50))
path = tmp_path / "icon.png"
img.save(path, format="PNG")
return path
def test_local_image_flag_uses_local_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
icon = _make_png_file(tmp_path)
output = tmp_path / "out.pdf"
captured_source = {}
original_init = GeneratePuzzlesUseCase.__init__
def patched_init(self, image_source, image_converter, pdf_exporter):
captured_source["source"] = image_source
original_init(self, image_source, image_converter, pdf_exporter)
monkeypatch.setattr(GeneratePuzzlesUseCase, "__init__", patched_init)
monkeypatch.setattr("logimage.application.use_cases.generate_puzzles.GeneratePuzzlesUseCase.execute", lambda *a, **kw: None)
from logimage.cli.main import main
monkeypatch.setattr("sys.argv", ["logimage", "--local-image", str(icon), "--output", str(output)])
main()
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
assert isinstance(captured_source["source"], LocalFileImageSource)
```
Note: si le pattern de test existant est différent (mocks directs, fixtures spécifiques), adapte ce test au style du fichier.
- [ ] **Step 3: Vérifier que le test échoue**
```bash
pytest tests/cli/test_main_routing.py -v -k "test_local_image"
```
Attendu : FAIL.
- [ ] **Step 4: Ajouter l'option `--local-image` dans `main.py`**
Dans `src/logimage/cli/main.py`, après les imports existants ajouter :
```python
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
```
Dans la fonction `main()`, après la définition de `--output`, ajouter l'argument :
```python
parser.add_argument(
"--local-image",
type=Path,
metavar="PATH",
help="Use a local image file instead of fetching from an API",
)
```
Remplacer le bloc qui crée `image_source` (lignes avec `pexels_key`, `unsplash_key`) par :
```python
image_source: ImageSource
if args.local_image:
image_source = LocalFileImageSource(args.local_image)
elif pexels_key:
image_source = PexelsImageSource(api_key=pexels_key)
elif unsplash_key:
image_source = UnsplashImageSource(api_key=unsplash_key)
else:
print(
"Error: no image API key found.\n"
"Set PEXELS_API_KEY or UNSPLASH_ACCESS_KEY in your .env file.",
file=sys.stderr,
)
sys.exit(1)
```
- [ ] **Step 5: Vérifier que les tests passent**
```bash
pytest tests/cli/ -v
```
Attendu : tous PASS.
- [ ] **Step 6: Lancer la suite complète**
```bash
pytest -v
```
Attendu : tous PASS.
- [ ] **Step 7: Commit**
```bash
git add src/logimage/cli/main.py tests/cli/test_main_routing.py
git commit -m "feat: add --local-image CLI option for local file input"
```
---
### Task 4 : Test d'intégration avec image locale
**Files:**
- Create: `tests/infrastructure/test_pipeline_integration.py`
- [ ] **Step 1: Écrire le test d'intégration**
Créer `tests/infrastructure/test_pipeline_integration.py` :
```python
import io
from pathlib import Path
from PIL import Image, ImageDraw
from logimage.infrastructure.image.pillow_converter import PillowImageConverter
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
def _make_icon_bytes() -> bytes:
"""Maison simple : rectangle + triangle sur fond blanc."""
img = Image.new("RGB", (100, 100), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
# Corps de la maison
draw.rectangle([25, 50, 75, 90], fill=(0, 0, 0))
# Toit (triangle approximatif)
draw.polygon([(50, 10), (20, 50), (80, 50)], fill=(0, 0, 0))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _count_blocks(line: tuple[bool, ...]) -> int:
blocks = 0
in_block = False
for cell in line:
if cell:
if not in_block:
blocks += 1
in_block = True
else:
in_block = False
return blocks
def test_house_icon_produces_playable_grid() -> None:
"""Une icône maison simple doit donner une grille jouable (≤ 6 blocs)."""
converter = PillowImageConverter(max_clue_blocks=6)
grid = converter.to_grid(_make_icon_bytes(), width=20, height=20)
for i in range(grid.height):
blocks = _count_blocks(grid.row(i))
assert blocks <= 6, f"Ligne {i} a {blocks} blocs"
for j in range(grid.width):
blocks = _count_blocks(grid.col(j))
assert blocks <= 6, f"Colonne {j} a {blocks} blocs"
def test_local_source_feeds_converter(tmp_path: Path) -> None:
"""LocalFileImageSource + PillowImageConverter produisent une grille valide."""
icon_path = tmp_path / "house.png"
icon_path.write_bytes(_make_icon_bytes())
source = LocalFileImageSource(icon_path)
image_data = source.fetch()
converter = PillowImageConverter(max_clue_blocks=6)
grid = converter.to_grid(image_data.content, width=15, height=15)
assert grid.width == 15
assert grid.height == 15
```
- [ ] **Step 2: Vérifier que les tests passent**
```bash
pytest tests/infrastructure/test_pipeline_integration.py -v
```
Attendu : tous PASS.
- [ ] **Step 3: Lancer la suite complète**
```bash
pytest -v
```
Attendu : tous PASS.
- [ ] **Step 4: Commit**
```bash
git add tests/infrastructure/test_pipeline_integration.py
git commit -m "test: add integration tests for local image pipeline"
```