import json import re import struct from functools import lru_cache from io import BytesIO from pathlib import Path from PIL import Image, UnidentifiedImageError from .settings import DATA_DIR def load_json(path: Path) -> dict: with path.open("r", encoding="utf-8") as f: return json.load(f) def load_project_version() -> str: project_file = DATA_DIR / "project.json" if not project_file.exists(): return "" return str(load_json(project_file).get("projectVersion", "")) def find_art_for_json(json_path: Path) -> Path | None: base = json_path.with_suffix("") for ext in (".png", ".jpg", ".jpeg"): candidate = base.with_suffix(ext) if candidate.exists(): return candidate return None @lru_cache(maxsize=256) def _load_rgb_cached(path: str, mtime_ns: int, size: int) -> Image.Image: try: return Image.open(path).convert("RGBA") except UnidentifiedImageError: cleaned = strip_png_ancillary_chunks(Path(path)) if cleaned is None: raise return Image.open(BytesIO(cleaned)).convert("RGBA") def load_rgb(path: Path) -> Image.Image: stat = path.stat() return _load_rgb_cached(str(path), stat.st_mtime_ns, stat.st_size).copy() def save_temp_png(img: Image.Image, path: Path, dpi: int) -> None: img.save(path, dpi=(dpi, dpi), compress_level=1, optimize=False) def strip_png_ancillary_chunks(path: Path) -> bytes | None: """Return a PNG with nonessential chunks removed. A few source PNG templates carry metadata chunks that Pillow refuses to parse, while the pixel data itself is valid. Keeping critical chunks gives us a deterministic source-data repair without touching the copied assets. """ data = path.read_bytes() signature = b"\x89PNG\r\n\x1a\n" if not data.startswith(signature): return None keep = {b"IHDR", b"PLTE", b"IDAT", b"IEND", b"tRNS"} out = bytearray(signature) pos = len(signature) try: while pos + 12 <= len(data): size = struct.unpack(">I", data[pos : pos + 4])[0] chunk_type = data[pos + 4 : pos + 8] chunk_end = pos + 8 + size + 4 if chunk_end > len(data): return None if chunk_type in keep: out.extend(data[pos:chunk_end]) pos = chunk_end if chunk_type == b"IEND": return bytes(out) except struct.error: return None return None def cover_resize(img: Image.Image, size: tuple[int, int]) -> Image.Image: target_w, target_h = size src = img.convert("RGBA") ratio = max(target_w / src.width, target_h / src.height) new_size = (round(src.width * ratio), round(src.height * ratio)) resized = src.resize(new_size, Image.Resampling.LANCZOS) left = max(0, (resized.width - target_w) // 2) top = max(0, (resized.height - target_h) // 2) return resized.crop((left, top, left + target_w, top + target_h)) def render_work_scale(meta: dict, target_size: tuple[int, int]) -> float: sx = target_size[0] / float(meta["cardWidth"]) sy = target_size[1] / float(meta["cardHeight"]) return max(2.0, sx, sy) def meta_unit_scale(meta: dict) -> float: return float(meta.get("cardWidth", 825)) / 825.0 def hex_color(value: str | None, fallback: str = "#ffffff") -> tuple[int, int, int]: value = value or fallback value = value.strip() if value.startswith("#"): value = value[1:] if len(value) != 6: return hex_color(fallback) return tuple(int(value[i : i + 2], 16) for i in (0, 2, 4)) def safe_name(value: str) -> str: value = re.sub(r'[\\/:*?"<>|]', "", value) value = re.sub(r"\s+", "_", value.strip()) return value or "untitled"