115 lines
3.5 KiB
Python
115 lines
3.5 KiB
Python
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 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"
|