import initial
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# Pexels (prioritaire) — https://www.pexels.com/api/
|
||||||
|
PEXELS_API_KEY=
|
||||||
|
|
||||||
|
# Unsplash (fallback) — https://unsplash.com/developers
|
||||||
|
UNSPLASH_ACCESS_KEY=
|
||||||
+48
@@ -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
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
@@ -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: ...
|
||||||
@@ -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: ...
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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,)
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user