"""Génère des frises horizontales de minifigs par personnage.""" from pathlib import Path from typing import Dict, Iterable, List, Sequence, Set from PIL import Image, ImageDraw, ImageFont from lib.filesystem import ensure_parent_dir from lib.rebrickable.resources import sanitize_name def resize_to_height(image: Image.Image, target_height: int) -> Image.Image: """Redimensionne une image en conservant le ratio selon une hauteur cible.""" width, height = image.size ratio = target_height / height new_width = int(width * ratio) return image.resize((new_width, target_height), Image.Resampling.LANCZOS) def render_label(width: int, height: int, text: str, font: ImageFont.ImageFont) -> Image.Image: """Construit l'étiquette textuelle centrée sous une minifig.""" label = Image.new("RGB", (width, height), (255, 255, 255)) drawer = ImageDraw.Draw(label) bbox = drawer.textbbox((0, 0), text, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] x = (width - text_width) // 2 y = (height - text_height) // 2 drawer.text((x, y), text, fill=(0, 0, 0), font=font) return label def build_placeholder(image_height: int) -> Image.Image: """Crée un rectangle neutre pour signaler une image manquante.""" placeholder = Image.new("RGBA", (image_height, image_height), (220, 220, 220, 255)) drawer = ImageDraw.Draw(placeholder) drawer.rectangle([(0, 0), (image_height - 1, image_height - 1)], outline=(150, 150, 150), width=2) drawer.line([(0, 0), (image_height - 1, image_height - 1)], fill=(200, 80, 80), width=3) drawer.line([(image_height - 1, 0), (0, image_height - 1)], fill=(200, 80, 80), width=3) return placeholder def build_cell(image: Image.Image, label: str, image_height: int, label_height: int, font: ImageFont.ImageFont) -> Image.Image: """Assemble une minifig redimensionnée et son étiquette associée.""" resized = resize_to_height(image, image_height) label_img = render_label(resized.width, label_height, label, font).convert("RGBA") cell = Image.new("RGBA", (resized.width, resized.height + label_height), (255, 255, 255, 255)) cell.paste(resized, (0, 0)) cell.paste(label_img, (0, resized.height)) return cell def build_character_collage( character: str, entries: Sequence[dict], resources_dir: Path, destination_dir: Path, font: ImageFont.ImageFont, missing_paths: Set[str] | None = None, image_filename: str = "minifig.jpg", label_field: str = "fig_num", image_height: int = 260, label_height: int = 44, spacing: int = 28, ) -> Path: """Construit la frise d'un personnage et la sauvegarde dans le répertoire cible.""" sanitized = sanitize_name(character) missing = missing_paths or set() cells: List[Image.Image] = [] for row in entries: image_path = resources_dir / row["set_id"] / sanitized / image_filename owned = "*" if row.get("in_collection", "").lower() == "true" else "" label_value = row[label_field] label = f"{row['year']} - {row['set_num']}{owned} ({label_value})" if str(image_path) in missing: image = build_placeholder(image_height) else: image = Image.open(image_path).convert("RGBA") cells.append(build_cell(image, label, image_height, label_height, font)) total_width = sum(cell.width for cell in cells) + spacing * (len(cells) - 1) total_height = max(cell.height for cell in cells) canvas = Image.new("RGB", (total_width, total_height), (255, 255, 255)) x = 0 for cell in cells: canvas.paste(cell.convert("RGB"), (x, 0)) x += cell.width + spacing destination_path = destination_dir / f"{sanitized}.png" ensure_parent_dir(destination_path) canvas.save(destination_path) return destination_path def build_character_collages( grouped_entries: Dict[str, Sequence[dict]], resources_dir: Path, destination_dir: Path, missing_paths: Set[str] | None = None, image_filename: str = "minifig.jpg", label_field: str = "fig_num", image_height: int = 260, label_height: int = 44, spacing: int = 28, ) -> List[Path]: """Construit les frises pour chaque personnage.""" font = ImageFont.load_default() generated: List[Path] = [] for character, entries in grouped_entries.items(): generated.append( build_character_collage( character, entries, resources_dir, destination_dir, font, missing_paths=missing_paths, image_filename=image_filename, label_field=label_field, image_height=image_height, label_height=label_height, spacing=spacing, ) ) return generated def filter_characters_with_variations( grouped_entries: Dict[str, Sequence[dict]], min_variations: int = 2, ) -> Dict[str, Sequence[dict]]: """Conserve uniquement les personnages ayant un nombre minimal de variations (fig_num distincts).""" filtered: Dict[str, Sequence[dict]] = {} for character, entries in grouped_entries.items(): variations = {entry["fig_num"] for entry in entries} if len(variations) >= min_variations: filtered[character] = entries return filtered def stack_collages( source_dir: Path, destination_path: Path, spacing: int = 18, allowed_stems: Set[str] | None = None, name_panel_width: int = 0, labels_by_stem: Dict[str, str] | None = None, ) -> None: """Superpose verticalement toutes les frises présentes dans un dossier.""" images: List[Image.Image] = [] stems: List[str] = [] for path in sorted(source_dir.glob("*.png")): if allowed_stems is not None and path.stem not in allowed_stems: continue images.append(Image.open(path).convert("RGB")) stems.append(path.stem) if not images: return font = ImageFont.load_default() if name_panel_width > 0: with_names: List[Image.Image] = [] for image, stem in zip(images, stems): label = labels_by_stem.get(stem, stem) if labels_by_stem else stem name_img = render_label(name_panel_width, image.height, label, font).convert("RGB") combined = Image.new("RGB", (name_panel_width + image.width, image.height), (255, 255, 255)) combined.paste(name_img, (0, 0)) combined.paste(image, (name_panel_width, 0)) with_names.append(combined) images = with_names max_width = max(image.width for image in images) total_height = sum(image.height for image in images) + spacing * (len(images) - 1) canvas = Image.new("RGB", (max_width, total_height), (255, 255, 255)) y = 0 for image in images: canvas.paste(image, (0, y)) y += image.height + spacing ensure_parent_dir(destination_path) canvas.save(destination_path)