256 lines
8.9 KiB
Python
256 lines
8.9 KiB
Python
#!/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)
|