#!/usr/bin/env python3 """Export cataloged creature artwork with consistent transparent margins.""" from __future__ import annotations import argparse import hashlib import re import sys import zipfile import xml.etree.ElementTree as ET from pathlib import Path from PIL import Image TABLE_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0" TABLE_ATTRIBUTE = "{%s}" % TABLE_NAMESPACE NAMESPACES = {"table": TABLE_NAMESPACE} VALID_EXPORT_SIZES = {64, 128, 256, 512} SAFE_AREA_RATIO = 0.875 KNOWN_SOURCE_ALIASES = { # The delivered filename predates the catalog's authoritative species ID. "bowfish": "bowfin", } def parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Export PNG creature art according to the catalog's recommended " "canvas size, using alpha-bound trimming and nearest-neighbor scaling." ) ) parser.add_argument("--tracker", required=True, type=Path) parser.add_argument("--source", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument( "--skip", action="append", default=[], help="Catalog creature ID or source filename stem to skip; repeat as needed.", ) parser.add_argument( "--dry-run", action="store_true", help="Validate and report the batch without writing output files.", ) return parser.parse_args() def normalize_name(value: str) -> str: normalized = value.strip().lower().replace("&", " and ") normalized = re.sub(r"[^a-z0-9]+", "_", normalized) return normalized.strip("_") def expanded_row_values(row: ET.Element) -> list[str]: values: list[str] = [] for cell in row.findall("table:table-cell", NAMESPACES): repeat = int( cell.attrib.get( TABLE_ATTRIBUTE + "number-columns-repeated", "1", ) ) values.extend(["".join(cell.itertext()).strip()] * repeat) return values def load_catalog(tracker_path: Path) -> list[dict[str, str]]: with zipfile.ZipFile(tracker_path) as archive: root = ET.fromstring(archive.read("content.xml")) table = root.find(".//table:table", NAMESPACES) if table is None: raise ValueError("tracker does not contain a table") rows = table.findall("table:table-row", NAMESPACES) if not rows: raise ValueError("tracker table is empty") headers = expanded_row_values(rows[0]) required_headers = { "catalog_number", "display_name", "id", "recommended_export_canvas_px", } missing_headers = required_headers.difference(headers) if missing_headers: raise ValueError( "tracker is missing required columns: " + ", ".join(sorted(missing_headers)) ) records: list[dict[str, str]] = [] for row in rows[1:]: values = expanded_row_values(row) if not any(values): continue record = dict(zip(headers, values)) if record.get("id", "").strip(): records.append(record) return records def catalog_lookup(records: list[dict[str, str]]) -> dict[str, dict[str, str]]: lookup: dict[str, dict[str, str]] = {} for record in records: keys = { normalize_name(record["catalog_number"]), normalize_name(record["id"]), normalize_name(record["display_name"]), } for key in keys: previous = lookup.get(key) if previous is not None and previous["id"] != record["id"]: raise ValueError( f"ambiguous normalized catalog name {key!r}: " f"{previous['id']} and {record['id']}" ) lookup[key] = record return lookup def resolve_record( source_path: Path, lookup: dict[str, dict[str, str]], ) -> dict[str, str]: source_key = normalize_name(source_path.stem) catalog_key = KNOWN_SOURCE_ALIASES.get(source_key, source_key) record = lookup.get(catalog_key) if record is None: raise ValueError( f"cannot map source {source_path.name!r} to one catalog creature" ) return record def normalize_artwork(source_path: Path, canvas_size: int) -> tuple[Image.Image, tuple[int, int, int, int], tuple[int, int]]: with Image.open(source_path) as source_image: artwork = source_image.convert("RGBA") alpha_bounds = artwork.getchannel("A").getbbox() if alpha_bounds is None: raise ValueError(f"source {source_path.name!r} is fully transparent") visible = artwork.crop(alpha_bounds) safe_long_side = round(canvas_size * SAFE_AREA_RATIO) scale = safe_long_side / max(visible.width, visible.height) scaled_size = ( max(1, round(visible.width * scale)), max(1, round(visible.height * scale)), ) visible = visible.resize(scaled_size, Image.Resampling.NEAREST) canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0)) offset = ( (canvas_size - scaled_size[0]) // 2, (canvas_size - scaled_size[1]) // 2, ) canvas.alpha_composite(visible, offset) return canvas, alpha_bounds, scaled_size def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as source: for block in iter(lambda: source.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def main() -> int: arguments = parse_arguments() records = load_catalog(arguments.tracker) lookup = catalog_lookup(records) skip_keys = {normalize_name(value) for value in arguments.skip} source_paths = sorted(arguments.source.glob("*.png")) if not source_paths: raise ValueError(f"no PNG files found under {arguments.source}") resolved_by_output: dict[str, tuple[Path, dict[str, str]]] = {} for source_path in source_paths: record = resolve_record(source_path, lookup) source_key = normalize_name(source_path.stem) creature_key = normalize_name(record["id"]) if source_key in skip_keys or creature_key in skip_keys: print(f"SKIP {source_path.name} -> {record['id']}") continue output_name = f"{record['catalog_number']}.png" previous = resolved_by_output.get(output_name) if previous is not None: previous_path, _previous_record = previous catalog_key = normalize_name(record["catalog_number"]) previous_is_numbered = ( normalize_name(previous_path.stem) == catalog_key ) current_is_numbered = source_key == catalog_key if current_is_numbered and not previous_is_numbered: print( "SUPERSEDE " f"{previous_path.name} with numbered source " f"{source_path.name} -> {record['id']}" ) resolved_by_output[output_name] = (source_path, record) continue if previous_is_numbered and not current_is_numbered: print( "SKIP " f"{source_path.name}; numbered source " f"{previous_path.name} takes precedence -> {record['id']}" ) continue raise ValueError( f"duplicate sources for output filename {output_name}: " f"{previous_path.name} and {source_path.name}" ) resolved_by_output[output_name] = (source_path, record) resolved = list(resolved_by_output.values()) if not arguments.dry_run: arguments.output.mkdir(parents=True, exist_ok=True) for source_path, record in resolved: canvas_size = int(record["recommended_export_canvas_px"]) if canvas_size not in VALID_EXPORT_SIZES: raise ValueError( f"{record['id']} has invalid export size {canvas_size}" ) image, alpha_bounds, scaled_size = normalize_artwork( source_path, canvas_size, ) output_path = arguments.output / f"{record['catalog_number']}.png" if not arguments.dry_run: image.save(output_path, format="PNG", optimize=False, compress_level=6) output_hash = sha256(output_path) else: output_hash = "dry-run" print( "EXPORT " f"{source_path.name} -> {output_path.name} " f"id={record['id']} canvas={canvas_size}x{canvas_size} " f"alpha_bounds={alpha_bounds} visible={scaled_size[0]}x{scaled_size[1]} " f"source_sha256={sha256(source_path)} output_sha256={output_hash}" ) print( f"Complete: {len(resolved)} export(s), " f"{len(source_paths) - len(resolved)} skipped." ) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except (OSError, ValueError, zipfile.BadZipFile) as error: print(f"ERROR: {error}", file=sys.stderr) raise SystemExit(1)