From 5a03f8a38d93b1a82b846f8e0b5105cde932b670 Mon Sep 17 00:00:00 2001 From: Vincent Bourdon Date: Wed, 10 Jun 2026 10:21:18 +0200 Subject: [PATCH] import initial --- .env.example | 5 + .gitignore | 48 + .omc/project-memory.json | 98 ++ .../3568b4ad-a08c-4579-83f4-046cccd72c52.json | 8 + README.md | 101 ++ .../2026-05-20-image-on-solution-page.md | 358 ++++ .../plans/2026-05-20-logimage-generator.md | 1470 +++++++++++++++++ .../plans/2026-05-20-pexels-image-source.md | 341 ++++ ...2026-05-21-pipeline-conversion-ameliore.md | 583 +++++++ ...026-05-20-image-on-solution-page-design.md | 71 + .../2026-05-20-logimage-generator-design.md | 280 ++++ .../2026-05-20-pexels-image-source-design.md | 45 + ...-21-pipeline-conversion-ameliore-design.md | 54 + pyproject.toml | 36 + src/logimage/__init__.py | 0 src/logimage/application/__init__.py | 0 .../application/use_cases/__init__.py | 0 .../application/use_cases/generate_puzzles.py | 44 + src/logimage/cli/__init__.py | 0 src/logimage/cli/main.py | 107 ++ src/logimage/domain/__init__.py | 0 src/logimage/domain/entities/__init__.py | 0 src/logimage/domain/entities/puzzle.py | 29 + src/logimage/domain/ports/__init__.py | 0 src/logimage/domain/ports/image_converter.py | 7 + src/logimage/domain/ports/image_source.py | 7 + src/logimage/domain/ports/pdf_exporter.py | 13 + src/logimage/domain/value_objects/__init__.py | 0 src/logimage/domain/value_objects/clue.py | 20 + src/logimage/domain/value_objects/grid.py | 34 + .../domain/value_objects/image_data.py | 7 + src/logimage/infrastructure/__init__.py | 0 src/logimage/infrastructure/image/__init__.py | 0 .../infrastructure/image/local_file_source.py | 13 + .../infrastructure/image/pexels_source.py | 38 + .../infrastructure/image/pillow_converter.py | 78 + .../infrastructure/image/unsplash_source.py | 37 + src/logimage/infrastructure/pdf/__init__.py | 0 .../infrastructure/pdf/reportlab_exporter.py | 112 ++ tests/__init__.py | 0 tests/application/__init__.py | 0 tests/application/test_generate_puzzles.py | 113 ++ tests/cli/__init__.py | 0 tests/cli/test_main_routing.py | 96 ++ tests/conftest.py | 0 tests/domain/__init__.py | 0 tests/domain/test_clue.py | 34 + tests/domain/test_grid.py | 57 + tests/domain/test_puzzle.py | 59 + tests/fakes/__init__.py | 0 tests/fakes/fakes.py | 50 + tests/helpers.py | 11 + tests/infrastructure/__init__.py | 0 .../infrastructure/test_local_file_source.py | 35 + tests/infrastructure/test_pexels_source.py | 28 + tests/infrastructure/test_pillow_converter.py | 92 ++ .../test_pipeline_integration.py | 51 + .../infrastructure/test_reportlab_exporter.py | 80 + tests/infrastructure/test_unsplash_source.py | 27 + 59 files changed, 4777 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .omc/project-memory.json create mode 100644 .omc/sessions/3568b4ad-a08c-4579-83f4-046cccd72c52.json create mode 100644 README.md create mode 100644 docs/superpowers/plans/2026-05-20-image-on-solution-page.md create mode 100644 docs/superpowers/plans/2026-05-20-logimage-generator.md create mode 100644 docs/superpowers/plans/2026-05-20-pexels-image-source.md create mode 100644 docs/superpowers/plans/2026-05-21-pipeline-conversion-ameliore.md create mode 100644 docs/superpowers/specs/2026-05-20-image-on-solution-page-design.md create mode 100644 docs/superpowers/specs/2026-05-20-logimage-generator-design.md create mode 100644 docs/superpowers/specs/2026-05-20-pexels-image-source-design.md create mode 100644 docs/superpowers/specs/2026-05-21-pipeline-conversion-ameliore-design.md create mode 100644 pyproject.toml create mode 100644 src/logimage/__init__.py create mode 100644 src/logimage/application/__init__.py create mode 100644 src/logimage/application/use_cases/__init__.py create mode 100644 src/logimage/application/use_cases/generate_puzzles.py create mode 100644 src/logimage/cli/__init__.py create mode 100644 src/logimage/cli/main.py create mode 100644 src/logimage/domain/__init__.py create mode 100644 src/logimage/domain/entities/__init__.py create mode 100644 src/logimage/domain/entities/puzzle.py create mode 100644 src/logimage/domain/ports/__init__.py create mode 100644 src/logimage/domain/ports/image_converter.py create mode 100644 src/logimage/domain/ports/image_source.py create mode 100644 src/logimage/domain/ports/pdf_exporter.py create mode 100644 src/logimage/domain/value_objects/__init__.py create mode 100644 src/logimage/domain/value_objects/clue.py create mode 100644 src/logimage/domain/value_objects/grid.py create mode 100644 src/logimage/domain/value_objects/image_data.py create mode 100644 src/logimage/infrastructure/__init__.py create mode 100644 src/logimage/infrastructure/image/__init__.py create mode 100644 src/logimage/infrastructure/image/local_file_source.py create mode 100644 src/logimage/infrastructure/image/pexels_source.py create mode 100644 src/logimage/infrastructure/image/pillow_converter.py create mode 100644 src/logimage/infrastructure/image/unsplash_source.py create mode 100644 src/logimage/infrastructure/pdf/__init__.py create mode 100644 src/logimage/infrastructure/pdf/reportlab_exporter.py create mode 100644 tests/__init__.py create mode 100644 tests/application/__init__.py create mode 100644 tests/application/test_generate_puzzles.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/test_main_routing.py create mode 100644 tests/conftest.py create mode 100644 tests/domain/__init__.py create mode 100644 tests/domain/test_clue.py create mode 100644 tests/domain/test_grid.py create mode 100644 tests/domain/test_puzzle.py create mode 100644 tests/fakes/__init__.py create mode 100644 tests/fakes/fakes.py create mode 100644 tests/helpers.py create mode 100644 tests/infrastructure/__init__.py create mode 100644 tests/infrastructure/test_local_file_source.py create mode 100644 tests/infrastructure/test_pexels_source.py create mode 100644 tests/infrastructure/test_pillow_converter.py create mode 100644 tests/infrastructure/test_pipeline_integration.py create mode 100644 tests/infrastructure/test_reportlab_exporter.py create mode 100644 tests/infrastructure/test_unsplash_source.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90ef9dc --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Pexels (prioritaire) — https://www.pexels.com/api/ +PEXELS_API_KEY= + +# Unsplash (fallback) — https://unsplash.com/developers +UNSPLASH_ACCESS_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5aa9129 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.omc/project-memory.json b/.omc/project-memory.json new file mode 100644 index 0000000..6da45cc --- /dev/null +++ b/.omc/project-memory.json @@ -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": [] +} \ No newline at end of file diff --git a/.omc/sessions/3568b4ad-a08c-4579-83f4-046cccd72c52.json b/.omc/sessions/3568b4ad-a08c-4579-83f4-046cccd72c52.json new file mode 100644 index 0000000..2f3358f --- /dev/null +++ b/.omc/sessions/3568b4ad-a08c-4579-83f4-046cccd72c52.json @@ -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": [] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..647b4aa --- /dev/null +++ b/README.md @@ -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/ +``` diff --git a/docs/superpowers/plans/2026-05-20-image-on-solution-page.md b/docs/superpowers/plans/2026-05-20-image-on-solution-page.md new file mode 100644 index 0000000..126e5b5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-image-on-solution-page.md @@ -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" +``` diff --git a/docs/superpowers/plans/2026-05-20-logimage-generator.md b/docs/superpowers/plans/2026-05-20-logimage-generator.md new file mode 100644 index 0000000..9b08346 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-logimage-generator.md @@ -0,0 +1,1470 @@ +# Logimage Generator 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:** Build a Python CLI that fetches images from Unsplash, converts them into nonogram puzzles, and generates a printable A4 PDF. + +**Architecture:** Clean Architecture with 4 layers (domain → application → infrastructure → cli). The domain has zero external dependencies; ports (ABCs) decouple infrastructure from business logic. TDD throughout: domain and application tests are pure/fast; infrastructure tests are integration-marked. + +**Tech Stack:** Python 3.11+, Pillow, opencv-python-headless, reportlab, httpx, python-dotenv, pytest, ruff, mypy. + +--- + +## File Map + +``` +src/logimage/ +├── __init__.py +├── domain/ +│ ├── __init__.py +│ ├── value_objects/ +│ │ ├── __init__.py +│ │ ├── clue.py +│ │ ├── grid.py +│ │ └── image_data.py +│ ├── entities/ +│ │ ├── __init__.py +│ │ └── puzzle.py +│ └── ports/ +│ ├── __init__.py +│ ├── image_source.py +│ ├── image_converter.py +│ └── pdf_exporter.py +├── application/ +│ ├── __init__.py +│ └── use_cases/ +│ ├── __init__.py +│ └── generate_puzzles.py +├── infrastructure/ +│ ├── __init__.py +│ ├── image/ +│ │ ├── __init__.py +│ │ ├── unsplash_source.py +│ │ └── pillow_converter.py +│ └── pdf/ +│ ├── __init__.py +│ └── reportlab_exporter.py +└── cli/ + ├── __init__.py + └── main.py + +tests/ +├── conftest.py +├── domain/ +│ ├── test_clue.py +│ ├── test_grid.py +│ └── test_puzzle.py +├── application/ +│ └── test_generate_puzzles.py +├── infrastructure/ +│ ├── test_pillow_converter.py +│ ├── test_unsplash_source.py +│ └── test_reportlab_exporter.py +└── fakes/ + ├── __init__.py + └── fakes.py + +pyproject.toml +.env.example +.gitignore +``` + +--- + +## Task 1: Project scaffold + +**Files:** +- Create: `pyproject.toml` +- Create: `src/logimage/__init__.py` (and all `__init__.py` stubs) +- Create: `tests/conftest.py` +- Create: `.gitignore` +- Create: `.env.example` + +- [ ] **Step 1: Create `pyproject.toml`** + +```toml +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.backends.legacy:build" + +[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 +``` + +- [ ] **Step 2: Create all `__init__.py` stubs and directory structure** + +```bash +mkdir -p src/logimage/{domain/{value_objects,entities,ports},application/use_cases,infrastructure/{image,pdf},cli} +mkdir -p tests/{domain,application,infrastructure,fakes} +touch src/logimage/__init__.py +touch src/logimage/domain/__init__.py +touch src/logimage/domain/value_objects/__init__.py +touch src/logimage/domain/entities/__init__.py +touch src/logimage/domain/ports/__init__.py +touch src/logimage/application/__init__.py +touch src/logimage/application/use_cases/__init__.py +touch src/logimage/infrastructure/__init__.py +touch src/logimage/infrastructure/image/__init__.py +touch src/logimage/infrastructure/pdf/__init__.py +touch src/logimage/cli/__init__.py +touch tests/__init__.py +touch tests/domain/__init__.py +touch tests/application/__init__.py +touch tests/infrastructure/__init__.py +touch tests/fakes/__init__.py +``` + +- [ ] **Step 3: Create `tests/conftest.py`** + +```python +import pytest +``` + +- [ ] **Step 4: Create `.gitignore`** + +``` +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +.env +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ +.superpowers/ +*.pdf +``` + +- [ ] **Step 5: Create `.env.example`** + +``` +UNSPLASH_ACCESS_KEY=your_key_here +``` + +- [ ] **Step 6: Install dependencies and verify pytest runs** + +```bash +pip install -e ".[dev]" +pytest +``` + +Expected: `no tests ran` (0 errors, 0 failures). + +- [ ] **Step 7: Commit** + +```bash +git add pyproject.toml .gitignore .env.example src/ tests/ +git commit -m "chore: scaffold project structure" +``` + +--- + +## Task 2: `Clue` value object + +**Files:** +- Create: `src/logimage/domain/value_objects/clue.py` +- Create: `tests/domain/test_clue.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/domain/test_clue.py +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,) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/domain/test_clue.py -v +``` + +Expected: `ModuleNotFoundError` or `ImportError`. + +- [ ] **Step 3: Implement `Clue`** + +```python +# src/logimage/domain/value_objects/clue.py +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)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/domain/test_clue.py -v +``` + +Expected: 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/logimage/domain/value_objects/clue.py tests/domain/test_clue.py +git commit -m "feat(domain): add Clue value object" +``` + +--- + +## Task 3: `Grid` value object + +**Files:** +- Create: `src/logimage/domain/value_objects/grid.py` +- Create: `tests/domain/test_grid.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/domain/test_grid.py +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: + cells = [[True, False, True], [False, True, False], [True, True, False], + [False, False, True], [True, False, False]] + # 5x3 is too small (width < 5), use 5x5 + 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) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/domain/test_grid.py -v +``` + +Expected: `ImportError`. + +- [ ] **Step 3: Implement `Grid`** + +```python +# src/logimage/domain/value_objects/grid.py +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)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/domain/test_grid.py -v +``` + +Expected: 9 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/logimage/domain/value_objects/grid.py tests/domain/test_grid.py +git commit -m "feat(domain): add Grid value object" +``` + +--- + +## Task 4: Domain ports and `ImageData` + +**Files:** +- Create: `src/logimage/domain/value_objects/image_data.py` +- Create: `src/logimage/domain/ports/image_source.py` +- Create: `src/logimage/domain/ports/image_converter.py` +- Create: `src/logimage/domain/ports/pdf_exporter.py` + +No business logic here — verify they are importable. + +- [ ] **Step 1: Create `ImageData`** + +```python +# src/logimage/domain/value_objects/image_data.py +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ImageData: + content: bytes + title: str +``` + +- [ ] **Step 2: Create `ImageSource` port** + +```python +# src/logimage/domain/ports/image_source.py +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: ... +``` + +- [ ] **Step 3: Create `ImageConverter` port** + +```python +# src/logimage/domain/ports/image_converter.py +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: ... +``` + +- [ ] **Step 4: Create `PdfExporter` port** + +```python +# src/logimage/domain/ports/pdf_exporter.py +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: ... +``` + +- [ ] **Step 5: Verify imports work** + +```bash +python -c " +from logimage.domain.value_objects.image_data import ImageData +from logimage.domain.ports.image_source import ImageSource +from logimage.domain.ports.image_converter import ImageConverter +print('OK') +" +``` + +Note: `PdfExporter` imports `NonogramPuzzle` which does not exist yet — skip its import check until Task 5. + +Expected: `OK` + +- [ ] **Step 6: Commit** + +```bash +git add src/logimage/domain/value_objects/image_data.py src/logimage/domain/ports/ +git commit -m "feat(domain): add ports and ImageData value object" +``` + +--- + +## Task 5: `NonogramPuzzle` entity + +**Files:** +- Create: `src/logimage/domain/entities/puzzle.py` +- Create: `tests/domain/test_puzzle.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/domain/test_puzzle.py +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") + # col 0: T,F,T,F,T → 1,1,1 + assert puzzle.col_clues[0] == Clue(values=(1, 1, 1)) + # col 1: F,T,T,F,F → 2 + 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 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/domain/test_puzzle.py -v +``` + +Expected: `ImportError`. + +- [ ] **Step 3: Implement `NonogramPuzzle`** + +```python +# src/logimage/domain/entities/puzzle.py +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 + + @classmethod + def from_grid(cls, grid: Grid, title: str) -> "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) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/domain/ -v +``` + +Expected: all domain tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/logimage/domain/entities/puzzle.py tests/domain/test_puzzle.py +git commit -m "feat(domain): add NonogramPuzzle entity" +``` + +--- + +## Task 6: Test fakes + `GeneratePuzzlesUseCase` + +**Files:** +- Create: `tests/fakes/fakes.py` +- Create: `src/logimage/application/use_cases/generate_puzzles.py` +- Create: `tests/application/test_generate_puzzles.py` + +- [ ] **Step 1: Create test fakes** + +```python +# tests/fakes/fakes.py +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 +``` + +- [ ] **Step 2: Write failing tests for the use case** + +```python +# tests/application/test_generate_puzzles.py +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 = FakeImageConverter(), FakeImageConverter(), FakePdfExporter() + use_case = GeneratePuzzlesUseCase(source, FakeImageConverter(), exporter) + use_case.execute(GeneratePuzzlesRequest()) + assert exporter.exported_puzzles[0].title == "Beautiful Cat" +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +pytest tests/application/test_generate_puzzles.py -v +``` + +Expected: `ImportError`. + +- [ ] **Step 4: Implement `GeneratePuzzlesUseCase`** + +```python +# src/logimage/application/use_cases/generate_puzzles.py +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) + puzzles.append(puzzle) + self._pdf_exporter.export(puzzles, request.output_path, request.with_solution) +``` + +- [ ] **Step 5: Run all tests** + +```bash +pytest tests/domain/ tests/application/ -v +``` + +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add tests/fakes/ src/logimage/application/ tests/application/ +git commit -m "feat(application): add GeneratePuzzlesUseCase with fakes" +``` + +--- + +## Task 7: `PillowImageConverter` + +**Files:** +- Create: `src/logimage/infrastructure/image/pillow_converter.py` +- Create: `tests/infrastructure/test_pillow_converter.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/infrastructure/test_pillow_converter.py +import io +import pytest +from PIL import Image +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) -> bytes: + img = Image.new("RGB", (width, height), color=(128, 64, 192)) + buf = io.BytesIO() + img.save(buf, format="JPEG") + 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 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/infrastructure/test_pillow_converter.py -v +``` + +Expected: `ImportError`. + +- [ ] **Step 3: Implement `PillowImageConverter`** + +```python +# src/logimage/infrastructure/image/pillow_converter.py +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 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) + + edges = cv2.Canny(arr, 50, 150) + blended = cv2.addWeighted(arr, 0.6, edges, 0.4, 0) + + _, binary = cv2.threshold( + blended, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU + ) + + cells = [ + [bool(binary[y, x]) for x in range(width)] + for y in range(height) + ] + return Grid.from_list(cells) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/infrastructure/test_pillow_converter.py -v +``` + +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/logimage/infrastructure/image/pillow_converter.py tests/infrastructure/test_pillow_converter.py +git commit -m "feat(infra): add PillowImageConverter with edge detection" +``` + +--- + +## Task 8: `UnsplashImageSource` + +**Files:** +- Create: `src/logimage/infrastructure/image/unsplash_source.py` +- Create: `tests/infrastructure/test_unsplash_source.py` + +- [ ] **Step 1: Write failing integration test** + +```python +# tests/infrastructure/test_unsplash_source.py +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(monkeypatch: pytest.MonkeyPatch) -> 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 +``` + +- [ ] **Step 2: Run test to verify it fails (or skips without key)** + +```bash +pytest tests/infrastructure/test_unsplash_source.py -v -m integration +``` + +Expected: `ImportError` (module not yet created). + +- [ ] **Step 3: Implement `UnsplashImageSource`** + +```python +# src/logimage/infrastructure/image/unsplash_source.py +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) +``` + +- [ ] **Step 4: Run integration tests (requires `UNSPLASH_ACCESS_KEY`)** + +```bash +pytest tests/infrastructure/test_unsplash_source.py -v -m integration +``` + +Expected: 2 passed (or skipped if no API key set). + +- [ ] **Step 5: Commit** + +```bash +git add src/logimage/infrastructure/image/unsplash_source.py tests/infrastructure/test_unsplash_source.py +git commit -m "feat(infra): add UnsplashImageSource" +``` + +--- + +## Task 9: `ReportLabPdfExporter` + +**Files:** +- Create: `src/logimage/infrastructure/pdf/reportlab_exporter.py` +- Create: `tests/infrastructure/test_reportlab_exporter.py` + +- [ ] **Step 1: Write failing tests** + +```python +# tests/infrastructure/test_reportlab_exporter.py +import tempfile +from pathlib import Path +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" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/infrastructure/test_reportlab_exporter.py -v +``` + +Expected: `ImportError`. + +- [ ] **Step 3: Implement `ReportLabPdfExporter`** + +```python +# src/logimage/infrastructure/pdf/reportlab_exporter.py +from pathlib import Path +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import mm +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 + + +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) + + 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 = (_PAGE_W - block_w) / 2 + # Vertical center (below title) + origin_y = (_PAGE_H - _TITLE_H - block_h) / 2 + _TITLE_H / 2 + + grid_x = origin_x + row_clue_w + grid_y = origin_y # bottom of grid + + # Title + 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, + ) + + # Grid cells + 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) + + # Thicker horizontal line every 5 rows + 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) + + # Row clues (left of grid) + 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) + + # Column clues (above grid, bottom-aligned) + 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): + # bottom-align: last value (i = n-1) sits just above grid + 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)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/infrastructure/test_reportlab_exporter.py -v +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/logimage/infrastructure/pdf/reportlab_exporter.py tests/infrastructure/test_reportlab_exporter.py +git commit -m "feat(infra): add ReportLabPdfExporter" +``` + +--- + +## Task 10: CLI + final wiring + +**Files:** +- Create: `src/logimage/cli/main.py` + +- [ ] **Step 1: Implement `main.py`** + +```python +# src/logimage/cli/main.py +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.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() + + api_key = os.environ.get("UNSPLASH_ACCESS_KEY") + if not api_key: + print( + "Error: UNSPLASH_ACCESS_KEY is not set.\n" + "Create a .env file with UNSPLASH_ACCESS_KEY= " + "or set the environment variable.", + file=sys.stderr, + ) + sys.exit(1) + + use_case = GeneratePuzzlesUseCase( + image_source=UnsplashImageSource(api_key=api_key), + 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 2: Smoke-test the CLI help** + +```bash +python -m logimage --help +``` + +Expected: argument list printed, no errors. + +- [ ] **Step 3: Run full test suite** + +```bash +pytest tests/domain tests/application tests/infrastructure/test_pillow_converter.py tests/infrastructure/test_reportlab_exporter.py -v --cov=logimage --cov-report=term-missing +``` + +Expected: all pass, coverage ≥ 90% on `domain/` and `application/`. + +- [ ] **Step 4: Commit** + +```bash +git add src/logimage/cli/main.py +git commit -m "feat(cli): add CLI entry point and composition root" +``` + +--- + +## Task 11: End-to-end smoke test + +This verifies the whole pipeline runs without a crash. Requires `UNSPLASH_ACCESS_KEY` in `.env`. + +- [ ] **Step 1: Create `.env` from the example** + +```bash +cp .env.example .env +# Edit .env and fill in your UNSPLASH_ACCESS_KEY +``` + +- [ ] **Step 2: Run end-to-end** + +```bash +logimage --theme "architecture" --difficulty easy --count 2 --solution --output test_output.pdf +``` + +Expected: +``` +Generating 2 puzzle(s)… +Done! PDF saved to: test_output.pdf +``` + +- [ ] **Step 3: Open `test_output.pdf` and verify** + +- 4 pages total (2 puzzles × 2 pages each: puzzle + solution) +- Each puzzle page: title at top, grid centered, row clues on left, column clues above +- Each solution page: same grid with filled black cells + +- [ ] **Step 4: Clean up test file and commit** + +```bash +rm test_output.pdf +git add .env.example +git commit -m "chore: verify end-to-end pipeline works" +``` diff --git a/docs/superpowers/plans/2026-05-20-pexels-image-source.md b/docs/superpowers/plans/2026-05-20-pexels-image-source.md new file mode 100644 index 0000000..26bc843 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-pexels-image-source.md @@ -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)" +``` diff --git a/docs/superpowers/plans/2026-05-21-pipeline-conversion-ameliore.md b/docs/superpowers/plans/2026-05-21-pipeline-conversion-ameliore.md new file mode 100644 index 0000000..83f516c --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-pipeline-conversion-ameliore.md @@ -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" +``` diff --git a/docs/superpowers/specs/2026-05-20-image-on-solution-page-design.md b/docs/superpowers/specs/2026-05-20-image-on-solution-page-design.md new file mode 100644 index 0000000..fc92e02 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-image-on-solution-page-design.md @@ -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é) diff --git a/docs/superpowers/specs/2026-05-20-logimage-generator-design.md b/docs/superpowers/specs/2026-05-20-logimage-generator-design.md new file mode 100644 index 0000000..5842114 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-logimage-generator-design.md @@ -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=`) +- 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 diff --git a/docs/superpowers/specs/2026-05-20-pexels-image-source-design.md b/docs/superpowers/specs/2026-05-20-pexels-image-source-design.md new file mode 100644 index 0000000..60009bf --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-pexels-image-source-design.md @@ -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=&per_page=1` +- Sans thème : `GET https://api.pexels.com/v1/curated?per_page=1` + +Authentification : header `Authorization: ` (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) diff --git a/docs/superpowers/specs/2026-05-21-pipeline-conversion-ameliore-design.md b/docs/superpowers/specs/2026-05-21-pipeline-conversion-ameliore-design.md new file mode 100644 index 0000000..9bb3f56 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-pipeline-conversion-ameliore-design.md @@ -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 ` 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f1ffe65 --- /dev/null +++ b/pyproject.toml @@ -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 diff --git a/src/logimage/__init__.py b/src/logimage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/application/__init__.py b/src/logimage/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/application/use_cases/__init__.py b/src/logimage/application/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/application/use_cases/generate_puzzles.py b/src/logimage/application/use_cases/generate_puzzles.py new file mode 100644 index 0000000..d5111e7 --- /dev/null +++ b/src/logimage/application/use_cases/generate_puzzles.py @@ -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) diff --git a/src/logimage/cli/__init__.py b/src/logimage/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/cli/main.py b/src/logimage/cli/main.py new file mode 100644 index 0000000..9c73f60 --- /dev/null +++ b/src/logimage/cli/main.py @@ -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}") diff --git a/src/logimage/domain/__init__.py b/src/logimage/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/domain/entities/__init__.py b/src/logimage/domain/entities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/domain/entities/puzzle.py b/src/logimage/domain/entities/puzzle.py new file mode 100644 index 0000000..eca1e87 --- /dev/null +++ b/src/logimage/domain/entities/puzzle.py @@ -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, + ) diff --git a/src/logimage/domain/ports/__init__.py b/src/logimage/domain/ports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/domain/ports/image_converter.py b/src/logimage/domain/ports/image_converter.py new file mode 100644 index 0000000..a835f98 --- /dev/null +++ b/src/logimage/domain/ports/image_converter.py @@ -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: ... diff --git a/src/logimage/domain/ports/image_source.py b/src/logimage/domain/ports/image_source.py new file mode 100644 index 0000000..29556fc --- /dev/null +++ b/src/logimage/domain/ports/image_source.py @@ -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: ... diff --git a/src/logimage/domain/ports/pdf_exporter.py b/src/logimage/domain/ports/pdf_exporter.py new file mode 100644 index 0000000..a182afd --- /dev/null +++ b/src/logimage/domain/ports/pdf_exporter.py @@ -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: ... diff --git a/src/logimage/domain/value_objects/__init__.py b/src/logimage/domain/value_objects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/domain/value_objects/clue.py b/src/logimage/domain/value_objects/clue.py new file mode 100644 index 0000000..b5711b0 --- /dev/null +++ b/src/logimage/domain/value_objects/clue.py @@ -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)) diff --git a/src/logimage/domain/value_objects/grid.py b/src/logimage/domain/value_objects/grid.py new file mode 100644 index 0000000..b566bf3 --- /dev/null +++ b/src/logimage/domain/value_objects/grid.py @@ -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)) diff --git a/src/logimage/domain/value_objects/image_data.py b/src/logimage/domain/value_objects/image_data.py new file mode 100644 index 0000000..74a1a61 --- /dev/null +++ b/src/logimage/domain/value_objects/image_data.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ImageData: + content: bytes + title: str diff --git a/src/logimage/infrastructure/__init__.py b/src/logimage/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/infrastructure/image/__init__.py b/src/logimage/infrastructure/image/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/infrastructure/image/local_file_source.py b/src/logimage/infrastructure/image/local_file_source.py new file mode 100644 index 0000000..29ecd51 --- /dev/null +++ b/src/logimage/infrastructure/image/local_file_source.py @@ -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) diff --git a/src/logimage/infrastructure/image/pexels_source.py b/src/logimage/infrastructure/image/pexels_source.py new file mode 100644 index 0000000..65b6526 --- /dev/null +++ b/src/logimage/infrastructure/image/pexels_source.py @@ -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) diff --git a/src/logimage/infrastructure/image/pillow_converter.py b/src/logimage/infrastructure/image/pillow_converter.py new file mode 100644 index 0000000..4228b53 --- /dev/null +++ b/src/logimage/infrastructure/image/pillow_converter.py @@ -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 diff --git a/src/logimage/infrastructure/image/unsplash_source.py b/src/logimage/infrastructure/image/unsplash_source.py new file mode 100644 index 0000000..79c53ce --- /dev/null +++ b/src/logimage/infrastructure/image/unsplash_source.py @@ -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) diff --git a/src/logimage/infrastructure/pdf/__init__.py b/src/logimage/infrastructure/pdf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logimage/infrastructure/pdf/reportlab_exporter.py b/src/logimage/infrastructure/pdf/reportlab_exporter.py new file mode 100644 index 0000000..f183597 --- /dev/null +++ b/src/logimage/infrastructure/pdf/reportlab_exporter.py @@ -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") diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/application/__init__.py b/tests/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/application/test_generate_puzzles.py b/tests/application/test_generate_puzzles.py new file mode 100644 index 0000000..90df32d --- /dev/null +++ b/tests/application/test_generate_puzzles.py @@ -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" diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_main_routing.py b/tests/cli/test_main_routing.py new file mode 100644 index 0000000..42eb868 --- /dev/null +++ b/tests/cli/test_main_routing.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/domain/__init__.py b/tests/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/domain/test_clue.py b/tests/domain/test_clue.py new file mode 100644 index 0000000..96b8d5a --- /dev/null +++ b/tests/domain/test_clue.py @@ -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,) diff --git a/tests/domain/test_grid.py b/tests/domain/test_grid.py new file mode 100644 index 0000000..dcba8a5 --- /dev/null +++ b/tests/domain/test_grid.py @@ -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) diff --git a/tests/domain/test_puzzle.py b/tests/domain/test_puzzle.py new file mode 100644 index 0000000..2f0e744 --- /dev/null +++ b/tests/domain/test_puzzle.py @@ -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" diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fakes/fakes.py b/tests/fakes/fakes.py new file mode 100644 index 0000000..f5cbc48 --- /dev/null +++ b/tests/fakes/fakes.py @@ -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 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..502ebba --- /dev/null +++ b/tests/helpers.py @@ -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 diff --git a/tests/infrastructure/__init__.py b/tests/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/infrastructure/test_local_file_source.py b/tests/infrastructure/test_local_file_source.py new file mode 100644 index 0000000..f3c9ec8 --- /dev/null +++ b/tests/infrastructure/test_local_file_source.py @@ -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() diff --git a/tests/infrastructure/test_pexels_source.py b/tests/infrastructure/test_pexels_source.py new file mode 100644 index 0000000..f664c9b --- /dev/null +++ b/tests/infrastructure/test_pexels_source.py @@ -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) diff --git a/tests/infrastructure/test_pillow_converter.py b/tests/infrastructure/test_pillow_converter.py new file mode 100644 index 0000000..dd16152 --- /dev/null +++ b/tests/infrastructure/test_pillow_converter.py @@ -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 diff --git a/tests/infrastructure/test_pipeline_integration.py b/tests/infrastructure/test_pipeline_integration.py new file mode 100644 index 0000000..cffa8c7 --- /dev/null +++ b/tests/infrastructure/test_pipeline_integration.py @@ -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" diff --git a/tests/infrastructure/test_reportlab_exporter.py b/tests/infrastructure/test_reportlab_exporter.py new file mode 100644 index 0000000..d82824d --- /dev/null +++ b/tests/infrastructure/test_reportlab_exporter.py @@ -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" diff --git a/tests/infrastructure/test_unsplash_source.py b/tests/infrastructure/test_unsplash_source.py new file mode 100644 index 0000000..f16f25c --- /dev/null +++ b/tests/infrastructure/test_unsplash_source.py @@ -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