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
+5
View File
@@ -0,0 +1,5 @@
# Pexels (prioritaire) — https://www.pexels.com/api/
PEXELS_API_KEY=
# Unsplash (fallback) — https://unsplash.com/developers
UNSPLASH_ACCESS_KEY=
+48
View File
@@ -0,0 +1,48 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
build/
dist/
*.egg-info/
*.egg
.eggs/
wheels/
# Virtual environments
.venv/
venv/
env/
ENV/
# Test / coverage / type-checking caches
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
.coverage.*
htmlcov/
coverage.xml
.tox/
# Environment / secrets
.env
.env.*
!.env.example
# Generated output (puzzle images / PDFs)
output/
*.generated.png
puzzles.pdf
*.pdf
# IDE / editor
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
+98
View File
@@ -0,0 +1,98 @@
{
"version": "1.0.0",
"lastScanned": 1781079530489,
"projectRoot": "/home/vincent/src/misc/logimage-generator",
"techStack": {
"languages": [
{
"name": "Python",
"version": null,
"confidence": "high",
"markers": [
"pyproject.toml"
]
}
],
"frameworks": [
{
"name": "pytest",
"version": null,
"category": "testing"
}
],
"packageManager": null,
"runtime": null
},
"build": {
"buildCommand": null,
"testCommand": "pytest",
"lintCommand": "ruff check",
"devCommand": null,
"scripts": {}
},
"conventions": {
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": null
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [
"docs",
"src",
"tests"
],
"gitBranches": null
},
"customNotes": [],
"directoryMap": {
"docs": {
"path": "docs",
"purpose": "Documentation",
"fileCount": 0,
"lastAccessed": 1781079530486,
"keyFiles": []
},
"src": {
"path": "src",
"purpose": "Source code",
"fileCount": 0,
"lastAccessed": 1781079530486,
"keyFiles": []
},
"tests": {
"path": "tests",
"purpose": "Test files",
"fileCount": 3,
"lastAccessed": 1781079530487,
"keyFiles": [
"__init__.py",
"conftest.py",
"helpers.py"
]
}
},
"hotPaths": [
{
"path": "README.md",
"accessCount": 3,
"lastAccessed": 1781079628616,
"type": "file"
},
{
"path": "src/logimage/cli/main.py",
"accessCount": 1,
"lastAccessed": 1781079608525,
"type": "file"
},
{
"path": ".env.example",
"accessCount": 1,
"lastAccessed": 1781079619607,
"type": "file"
}
],
"userDirectives": []
}
@@ -0,0 +1,8 @@
{
"session_id": "3568b4ad-a08c-4579-83f4-046cccd72c52",
"ended_at": "2026-06-10T08:21:06.743Z",
"reason": "prompt_input_exit",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}
+101
View File
@@ -0,0 +1,101 @@
# Logimage Generator
Génère des puzzles nonogrammes (logimages) au format PDF à partir d'images récupérées automatiquement via Pexels ou Unsplash.
## Prérequis
- Python 3.11+
- Une clé API [Pexels](https://www.pexels.com/api/) (gratuite) ou [Unsplash](https://unsplash.com/developers) — facultatif si tu utilises une image locale (`--local-image`)
## Installation
Crée un environnement virtuel (obligatoire sur Debian/Ubuntu) :
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```
> À chaque nouvelle session de terminal, réactive l'environnement avec `source .venv/bin/activate` avant de lancer `logimage`.
## Configuration
Copie le fichier d'exemple et renseigne ta clé API :
```bash
cp .env.example .env
```
Édite `.env` :
```env
# Pexels (prioritaire) — https://www.pexels.com/api/
PEXELS_API_KEY=ta_clé_pexels
# Unsplash (fallback) — https://unsplash.com/developers
UNSPLASH_ACCESS_KEY=ta_clé_unsplash
```
Si les deux clés sont présentes, Pexels est utilisé en priorité. Une seule clé suffit.
## Utilisation
```bash
# Un puzzle sur un thème aléatoire (difficulté moyenne, 15×15)
logimage
# Thème personnalisé
logimage --theme "montagne"
# Difficulté
logimage --difficulty easy # 10×10
logimage --difficulty medium # 15×15 (défaut)
logimage --difficulty hard # 20×20
# Taille personnalisée
logimage --size 20x25
# Plusieurs puzzles dans un seul PDF
logimage --count 5
# Inclure les pages solution
logimage --solution
# Chemin de sortie
logimage --output mon_carnet.pdf
# Image locale (sans appel API)
logimage --local-image photo.jpg
# Combinaisons
logimage --theme "architecture" --count 3 --difficulty hard --solution --output archi.pdf
```
Le PDF est généré dans le répertoire courant (`puzzles.pdf` par défaut).
## Recommandations d'usage
- **Thèmes en anglais** : les APIs Pexels et Unsplash renvoient de meilleurs résultats avec des mots-clés en anglais (`forest`, `city`, `animals`…).
- **Difficulté** : commence par `easy` (10×10) pour vérifier le rendu avant de générer des grilles complexes.
- **Lot de puzzles** : `--count 5 --solution` génère un carnet complet avec les solutions en fin de PDF.
- **Résolution** : les images sont redimensionnées automatiquement selon la taille de grille choisie. Une grille plus grande donne un puzzle plus précis mais plus difficile à résoudre.
## Développement
```bash
# Installer les dépendances de dev (dans le venv activé)
pip install -e ".[dev]"
# Lancer les tests
pytest tests/ -v
# Tests d'intégration (nécessitent une clé API valide)
pytest tests/ -v -m integration
# Linter
ruff check src/ tests/
# Vérification des types
mypy src/
```
@@ -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"
```
@@ -0,0 +1,71 @@
# Design : affichage de l'image originale sur la page solution
**Date :** 2026-05-20
## Contexte
La page solution affiche actuellement la grille remplie (cellules noires). L'utilisateur souhaite voir également l'image d'origine dont la grille est issue, pour faciliter la vérification et enrichir le rendu visuel.
## Mise en page choisie
Page portrait A4. La page solution est divisée en **deux colonnes égales** :
- **Colonne gauche** : grille solution (indices + cellules remplies) — identique à aujourd'hui mais réduite pour tenir dans la moitié de la largeur
- **Colonne droite** : image originale redimensionnée à la même hauteur et largeur que la grille, centrée verticalement
Espacement entre les deux colonnes : gouttière fixe de 10 mm.
Si `image_bytes` est `None`, la page solution se comporte comme avant (grille seule, pleine largeur).
## Changements requis
### 1. `src/logimage/domain/entities/puzzle.py`
Ajouter un champ `image_bytes: bytes | None = None` à `NonogramPuzzle` :
```python
@dataclass(frozen=True)
class NonogramPuzzle:
grid: Grid
row_clues: tuple[Clue, ...]
col_clues: tuple[Clue, ...]
title: str
image_bytes: bytes | None = None
```
Étendre `from_grid()` pour accepter et transmettre ce paramètre :
```python
@classmethod
def from_grid(cls, grid: Grid, title: str, image_bytes: bytes | None = None) -> "NonogramPuzzle":
...
return cls(grid=grid, row_clues=row_clues, col_clues=col_clues, title=title, image_bytes=image_bytes)
```
### 2. `src/logimage/application/use_cases/generate_puzzles.py`
Passer `image_data.content` lors de la construction du puzzle :
```python
puzzle = NonogramPuzzle.from_grid(grid, image_data.title, image_data.content)
```
### 3. `src/logimage/infrastructure/pdf/reportlab_exporter.py`
Modifier `_draw_page()` pour la page solution (`filled=True`) :
- Si `puzzle.image_bytes` est `None` : comportement actuel inchangé.
- Si `puzzle.image_bytes` est présent : calculer la largeur disponible en divisant en deux colonnes (`(avail_w - gutter) / 2`). Réduire la grille à cette demi-largeur. Rendre l'image dans la colonne droite via `reportlab.lib.utils.ImageReader` avec `canvas.drawImage()`, aux mêmes dimensions que la grille (même x, y, width, height).
## Ce qui ne change pas
- La page puzzle (non solution) reste inchangée
- Le port `PdfExporter` reste inchangé (signature de `export()` inchangée)
- Le port `ImageSource` reste inchangé
- Les tests existants restent valides (le champ `image_bytes` est optionnel)
## Tests
- `NonogramPuzzle.from_grid()` avec et sans `image_bytes`
- `ReportLabPdfExporter` : vérifier que le PDF généré avec `with_solution=True` et des bytes image est plus grand (taille fichier) que sans image
- Vérifier que `with_solution=True` sans `image_bytes` se comporte comme avant (rétrocompatibilité)
@@ -0,0 +1,280 @@
# Logimage Generator — Design Spec
Date: 2026-05-20
## Résumé
Outil CLI Python qui génère des nonogrammes (logimages) en PDF prêts à imprimer. Le programme récupère automatiquement des images depuis Unsplash, les convertit en grilles noir/blanc optimisées pour la jouabilité, calcule les indices lignes/colonnes, et produit un PDF A4 avec une grille centrée par page.
---
## 1. Comportement utilisateur
### Invocation
```bash
# Mode minimal — 1 grille, thème aléatoire, difficulté medium
logimage
# Avec options
logimage \
--theme "animals" \
--difficulty medium \
--size 20x15 \
--count 5 \
--solution \
--output puzzles.pdf
```
### Paramètres CLI
| Flag | Défaut | Description |
|------|--------|-------------|
| `--theme TEXT` | aléatoire | Mot-clé pour la recherche d'images Unsplash |
| `--difficulty easy\|medium\|hard` | `medium` | 10×10 / 15×15 / 20×20 |
| `--size NxM` | — | Taille libre ; écrase `--difficulty` si fourni |
| `--count N` | `1` | Nombre de grilles dans le PDF |
| `--solution` | off | Ajoute une page solution après chaque grille |
| `--output PATH` | `puzzles.pdf` | Chemin du fichier PDF de sortie |
### Configuration
Clé API Unsplash via variable d'environnement ou fichier `.env` à la racine :
```
UNSPLASH_ACCESS_KEY=xxx
```
### Format PDF
- Format A4 portrait, une grille par page
- Mise en page : titre en haut, grille centrée, thème en bas
- Grille vierge (cases à colorier), indices lignes à gauche, indices colonnes en haut
- Si `--solution` : page solution insérée après chaque grille (grille pré-remplie)
---
## 2. Architecture (Clean Architecture)
La règle de dépendance est stricte : les couches internes ne connaissent jamais les couches externes.
```
domain ← application ← infrastructure ← cli
```
### Structure des fichiers
```
src/logimage/
├── domain/
│ ├── entities/
│ │ └── puzzle.py # NonogramPuzzle
│ ├── value_objects/
│ │ ├── grid.py # Grid (immuable)
│ │ └── clue.py # Clue (immuable)
│ └── ports/
│ ├── image_source.py # ImageSource (ABC)
│ ├── image_converter.py # ImageConverter (ABC)
│ └── pdf_exporter.py # PdfExporter (ABC)
├── application/
│ └── use_cases/
│ └── generate_puzzles.py # GeneratePuzzlesUseCase
├── infrastructure/
│ ├── image/
│ │ ├── unsplash_source.py # UnsplashImageSource
│ │ └── pillow_converter.py # PillowImageConverter
│ └── pdf/
│ └── reportlab_exporter.py
└── cli/
└── main.py # Composition root + argparse
tests/
├── domain/
├── application/
├── infrastructure/ # @pytest.mark.integration
└── fakes/ # FakeImageSource, FakeImageConverter, FakePdfExporter
```
---
## 3. Modèle du domaine
### Value objects
**`Grid`**
- Données : `tuple[tuple[bool]]` (immuable)
- Contraintes : largeur et hauteur entre 5 et 50
- Lève `ValueError` si les contraintes ne sont pas respectées
**`Clue`**
- Séquence immuable d'entiers positifs représentant les blocs d'une ligne ou colonne
- Ligne vide → `Clue([])`
- Exemple : `[T,T,F,T,F,F,T,T,T]``Clue([2, 1, 3])`
### Entité
**`NonogramPuzzle`**
- `grid: Grid` — la solution
- `row_clues: tuple[Clue]` — calculées à la construction
- `col_clues: tuple[Clue]` — calculées à la construction
- `title: str` — thème ou nom de l'image source
- Fabriqué via `NonogramPuzzle.from_grid(grid, title)` qui calcule les clues
### Ports (interfaces)
```python
class ImageSource(ABC):
def fetch(self, theme: str | None = None) -> bytes: ...
class ImageConverter(ABC):
def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid: ...
class PdfExporter(ABC):
def export(
self,
puzzles: list[NonogramPuzzle],
path: Path,
with_solution: bool = False,
) -> None: ...
```
---
## 4. Use case
**`GeneratePuzzlesUseCase`**
Dépendances injectées : `ImageSource`, `ImageConverter`, `PdfExporter`
Séquence d'exécution :
1. Valider les paramètres (taille, count)
2. Pour chaque grille à générer :
a. `image_source.fetch(theme)``bytes`
b. `image_converter.to_grid(bytes, width, height)``Grid`
c. `NonogramPuzzle.from_grid(grid, title)``NonogramPuzzle`
3. `pdf_exporter.export(puzzles, path, with_solution)`
---
## 5. Infrastructure
### `UnsplashImageSource`
- Utilise `httpx` pour appeler l'API Unsplash (`/photos/random?query=<theme>`)
- Clé API lue depuis `UNSPLASH_ACCESS_KEY`
- Télécharge l'image en JPEG (taille raisonnable, ~800px)
### `PillowImageConverter`
- Redimensionne l'image à `width × height` pixels
- Conversion en niveaux de gris
- Détection de contours avec OpenCV (Canny) pour favoriser les formes reconnaissables
- Seuillage adaptatif pour maximiser la jouabilité (pas trop de cases noires, pas trop peu)
- Retourne un `Grid`
### `ReportLabPdfExporter`
- Format A4 portrait
- Grille rendue avec `reportlab` (rectangles, texte pour les indices)
- Indices colonnes en haut (empilés verticalement si plusieurs chiffres), indices lignes à gauche
- Marquage de groupes de 5 cases (bords plus épais) pour faciliter le comptage
- Page solution : même layout, cases noires pré-remplies
---
## 6. Stratégie de test (TDD)
### Tests domain (purs, aucun mock)
```
test_clue_from_consecutive_cells()
test_clue_empty_row()
test_clue_single_cell()
test_puzzle_computes_row_and_col_clues()
test_grid_rejects_width_below_minimum()
test_grid_rejects_height_above_maximum()
test_grid_is_immutable()
```
### Tests application (mocks des ports via fakes)
```
test_generate_single_puzzle_calls_fetch_once()
test_generate_count_5_calls_fetch_5_times()
test_solution_flag_forwarded_to_exporter()
test_invalid_size_raises_value_error()
test_size_from_difficulty_easy_is_10x10()
test_size_from_difficulty_hard_is_20x20()
```
### Tests d'intégration (`@pytest.mark.integration`, réseau réel)
```
test_unsplash_returns_valid_jpeg_bytes()
test_pillow_converter_output_matches_requested_size()
test_reportlab_creates_nonempty_pdf_file()
```
### Exécution
```bash
pytest tests/domain tests/application # rapide, CI toujours
pytest -m integration # ponctuel, nécessite UNSPLASH_ACCESS_KEY
pytest --cov=logimage --cov-report=term-missing
```
Couverture cible : ≥ 90% sur `domain/` et `application/`.
---
## 7. Dépendances
| Rôle | Bibliothèque |
|------|-------------|
| Traitement image | `Pillow` |
| Détection contours | `opencv-python-headless` |
| Génération PDF | `reportlab` |
| HTTP | `httpx` |
| Variables d'env | `python-dotenv` |
| Tests | `pytest`, `pytest-cov` |
| Lint + format | `ruff` |
| Typage statique | `mypy` |
Python ≥ 3.11 requis.
### `pyproject.toml` (structure)
```toml
[project]
name = "logimage-generator"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"Pillow", "opencv-python-headless", "reportlab",
"httpx", "python-dotenv",
]
[project.optional-dependencies]
dev = ["pytest", "pytest-cov", "ruff", "mypy"]
[project.scripts]
logimage = "logimage.cli.main:main"
[tool.pytest.ini_options]
markers = ["integration: tests requiring network and API keys"]
[tool.ruff]
line-length = 88
[tool.mypy]
strict = true
```
---
## 8. Ce qui est hors scope
- Interface graphique ou web
- Éditeur interactif de grille
- Validation de l'unicité de la solution (problème NP-difficile pour les grandes grilles)
- Cache local des images téléchargées (peut être ajouté plus tard)
- Support des nonogrammes en couleur
@@ -0,0 +1,45 @@
# Design : intégration Pexels et aiguillage multi-provider
**Date :** 2026-05-20
## Contexte
Le projet dispose déjà d'un port `ImageSource` (ABC) et d'une implémentation `UnsplashImageSource`. La clé API Unsplash n'est pas encore disponible ; une clé Pexels est disponible maintenant. L'objectif est d'ajouter le support Pexels et de router automatiquement vers le bon provider selon les variables d'environnement.
## Périmètre
- Ajouter `PexelsImageSource` dans l'infrastructure
- Modifier `main.py` pour router selon les clés d'API présentes
- Pas de changement au domain, au port, ni au use case
## Nouveau fichier : `PexelsImageSource`
**Chemin :** `src/logimage/infrastructure/image/pexels_source.py`
Implémente `ImageSource`. Deux endpoints selon la présence d'un thème :
- Avec thème : `GET https://api.pexels.com/v1/search?query=<theme>&per_page=1`
- Sans thème : `GET https://api.pexels.com/v1/curated?per_page=1`
Authentification : header `Authorization: <api_key>` (pas de préfixe).
Extraction des données :
- URL image : `photo["src"]["large"]`
- Titre : `photo["alt"]` ou `theme` ou `"logimage"` en fallback
## Modification : routing dans `main.py`
Remplacer la logique actuelle (Unsplash uniquement) par :
1. Lire `PEXELS_API_KEY` et `UNSPLASH_ACCESS_KEY` depuis l'environnement
2. Si `PEXELS_API_KEY` est présente → instancier `PexelsImageSource`
3. Sinon si `UNSPLASH_ACCESS_KEY` est présente → instancier `UnsplashImageSource`
4. Sinon → afficher une erreur claire et quitter :
`"Error: set PEXELS_API_KEY or UNSPLASH_ACCESS_KEY in .env"`
Pexels est prioritaire car c'est la clé disponible aujourd'hui.
## Ce qui ne change pas
- Le port `ImageSource` reste inchangé
- Le use case `GeneratePuzzlesUseCase` reste inchangé
- `UnsplashImageSource` reste inchangé (prêt pour quand la clé sera disponible)
@@ -0,0 +1,54 @@
# Amélioration du pipeline de conversion image → nonogram
## Objectif
Produire des grilles de nonogram à la fois jouables (≤ 6 blocs par ligne/colonne) et reconnaissables (ressemblance avec l'image source), à partir de photos Pexels/Unsplash ou d'images locales.
## Problème actuel
Le pipeline actuel (edge detection Canny + blend + Otsu) génère trop de pixels isolés sur les photos, produisant des clues illisibles (ex: 15 blocs sur une ligne de 32 cellules) et une grille méconnaissable.
## Pipeline cible
```
Image bytes
→ Grayscale + resize (inchangé)
→ Bilateral filter (d=9, sigmaColor=75, sigmaSpace=75)
→ Posterize 2 niveaux (seuillage médian sur histogramme)
→ Binarisation Otsu
→ Fermeture morphologique kernel 3×3 (combler micro-trous)
→ Suppression composantes connexes < min_component_size (défaut: 2px)
→ [Boucle] Vérification complexité clues
si max_blocks_per_line > max_clue_blocks :
fermeture morphologique kernel += 1
max 3 itérations
→ Grid
```
## Paramètres configurables
| Paramètre | Défaut | Description |
|---|---|---|
| `max_clue_blocks` | 6 | Max blocs acceptables par ligne/colonne |
| `min_component_size` | 2 | Taille min composante connexe (px) |
| `max_cleanup_iterations` | 3 | Nb max d'itérations de nettoyage supplémentaire |
## Nouvelle source locale
`LocalFileImageSource` — implémente `ImageSource`, lit un fichier depuis le disque.
CLI : nouvelle option `--local-image <path>` mutuellement exclusive avec `--theme`.
## Tests
- **Unitaires** : bilateral filter, cleanup morpho, boucle complexité sur grilles synthétiques
- **Intégration** : image simple → vérifie max blocs/ligne ≤ 6
- **Fixture** : `tests/fixtures/simple_icon.png` généré programmatiquement (forme simple)
## Fichiers modifiés
- `src/logimage/infrastructure/image/pillow_converter.py` — pipeline complet
- `src/logimage/infrastructure/image/local_file_source.py` — nouvelle source
- `src/logimage/cli/main.py` — option `--local-image`
- `tests/infrastructure/test_pillow_converter.py` — nouveaux tests
- `tests/fixtures/simple_icon.png` — image de test
+36
View File
@@ -0,0 +1,36 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "logimage-generator"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"Pillow>=10.0",
"opencv-python-headless>=4.8",
"reportlab>=4.0",
"httpx>=0.27",
"python-dotenv>=1.0",
"numpy>=1.26",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.4", "mypy>=1.10"]
[project.scripts]
logimage = "logimage.cli.main:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["integration: tests requiring network or external resources"]
[tool.ruff]
line-length = 88
[tool.mypy]
mypy_path = "src"
strict = true
View File
@@ -0,0 +1,44 @@
from dataclasses import dataclass, field
from pathlib import Path
from logimage.domain.entities.puzzle import NonogramPuzzle
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.ports.image_converter import ImageConverter
from logimage.domain.ports.pdf_exporter import PdfExporter
_DIFFICULTY_SIZES: dict[str, tuple[int, int]] = {
"easy": (10, 10),
"medium": (15, 15),
"hard": (20, 20),
}
@dataclass
class GeneratePuzzlesRequest:
count: int = 1
difficulty: str = "medium"
size: tuple[int, int] | None = None
theme: str | None = None
output_path: Path = field(default_factory=lambda: Path("puzzles.pdf"))
with_solution: bool = False
class GeneratePuzzlesUseCase:
def __init__(
self,
image_source: ImageSource,
image_converter: ImageConverter,
pdf_exporter: PdfExporter,
) -> None:
self._image_source = image_source
self._image_converter = image_converter
self._pdf_exporter = pdf_exporter
def execute(self, request: GeneratePuzzlesRequest) -> None:
width, height = request.size or _DIFFICULTY_SIZES.get(request.difficulty, (15, 15))
puzzles: list[NonogramPuzzle] = []
for _ in range(request.count):
image_data = self._image_source.fetch(request.theme)
grid = self._image_converter.to_grid(image_data.content, width, height)
puzzle = NonogramPuzzle.from_grid(grid, image_data.title, image_data.content)
puzzles.append(puzzle)
self._pdf_exporter.export(puzzles, request.output_path, request.with_solution)
View File
+107
View File
@@ -0,0 +1,107 @@
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.domain.ports.image_source import ImageSource
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
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)",
)
parser.add_argument(
"--local-image",
type=Path,
metavar="PATH",
help="Use a local image file instead of fetching from an API",
)
args = parser.parse_args()
pexels_key = os.environ.get("PEXELS_API_KEY")
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY")
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 source configured.\n"
"Set PEXELS_API_KEY or UNSPLASH_ACCESS_KEY in your .env file,\n"
"or use --local-image PATH to provide a local image.",
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}")
View File
+29
View File
@@ -0,0 +1,29 @@
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,
)
@@ -0,0 +1,7 @@
from abc import ABC, abstractmethod
from logimage.domain.value_objects.grid import Grid
class ImageConverter(ABC):
@abstractmethod
def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid: ...
@@ -0,0 +1,7 @@
from abc import ABC, abstractmethod
from logimage.domain.value_objects.image_data import ImageData
class ImageSource(ABC):
@abstractmethod
def fetch(self, theme: str | None = None) -> ImageData: ...
+13
View File
@@ -0,0 +1,13 @@
from abc import ABC, abstractmethod
from pathlib import Path
from logimage.domain.entities.puzzle import NonogramPuzzle
class PdfExporter(ABC):
@abstractmethod
def export(
self,
puzzles: list[NonogramPuzzle],
path: Path,
with_solution: bool = False,
) -> None: ...
+20
View File
@@ -0,0 +1,20 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class Clue:
values: tuple[int, ...]
@classmethod
def from_row(cls, cells: tuple[bool, ...]) -> "Clue":
groups: list[int] = []
count = 0
for cell in cells:
if cell:
count += 1
elif count > 0:
groups.append(count)
count = 0
if count > 0:
groups.append(count)
return cls(values=tuple(groups))
+34
View File
@@ -0,0 +1,34 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class Grid:
cells: tuple[tuple[bool, ...], ...]
def __post_init__(self) -> None:
height = len(self.cells)
if height == 0:
raise ValueError("height must be between 5 and 50")
width = len(self.cells[0])
if not (5 <= width <= 50):
raise ValueError(f"width must be between 5 and 50, got {width}")
if not (5 <= height <= 50):
raise ValueError(f"height must be between 5 and 50, got {height}")
@property
def width(self) -> int:
return len(self.cells[0])
@property
def height(self) -> int:
return len(self.cells)
def row(self, index: int) -> tuple[bool, ...]:
return self.cells[index]
def col(self, index: int) -> tuple[bool, ...]:
return tuple(row[index] for row in self.cells)
@classmethod
def from_list(cls, cells: list[list[bool]]) -> "Grid":
return cls(cells=tuple(tuple(row) for row in cells))
@@ -0,0 +1,7 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class ImageData:
content: bytes
title: str
@@ -0,0 +1,13 @@
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)
@@ -0,0 +1,38 @@
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"
_PER_PAGE = 1
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": _PER_PAGE}
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)
@@ -0,0 +1,78 @@
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))
min_val = int(filtered.min())
max_val = int(filtered.max())
if min_val == max_val:
# Uniform image: treat dark as filled, light as empty
fill = np.uint8(0) if min_val < 128 else np.uint8(255)
posterized = np.full_like(filtered, fill)
else:
posterized = np.where(filtered < median, np.uint8(0), np.uint8(255))
binary = (255 - posterized).astype(np.uint8)
# Small fixed close to fill tiny internal holes — no enlargement to preserve thin features
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel).astype(np.uint8)
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
@@ -0,0 +1,37 @@
import httpx
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.value_objects.image_data import ImageData
_API_URL = "https://api.unsplash.com/photos/random"
class UnsplashImageSource(ImageSource):
def __init__(self, api_key: str) -> None:
self._api_key = api_key
def fetch(self, theme: str | None = None) -> ImageData:
params: dict[str, str] = {}
if theme:
params["query"] = theme
with httpx.Client(timeout=30.0) as client:
response = client.get(
_API_URL,
headers={"Authorization": f"Client-ID {self._api_key}"},
params=params,
)
response.raise_for_status()
photo = response.json()
image_url = photo["urls"]["regular"]
title: str = (
photo.get("alt_description")
or photo.get("description")
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)
@@ -0,0 +1,112 @@
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
assert puzzle.image_bytes is not None
reader = ImageReader(BytesIO(puzzle.image_bytes))
c.drawImage(reader, img_x, grid_y, width=img_w, height=grid_h, preserveAspectRatio=True, anchor="c")
View File
View File
+113
View File
@@ -0,0 +1,113 @@
import pytest
from pathlib import Path
from logimage.application.use_cases.generate_puzzles import (
GeneratePuzzlesRequest,
GeneratePuzzlesUseCase,
)
from tests.fakes.fakes import FakeImageSource, FakeImageConverter, FakePdfExporter
def _make_use_case() -> tuple[GeneratePuzzlesUseCase, FakeImageSource, FakeImageConverter, FakePdfExporter]:
source = FakeImageSource()
converter = FakeImageConverter()
exporter = FakePdfExporter()
use_case = GeneratePuzzlesUseCase(source, converter, exporter)
return use_case, source, converter, exporter
def test_generate_single_puzzle_calls_fetch_once() -> None:
use_case, source, _, _ = _make_use_case()
use_case.execute(GeneratePuzzlesRequest())
assert len(source.fetch_calls) == 1
def test_generate_count_5_calls_fetch_5_times() -> None:
use_case, source, _, _ = _make_use_case()
use_case.execute(GeneratePuzzlesRequest(count=5))
assert len(source.fetch_calls) == 5
def test_theme_forwarded_to_source() -> None:
use_case, source, _, _ = _make_use_case()
use_case.execute(GeneratePuzzlesRequest(theme="cats"))
assert source.fetch_calls[0] == "cats"
def test_no_theme_passes_none_to_source() -> None:
use_case, source, _, _ = _make_use_case()
use_case.execute(GeneratePuzzlesRequest())
assert source.fetch_calls[0] is None
def test_solution_flag_forwarded_to_exporter() -> None:
use_case, _, _, exporter = _make_use_case()
use_case.execute(GeneratePuzzlesRequest(with_solution=True))
assert exporter.last_with_solution is True
def test_output_path_forwarded_to_exporter() -> None:
use_case, _, _, exporter = _make_use_case()
path = Path("my_output.pdf")
use_case.execute(GeneratePuzzlesRequest(output_path=path))
assert exporter.last_path == path
def test_difficulty_easy_uses_10x10(monkeypatch: pytest.MonkeyPatch) -> None:
source = FakeImageSource()
exporter = FakePdfExporter()
sizes_used: list[tuple[int, int]] = []
class CapturingConverter(FakeImageConverter):
def to_grid(self, image_bytes: bytes, width: int, height: int): # type: ignore[override]
sizes_used.append((width, height))
return super().to_grid(image_bytes, width, height)
use_case = GeneratePuzzlesUseCase(source, CapturingConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest(difficulty="easy"))
assert sizes_used[0] == (10, 10)
def test_difficulty_hard_uses_20x20(monkeypatch: pytest.MonkeyPatch) -> None:
source = FakeImageSource()
exporter = FakePdfExporter()
sizes_used: list[tuple[int, int]] = []
class CapturingConverter(FakeImageConverter):
def to_grid(self, image_bytes: bytes, width: int, height: int): # type: ignore[override]
sizes_used.append((width, height))
return super().to_grid(image_bytes, width, height)
use_case = GeneratePuzzlesUseCase(source, CapturingConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest(difficulty="hard"))
assert sizes_used[0] == (20, 20)
def test_custom_size_overrides_difficulty(monkeypatch: pytest.MonkeyPatch) -> None:
source = FakeImageSource()
exporter = FakePdfExporter()
sizes_used: list[tuple[int, int]] = []
class CapturingConverter(FakeImageConverter):
def to_grid(self, image_bytes: bytes, width: int, height: int): # type: ignore[override]
sizes_used.append((width, height))
return super().to_grid(image_bytes, width, height)
use_case = GeneratePuzzlesUseCase(source, CapturingConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest(difficulty="easy", size=(25, 30)))
assert sizes_used[0] == (25, 30)
def test_puzzle_title_comes_from_image_source() -> None:
source = FakeImageSource(title="Beautiful Cat")
exporter = FakePdfExporter()
use_case = GeneratePuzzlesUseCase(source, FakeImageConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest())
assert exporter.exported_puzzles[0].title == "Beautiful Cat"
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"
View File
+96
View File
@@ -0,0 +1,96 @@
from pathlib import Path
import pytest
from PIL import Image
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
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
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_pexels_key_uses_pexels_source(monkeypatch: pytest.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: pytest.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: pytest.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_local_image_flag_uses_local_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
icon = _make_png_file(tmp_path)
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--local-image", str(icon)]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], LocalFileImageSource)
def test_local_image_takes_priority_over_api_keys(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
icon = _make_png_file(tmp_path)
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", "--local-image", str(icon)]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], LocalFileImageSource)
def test_no_key_exits_with_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> 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
View File
View File
+34
View File
@@ -0,0 +1,34 @@
import pytest
from logimage.domain.value_objects.clue import Clue
def test_clue_from_consecutive_cells() -> None:
cells = (True, True, False, True, False, False, True, True, True)
clue = Clue.from_row(cells)
assert clue.values == (2, 1, 3)
def test_clue_from_empty_row() -> None:
clue = Clue.from_row((False, False, False))
assert clue.values == ()
def test_clue_from_single_true_cell() -> None:
clue = Clue.from_row((False, True, False))
assert clue.values == (1,)
def test_clue_from_all_true_row() -> None:
clue = Clue.from_row((True, True, True))
assert clue.values == (3,)
def test_clue_is_immutable() -> None:
clue = Clue.from_row((True, False, True))
with pytest.raises(AttributeError):
clue.values = (99,) # type: ignore[misc]
def test_clue_trailing_true() -> None:
clue = Clue.from_row((False, True, True))
assert clue.values == (2,)
+57
View File
@@ -0,0 +1,57 @@
import pytest
from logimage.domain.value_objects.grid import Grid
def _make_cells(rows: int, cols: int, value: bool = False) -> list[list[bool]]:
return [[value] * cols for _ in range(rows)]
def test_grid_width_and_height() -> None:
grid = Grid.from_list(_make_cells(10, 15))
assert grid.width == 15
assert grid.height == 10
def test_grid_row_returns_correct_values() -> None:
cells5 = [[True, False, True, False, True]] * 5
grid = Grid.from_list(cells5)
assert grid.row(0) == (True, False, True, False, True)
def test_grid_col_returns_correct_values() -> None:
cells = [[True, False, False, False, False]] * 5
grid = Grid.from_list(cells)
assert grid.col(0) == (True, True, True, True, True)
assert grid.col(1) == (False, False, False, False, False)
def test_grid_rejects_width_below_minimum() -> None:
with pytest.raises(ValueError, match="width"):
Grid.from_list([[True, False, True, False]] * 5)
def test_grid_rejects_height_below_minimum() -> None:
with pytest.raises(ValueError, match="height"):
Grid.from_list([[True] * 5] * 4)
def test_grid_rejects_width_above_maximum() -> None:
with pytest.raises(ValueError, match="width"):
Grid.from_list([[True] * 51] * 5)
def test_grid_rejects_height_above_maximum() -> None:
with pytest.raises(ValueError, match="height"):
Grid.from_list([[True] * 5] * 51)
def test_grid_is_immutable() -> None:
grid = Grid.from_list(_make_cells(5, 5))
with pytest.raises(AttributeError):
grid.cells = () # type: ignore[misc]
def test_grid_from_list_converts_to_tuples() -> None:
grid = Grid.from_list(_make_cells(5, 5, True))
assert isinstance(grid.cells, tuple)
assert isinstance(grid.cells[0], tuple)
+59
View File
@@ -0,0 +1,59 @@
from logimage.domain.value_objects.grid import Grid
from logimage.domain.value_objects.clue import Clue
from logimage.domain.entities.puzzle import NonogramPuzzle
def _simple_grid() -> Grid:
return Grid.from_list([
[True, False, True, False, True ],
[False, True, False, True, False],
[True, True, False, False, True ],
[False, False, True, True, False],
[True, False, False, True, True ],
])
def test_puzzle_row_clues_computed_correctly() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test")
assert puzzle.row_clues[0] == Clue(values=(1, 1, 1))
assert puzzle.row_clues[1] == Clue(values=(1, 1))
assert puzzle.row_clues[2] == Clue(values=(2, 1))
def test_puzzle_col_clues_computed_correctly() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test")
assert puzzle.col_clues[0] == Clue(values=(1, 1, 1))
assert puzzle.col_clues[1] == Clue(values=(2,))
def test_puzzle_title_stored() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Mountains")
assert puzzle.title == "Mountains"
def test_puzzle_has_correct_grid() -> None:
grid = _simple_grid()
puzzle = NonogramPuzzle.from_grid(grid, "Test")
assert puzzle.grid is grid
def test_puzzle_row_clue_count_matches_grid_height() -> None:
grid = _simple_grid()
puzzle = NonogramPuzzle.from_grid(grid, "Test")
assert len(puzzle.row_clues) == grid.height
def test_puzzle_col_clue_count_matches_grid_width() -> None:
grid = _simple_grid()
puzzle = NonogramPuzzle.from_grid(grid, "Test")
assert len(puzzle.col_clues) == grid.width
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"
View File
+50
View File
@@ -0,0 +1,50 @@
from pathlib import Path
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.ports.image_converter import ImageConverter
from logimage.domain.ports.pdf_exporter import PdfExporter
from logimage.domain.value_objects.image_data import ImageData
from logimage.domain.value_objects.grid import Grid
from logimage.domain.entities.puzzle import NonogramPuzzle
_MINIMAL_CELLS = [[i % 2 == 0] * 5 for i in range(5)]
MINIMAL_GRID = Grid.from_list(_MINIMAL_CELLS)
class FakeImageSource(ImageSource):
def __init__(
self,
content: bytes = b"fake",
title: str = "Fake Puzzle",
) -> None:
self._content = content
self._title = title
self.fetch_calls: list[str | None] = []
def fetch(self, theme: str | None = None) -> ImageData:
self.fetch_calls.append(theme)
return ImageData(content=self._content, title=self._title)
class FakeImageConverter(ImageConverter):
def __init__(self, grid: Grid = MINIMAL_GRID) -> None:
self._grid = grid
def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid:
return self._grid
class FakePdfExporter(PdfExporter):
def __init__(self) -> None:
self.exported_puzzles: list[NonogramPuzzle] = []
self.last_path: Path | None = None
self.last_with_solution: bool = False
def export(
self,
puzzles: list[NonogramPuzzle],
path: Path,
with_solution: bool = False,
) -> None:
self.exported_puzzles = list(puzzles)
self.last_path = path
self.last_with_solution = with_solution
+11
View File
@@ -0,0 +1,11 @@
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
View File
@@ -0,0 +1,35 @@
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()
@@ -0,0 +1,28 @@
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)
@@ -0,0 +1,92 @@
import io
from PIL import Image, ImageDraw
from logimage.infrastructure.image.pillow_converter import PillowImageConverter
from logimage.domain.value_objects.grid import Grid
from tests.helpers import count_blocks
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
@@ -0,0 +1,51 @@
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
from tests.helpers import count_blocks
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 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 len(image_data.content) > 0
assert grid.width == 15
assert grid.height == 15
total_filled = sum(cell for r in range(grid.height) for cell in grid.row(r))
assert total_filled > 0, "Pipeline should produce some filled cells for house icon"
@@ -0,0 +1,80 @@
import io
import tempfile
from pathlib import Path
from PIL import Image as PilImage
from logimage.infrastructure.pdf.reportlab_exporter import ReportLabPdfExporter
from logimage.domain.entities.puzzle import NonogramPuzzle
from logimage.domain.value_objects.grid import Grid
def _make_puzzle(rows: int = 10, cols: int = 10, title: str = "Test") -> NonogramPuzzle:
cells = [[i % 3 == 0] * cols for i in range(rows)]
grid = Grid.from_list(cells)
return NonogramPuzzle.from_grid(grid, title)
def test_export_creates_pdf_file() -> None:
exporter = ReportLabPdfExporter()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "output.pdf"
exporter.export([_make_puzzle()], path)
assert path.exists()
assert path.stat().st_size > 1000
def test_export_multiple_puzzles_creates_larger_file() -> None:
exporter = ReportLabPdfExporter()
with tempfile.TemporaryDirectory() as tmp:
path_single = Path(tmp) / "single.pdf"
path_multi = Path(tmp) / "multi.pdf"
exporter.export([_make_puzzle()], path_single)
exporter.export([_make_puzzle(), _make_puzzle(), _make_puzzle()], path_multi)
assert path_multi.stat().st_size > path_single.stat().st_size
def test_export_with_solution_creates_larger_file() -> None:
exporter = ReportLabPdfExporter()
with tempfile.TemporaryDirectory() as tmp:
path_no_sol = Path(tmp) / "no_solution.pdf"
path_with_sol = Path(tmp) / "with_solution.pdf"
puzzle = _make_puzzle()
exporter.export([puzzle], path_no_sol, with_solution=False)
exporter.export([puzzle], path_with_sol, with_solution=True)
assert path_with_sol.stat().st_size > path_no_sol.stat().st_size
def test_export_starts_with_pdf_magic_bytes() -> None:
exporter = ReportLabPdfExporter()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "output.pdf"
exporter.export([_make_puzzle()], path)
assert path.read_bytes()[:4] == b"%PDF"
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"
@@ -0,0 +1,27 @@
import os
import pytest
from logimage.infrastructure.image.unsplash_source import UnsplashImageSource
from logimage.domain.value_objects.image_data import ImageData
@pytest.mark.integration
def test_fetch_returns_image_data() -> None:
api_key = os.environ.get("UNSPLASH_ACCESS_KEY")
if not api_key:
pytest.skip("UNSPLASH_ACCESS_KEY not set")
source = UnsplashImageSource(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("UNSPLASH_ACCESS_KEY")
if not api_key:
pytest.skip("UNSPLASH_ACCESS_KEY not set")
source = UnsplashImageSource(api_key=api_key)
result = source.fetch(theme="forest")
assert isinstance(result, ImageData)
assert len(result.content) > 1000