← Files Comic SolARCHIVED FILE

skills/comic-sol/scripts/normalize_panels.py

8.78 KB · Sep 30, 2026 · 23:14 UTC

↓ Download file

#!/usr/bin/env python3
"""Deterministic panel normalization and provenance publication."""

from __future__ import annotations

import hashlib
import io
import json
import re
import warnings
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

from PIL import Image, ImageOps, UnidentifiedImageError

# Fail closed on crafted raster decompression bombs; explicit per-project decoded
# size checks below remain the authoritative lettering/normalization ceiling.
Image.MAX_IMAGE_PIXELS = 1600 * 2400 * 16

from project_io import ProjectTransaction, contained_project_path


IMPLEMENTATION_VERSION = "1"
MAX_DECODED_PIXELS = 100_000_000
PANEL_ID = re.compile(r"^p[0-9]{2}-[0-9]{2}$")
MODES = frozenset({"crop", "fit", "exact"})


@dataclass(frozen=True)
class NormalizationSpec:
    panel_id: str
    source_relative: str
    target_size: tuple[int, int]
    mode: str


@dataclass(frozen=True)
class NormalizationGeometry:
    source_size: tuple[int, int]
    target_size: tuple[int, int]
    mode: str
    crop_box: tuple[int, int, int, int] | None
    resized_size: tuple[int, int]
    paste_origin: tuple[int, int]


@dataclass(frozen=True)
class _PreparedNormalization:
    spec: NormalizationSpec
    clean_bytes: bytes
    record_bytes: bytes


def _positive_size(value: object, name: str) -> tuple[int, int]:
    if (
        not isinstance(value, tuple)
        or len(value) != 2
        or any(isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in value)
    ):
        raise ValueError(f"{name} dimensions must be positive integers")
    width, height = value
    if width * height > MAX_DECODED_PIXELS:
        raise ValueError(f"{name} exceeds the decoded pixel limit")
    return width, height


def normalization_geometry(
    source_size: tuple[int, int],
    target_size: tuple[int, int],
    mode: str,
) -> NormalizationGeometry:
    """Compute deterministic geometry in oriented source pixel coordinates."""
    source_width, source_height = _positive_size(source_size, "source size")
    target_width, target_height = _positive_size(target_size, "target size")
    if mode not in MODES:
        raise ValueError(f"normalization mode must be one of {sorted(MODES)}")

    source_ratio = source_width / source_height
    target_ratio = target_width / target_height
    if mode == "exact":
        if source_width * target_height != source_height * target_width:
            raise ValueError("exact normalization requires matching aspect ratios")
        return NormalizationGeometry(
            source_size, target_size, mode, None, target_size, (0, 0)
        )

    if mode == "fit":
        if source_width * target_height >= source_height * target_width:
            resized_width = target_width
            resized_height = max(1, (source_height * target_width) // source_width)
        else:
            resized_height = target_height
            resized_width = max(1, (source_width * target_height) // source_height)
        origin = (
            (target_width - resized_width) // 2,
            (target_height - resized_height) // 2,
        )
        return NormalizationGeometry(
            source_size, target_size, mode, None,
            (resized_width, resized_height), origin,
        )

    if source_ratio > target_ratio:
        crop_width = (source_height * target_width) // target_height
        left = (source_width - crop_width) // 2
        crop_box = (left, 0, left + crop_width, source_height)
    else:
        crop_height = (source_width * target_height) // target_width
        top = (source_height - crop_height) // 2
        crop_box = (0, top, source_width, top + crop_height)
    return NormalizationGeometry(
        source_size, target_size, mode, crop_box, target_size, (0, 0)
    )


def _canonical_json(value: object) -> bytes:
    return (
        json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
    ).encode("utf-8")


def _sha256(payload: bytes) -> str:
    return hashlib.sha256(payload).hexdigest()


def _png_bytes(image: Image.Image) -> bytes:
    output = io.BytesIO()
    image.save(
        output,
        format="PNG",
        optimize=False,
        compress_level=9,
    )
    return output.getvalue()


def _prepare(project_dir: Path, spec: NormalizationSpec) -> _PreparedNormalization:
    if not isinstance(spec, NormalizationSpec):
        raise TypeError("normalization specs must be NormalizationSpec values")
    if PANEL_ID.fullmatch(spec.panel_id) is None:
        raise ValueError("panel_id must match pNN-NN")
    target_size = _positive_size(spec.target_size, "target size")
    if spec.mode not in MODES:
        raise ValueError(f"normalization mode must be one of {sorted(MODES)}")

    source_path = contained_project_path(
        project_dir, spec.source_relative, must_exist=True
    )
    source_bytes = source_path.read_bytes()
    try:
        with warnings.catch_warnings():
            warnings.simplefilter("error", Image.DecompressionBombWarning)
            with Image.open(io.BytesIO(source_bytes)) as source:
                source_format = source.format
                encoded_size = source.size
                orientation = source.getexif().get(274, 1)
                if encoded_size[0] * encoded_size[1] > MAX_DECODED_PIXELS:
                    raise ValueError("source image exceeds the decoded pixel limit")
                source.load()
                oriented = ImageOps.exif_transpose(source).convert("RGB")
    except ValueError:
        raise
    except (
        OSError,
        SyntaxError,
        UnidentifiedImageError,
        Image.DecompressionBombError,
        Image.DecompressionBombWarning,
    ) as error:
        raise ValueError(f"source is not a readable image: {spec.source_relative}") from error

    if source_format not in {"PNG", "JPEG", "WEBP"}:
        raise ValueError("source image format must be PNG, JPEG, or WEBP")
    geometry = normalization_geometry(oriented.size, target_size, spec.mode)
    if geometry.crop_box is not None:
        clean = oriented.crop(geometry.crop_box).resize(
            target_size, Image.Resampling.LANCZOS
        )
    elif spec.mode == "fit":
        resized = oriented.resize(geometry.resized_size, Image.Resampling.LANCZOS)
        clean = Image.new("RGB", target_size, "white")
        clean.paste(resized, geometry.paste_origin)
    else:
        clean = oriented.resize(target_size, Image.Resampling.LANCZOS)

    clean_bytes = _png_bytes(clean)
    clean_relative = f"panels/{spec.panel_id}/clean.png"
    record = {
        "clean": {
            "mode": "RGB",
            "path": clean_relative,
            "sha256": _sha256(clean_bytes),
            "size": list(clean.size),
        },
        "implementation_version": IMPLEMENTATION_VERSION,
        "operation": {
            "crop_box": (
                list(geometry.crop_box) if geometry.crop_box is not None else None
            ),
            "mode": spec.mode,
            "paste_origin": list(geometry.paste_origin),
            "resized_size": list(geometry.resized_size),
        },
        "panel_id": spec.panel_id,
        "schema_version": "1.0",
        "source": {
            "encoded_size": list(encoded_size),
            "exif_orientation": orientation,
            "format": source_format,
            "path": spec.source_relative.replace("\\", "/"),
            "sha256": _sha256(source_bytes),
            "size": list(oriented.size),
        },
        "target_size": list(target_size),
    }
    return _PreparedNormalization(spec, clean_bytes, _canonical_json(record))


def normalize_panels(
    project_dir: Path,
    specs: Iterable[NormalizationSpec],
) -> tuple[Path, ...]:
    """Preflight every panel, then publish the whole batch atomically."""
    project_dir = Path(project_dir)
    prepared = tuple(_prepare(project_dir, spec) for spec in specs)
    if not prepared:
        return ()
    panel_ids = [item.spec.panel_id for item in prepared]
    if len(set(panel_ids)) != len(panel_ids):
        raise ValueError("normalization batch contains duplicate panel IDs")

    with ProjectTransaction(project_dir, "panel-normalization") as transaction:
        for item in prepared:
            panel_id = item.spec.panel_id
            transaction.stage_bytes(
                f"panels/{panel_id}/clean.png", item.clean_bytes
            )
            transaction.stage_bytes(
                f"panels/{panel_id}/normalization.json", item.record_bytes
            )
    return tuple(
        project_dir / f"panels/{item.spec.panel_id}/clean.png" for item in prepared
    )


def normalize_panel(
    project_dir: Path,
    panel_id: str,
    source_relative: str,
    target_size: tuple[int, int],
    mode: str,
) -> Path:
    """Normalize one panel and return its canonical clean image path."""
    return normalize_panels(
        project_dir,
        (NormalizationSpec(panel_id, source_relative, target_size, mode),),
    )[0]

SHA-256: aa8284b35e16a1581510f4bb926a69ede701e5d9f5418b18336db4ae50d37ff9