← Files Comic SolARCHIVED FILE

skills/comic-sol/scripts/letter_panels.py

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

↓ Download file

#!/usr/bin/env python3
"""Deterministic panel lettering for Comic Sol."""

from __future__ import annotations

import argparse
import hashlib
import io
import json
import math
import re
import sys
import tempfile
import unicodedata
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path

from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps

from comic_sol import atomic_write_bytes, canonical_artifact_bytes, read_json, sha256_file
from project_io import ProjectTransaction, contained_project_path, open_path_nofollow, read_contained_bytes
from typography import (
    lettering_geometry_hash,
    preflight_text_items,
)
from font_cmap import font_supports


ROOT = Path(__file__).resolve().parents[1]
DEFAULT_FONT_PATH = ROOT / "assets/fonts/ComicNeue-Regular.ttf"
FONT_PATH = DEFAULT_FONT_PATH
FONT_PATH_BOLD = ROOT / "assets/fonts/ComicNeue-Bold.ttf"
FONT_PATH_FALLBACK = ROOT / "assets/fonts/NotoSans-Regular.ttf"
ANCHORS = (
    "top-left",
    "top-center",
    "top-right",
    "middle-right",
    "bottom-right",
    "bottom-center",
    "bottom-left",
    "middle-left",
)
# A w x h text block only fits inside an ellipse whose axes are at least
# sqrt(2) times w and h, so balloons budget and circumscribe text with it.
ELLIPSE_TEXT_RATIO = math.sqrt(2.0)
BALLOON_PADDING = 19
CAPTION_PADDING = 20
# Panels are page-sized at most (1600x2400); sixteen page areas leaves room for
# oversampled source art while rejecting decompression bombs.
MAX_DECODED_PIXELS = 1600 * 2400 * 16
BALLOON_SUPERSAMPLE = 6


def normalize_content(text: str) -> str:
    """Normalize authored text without changing punctuation, emoji, or newlines."""
    if not isinstance(text, str):
        raise TypeError("text must be a string")
    normalized = unicodedata.normalize("NFC", text)
    normalized = "".join(
        " " if unicodedata.category(character) == "Cc" and character != "\n" else character
        for character in normalized
    )
    lines = [re.sub(r"[^\S\n]+", " ", line).strip() for line in normalized.split("\n")]
    return "\n".join(lines).strip()


def _display_content(kind: object, text: str) -> str:
    """Return display text without modifying the authored storyboard value."""
    normalized = normalize_content(text)
    return normalized.upper() if kind == "dialogue" else normalized


def normalized_word_count(text: str) -> int:
    """Count whitespace-separated words after deterministic normalization."""
    return len(normalize_content(text).split())


def _parse_emphasis(text: str) -> list[tuple[str, bool]]:
    """Parse complete ``**bold**`` spans into text and emphasis chunks."""
    parts = text.split("**")
    if len(parts) % 2 == 0 or any(
        not parts[index].strip() for index in range(1, len(parts), 2)
    ):
        return [(text, False)]
    return [(part, index % 2 != 0) for index, part in enumerate(parts) if part]


def _font_supports(path: Path, character: str) -> bool:
    """Return whether a font's Unicode cmap maps one character to a glyph."""
    return font_supports(path, character)


@lru_cache(maxsize=None)
def _load_font_path(path: str, size: int) -> ImageFont.FreeTypeFont:
    return ImageFont.truetype(path, size)


def _load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
    """Load a dialogue face, falling back when the requested face is unavailable."""
    path = FONT_PATH_BOLD if bold else FONT_PATH
    try:
        return _load_font_path(str(path), size)
    except OSError:
        return _load_font_path(str(FONT_PATH_FALLBACK), size)


def _font_runs(
    text: str,
    size: int,
    bold: bool = False,
    primary: ImageFont.FreeTypeFont | None = None,
) -> tuple[tuple[str, ImageFont.FreeTypeFont], ...]:
    """Group text into adjacent runs using exact per-character font fallback."""
    primary = primary or _load_font(size, bold)
    fallback = _load_font_path(str(FONT_PATH_FALLBACK), size)
    primary_path = Path(primary.path)
    runs: list[tuple[str, ImageFont.FreeTypeFont]] = []
    for character in text:
        selected = primary if character == "\n" or _font_supports(primary_path, character) else fallback
        if runs and Path(runs[-1][1].path) == Path(selected.path):
            runs[-1] = (runs[-1][0] + character, selected)
        else:
            runs.append((character, selected))
    return tuple(runs)


def _styled_font_runs(
    text: str,
    regular_font: ImageFont.FreeTypeFont,
) -> tuple[tuple[str, ImageFont.FreeTypeFont], ...]:
    """Compose emphasis chunks and per-character fallback into drawable runs."""
    runs: list[tuple[str, ImageFont.FreeTypeFont]] = []
    for chunk, bold in _parse_emphasis(text):
        primary = _load_font(regular_font.size, True) if bold else regular_font
        for run_text, run_font in _font_runs(chunk, regular_font.size, bold, primary):
            if runs and Path(runs[-1][1].path) == Path(run_font.path):
                runs[-1] = (runs[-1][0] + run_text, run_font)
            else:
                runs.append((run_text, run_font))
    return tuple(runs)


@dataclass(frozen=True)
class _StyledLine:
    runs: tuple[tuple[str, ImageFont.FreeTypeFont], ...]
    width: float
    top: int
    bottom: int

    @property
    def height(self) -> int:
        return self.bottom - self.top


@dataclass(frozen=True)
class _StyledLayout:
    lines: tuple[_StyledLine, ...]
    spacing: int

    @property
    def width(self) -> float:
        return max((line.width for line in self.lines), default=0.0)

    @property
    def height(self) -> int:
        return sum(line.height for line in self.lines) + self.spacing * max(0, len(self.lines) - 1)


def _merge_font_tokens(
    tokens: list[tuple[str, ImageFont.FreeTypeFont]],
) -> tuple[tuple[str, ImageFont.FreeTypeFont], ...]:
    """Merge adjacent character tokens that use the same font face."""
    runs: list[tuple[str, ImageFont.FreeTypeFont]] = []
    for character, font in tokens:
        if runs and Path(runs[-1][1].path) == Path(font.path):
            runs[-1] = (runs[-1][0] + character, font)
        else:
            runs.append((character, font))
    return tuple(runs)


def _measure_styled_line(
    draw: ImageDraw.ImageDraw,
    tokens: list[tuple[str, ImageFont.FreeTypeFont]],
    regular_font: ImageFont.FreeTypeFont,
) -> _StyledLine:
    """Measure one mixed-font line from the baseline used to draw it."""
    runs = _merge_font_tokens(tokens)
    width = sum(draw.textlength(text, font=run_font) for text, run_font in runs)
    if runs:
        boxes = [
            draw.textbbox((0, 0), text, font=run_font, anchor="ls")
            for text, run_font in runs
        ]
    else:
        boxes = [draw.textbbox((0, 0), "Ag", font=regular_font, anchor="ls")]
    return _StyledLine(
        runs=runs,
        width=width,
        top=min(box[1] for box in boxes),
        bottom=max(box[3] for box in boxes),
    )


def _layout_styled_text(
    draw: ImageDraw.ImageDraw,
    content: str,
    regular_font: ImageFont.FreeTypeFont,
    maximum_width: float,
    spacing: int = 6,
    emphasis: bool = True,
) -> _StyledLayout | None:
    """Wrap parsed dialogue runs using the exact fonts that will draw them."""
    if maximum_width <= 0:
        return None

    paragraphs: list[list[tuple[str, ImageFont.FreeTypeFont]]] = [[]]
    source_runs = (
        _styled_font_runs(content, regular_font)
        if emphasis
        else _font_runs(content, regular_font.size, primary=regular_font)
    )
    for text, run_font in source_runs:
        for character in text:
            if character == "\n":
                paragraphs.append([])
            else:
                paragraphs[-1].append((character, run_font))

    lines: list[_StyledLine] = []
    for paragraph in paragraphs:
        words: list[
            tuple[
                list[tuple[str, ImageFont.FreeTypeFont]],
                list[tuple[str, ImageFont.FreeTypeFont]],
            ]
        ] = []
        separator: list[tuple[str, ImageFont.FreeTypeFont]] = []
        word: list[tuple[str, ImageFont.FreeTypeFont]] = []
        for token in paragraph:
            if token[0].isspace():
                if word:
                    words.append((separator, word))
                    separator = []
                    word = []
                separator.append(token)
            else:
                word.append(token)
        if word:
            words.append((separator, word))

        if not words:
            lines.append(_measure_styled_line(draw, [], regular_font))
            continue

        current = words[0][0] + words[0][1]
        first_line = _measure_styled_line(draw, current, regular_font)
        if first_line.width > maximum_width:
            return None
        for between, next_word in words[1:]:
            candidate = current + between + next_word
            candidate_line = _measure_styled_line(draw, candidate, regular_font)
            if candidate_line.width <= maximum_width:
                current = candidate
                continue
            lines.append(_measure_styled_line(draw, current, regular_font))
            current = list(next_word)
            next_line = _measure_styled_line(draw, current, regular_font)
            if next_line.width > maximum_width:
                return None
        lines.append(_measure_styled_line(draw, current, regular_font))

    return _StyledLayout(tuple(lines), spacing)


def _draw_font_runs(
    draw: ImageDraw.ImageDraw,
    runs: tuple[tuple[str, ImageFont.FreeTypeFont], ...],
    position: tuple[float, float],
    fill: tuple[int, ...],
) -> None:
    """Draw one line of mixed-font runs from a shared baseline."""
    x, y = position
    for text, run_font in runs:
        # One-pixel keyline gives Comic Neue the confident ink weight used by
        # print-comic dialogue without changing measured glyph advances.
        draw.text(
            (x, y), text, font=run_font, fill=fill, anchor="ls",
            stroke_width=1, stroke_fill=fill,
        )
        x += draw.textlength(text, font=run_font)


def _draw_styled_layout(
    draw: ImageDraw.ImageDraw,
    layout: _StyledLayout,
    center_x: float,
    top_y: float,
    fill: tuple[int, ...],
) -> None:
    """Draw a measured layout with every line centered independently."""
    line_top = top_y
    for line in layout.lines:
        _draw_font_runs(
            draw,
            line.runs,
            (center_x - line.width / 2, line_top - line.top),
            fill,
        )
        line_top += line.height + layout.spacing


def _known_character(character_bible: list[dict], speaker: object) -> bool:
    if not isinstance(speaker, str) or not speaker:
        return False
    return any(
        isinstance(character, dict)
        and speaker in {character.get("id"), character.get("name")}
        for character in character_bible
    )


def _anchor_rect(anchor: str, width: int, height: int) -> dict[str, int]:
    inset_x = max(4, round(width * 0.04))
    inset_y = max(4, round(height * 0.04))
    box_width = min(width - 2 * inset_x, max(1, round(width * 0.42)))
    box_height = min(height - 2 * inset_y, max(1, round(height * 0.30)))
    horizontal = {
        "left": inset_x,
        "center": (width - box_width) // 2,
        "right": width - inset_x - box_width,
    }
    vertical = {
        "top": inset_y,
        "middle": (height - box_height) // 2,
        "bottom": height - inset_y - box_height,
    }
    vertical_name, horizontal_name = anchor.split("-", 1)
    return {
        "x": horizontal[horizontal_name],
        "y": vertical[vertical_name],
        "width": box_width,
        "height": box_height,
    }


def _overlap(first: dict[str, int], second: dict[str, int]) -> bool:
    return not (
        first["x"] + first["width"] <= second["x"]
        or second["x"] + second["width"] <= first["x"]
        or first["y"] + first["height"] <= second["y"]
        or second["y"] + second["height"] <= first["y"]
    )


def _text_padding(kind: object) -> int:
    """Return the padding between a text block and the shape drawn around it."""
    return BALLOON_PADDING if kind == "dialogue" else CAPTION_PADDING


def _text_wrap_width(kind: object, box_width: float) -> float:
    """Return the wrapping width a text block may use inside its drawn shape."""
    inner = max(1.0, box_width - 2 * _text_padding(kind))
    # Balloon text is inscribed in the ellipse rather than in the box bounding
    # it, so only inner / sqrt(2) of that box is usable text width.
    return inner / ELLIPSE_TEXT_RATIO if kind == "dialogue" else inner


def _balloon_box(layout: _StyledLayout) -> tuple[int, int]:
    """Return the ellipse box that circumscribes one centered text block."""
    return (
        math.ceil(layout.width * ELLIPSE_TEXT_RATIO) + 2 * BALLOON_PADDING,
        math.ceil(layout.height * ELLIPSE_TEXT_RATIO) + 2 * BALLOON_PADDING,
    )


def _fitted_item_rect(
    draw: ImageDraw.ImageDraw,
    item: dict,
    maximum: dict[str, int],
    font: ImageFont.FreeTypeFont,
) -> dict[str, int]:
    """Fit a rendered text shape inside its anchor's maximum placement area."""
    kind = item.get("kind")
    if kind not in {"caption", "dialogue"}:
        return dict(maximum)
    layout = _layout_styled_text(
        draw,
        _display_content(kind, item.get("content", "")),
        font,
        _text_wrap_width(kind, maximum["width"]),
        emphasis=kind == "dialogue",
    )
    if layout is None:
        raise ValueError(f"text item {item.get('id', 'unknown')} cannot be wrapped")
    if kind == "dialogue":
        box_width, box_height = _balloon_box(layout)
    else:
        box_width = math.ceil(layout.width) + 2 * CAPTION_PADDING
        box_height = layout.height + CAPTION_PADDING
    fitted_width = min(maximum["width"], box_width)
    fitted_height = min(maximum["height"], box_height)

    anchor = item.get("anchor", "top-left")
    vertical, horizontal = anchor.split("-", 1) if anchor in ANCHORS else ("top", "left")
    x = {
        "left": maximum["x"],
        "center": maximum["x"] + (maximum["width"] - fitted_width) // 2,
        "right": maximum["x"] + maximum["width"] - fitted_width,
    }[horizontal]
    y = {
        "top": maximum["y"],
        "middle": maximum["y"] + (maximum["height"] - fitted_height) // 2,
        "bottom": maximum["y"] + maximum["height"] - fitted_height,
    }[vertical]
    return {"x": x, "y": y, "width": fitted_width, "height": fitted_height}


def _organic_tail_geometry(
    rect: dict[str, int],
    speaker_anchor: list[float],
    image_width: int,
    image_height: int,
    voice_source: str,
) -> dict[str, object]:
    """Resolve one compact, tapered cubic tail toward an explicit voice source."""
    if voice_source not in {"human", "device"}:
        raise ValueError("dialogue voice source must be human or device")
    if (
        not isinstance(speaker_anchor, list)
        or len(speaker_anchor) != 2
        or any(
            isinstance(value, bool)
            or not isinstance(value, (int, float))
            or not math.isfinite(float(value))
            or not 0 <= value <= 1
            for value in speaker_anchor
        )
    ):
        raise ValueError("speaker anchor must contain finite normalized coordinates")
    target_x = round(float(speaker_anchor[0]) * image_width)
    target_y = round(float(speaker_anchor[1]) * image_height)
    center_x = rect["x"] + rect["width"] / 2
    center_y = rect["y"] + rect["height"] / 2
    radius_x = max(0.5, rect["width"] / 2)
    radius_y = max(0.5, rect["height"] / 2)
    delta_x = target_x - center_x
    delta_y = target_y - center_y
    normalized_distance = math.sqrt(
        (delta_x / radius_x) ** 2 + (delta_y / radius_y) ** 2
    )
    if normalized_distance <= 1.08:
        raise ValueError("speaker anchor must remain outside the balloon outline")
    scale = 1 / normalized_distance
    attachment_x = center_x + delta_x * scale
    attachment_y = center_y + delta_y * scale
    source_distance = math.hypot(
        target_x - attachment_x, target_y - attachment_y
    )
    minimum_source_gap = max(
        8.0, min(24.0, min(image_width, image_height) * 0.025)
    )
    available_length = source_distance - minimum_source_gap
    maximum_length = min(
        min(radius_x, radius_y) * 0.9,
        min(image_width, image_height) * 0.12,
    )
    if available_length < 12.0:
        raise ValueError("speaker anchor is too close to the balloon for a readable tail")
    tail_length = min(available_length, maximum_length)
    source_gap = source_distance - tail_length
    unit_x = (target_x - attachment_x) / source_distance
    unit_y = (target_y - attachment_y) / source_distance
    half_base = max(5.0, min(14.0, tail_length * 0.18))

    # Begin both cubic sides inside the balloon body. The merged mask then
    # computes the visible ellipse/tail intersection naturally, avoiding the
    # cusp and shoulder produced by forcing roots onto the ellipse outline.
    normal_x, normal_y = -unit_y, unit_x
    root_depth = min(10.0, half_base * 0.7)
    base_one = (
        attachment_x - unit_x * root_depth + normal_x * half_base,
        attachment_y - unit_y * root_depth + normal_y * half_base,
    )
    base_two = (
        attachment_x - unit_x * root_depth - normal_x * half_base,
        attachment_y - unit_y * root_depth - normal_y * half_base,
    )
    tip = (
        attachment_x + unit_x * tail_length,
        attachment_y + unit_y * tail_length,
    )
    shoulder_run = min(22.0, tail_length * 0.30)
    near_tip_run = min(24.0, tail_length * 0.30)
    near_tip_half_width = max(2.6, half_base * 0.30)
    first_controls = (
        (
            attachment_x + unit_x * shoulder_run + normal_x * half_base * 0.92,
            attachment_y + unit_y * shoulder_run + normal_y * half_base * 0.92,
        ),
        (
            tip[0] - unit_x * near_tip_run + normal_x * near_tip_half_width,
            tip[1] - unit_y * near_tip_run + normal_y * near_tip_half_width,
        ),
    )
    second_controls = (
        (
            attachment_x + unit_x * shoulder_run - normal_x * half_base * 0.92,
            attachment_y + unit_y * shoulder_run - normal_y * half_base * 0.92,
        ),
        (
            tip[0] - unit_x * near_tip_run - normal_x * near_tip_half_width,
            tip[1] - unit_y * near_tip_run - normal_y * near_tip_half_width,
        ),
    )

    def rounded(point: tuple[float, float]) -> list[float]:
        return [round(point[0], 4), round(point[1], 4)]

    return {
        "attachment": rounded((attachment_x, attachment_y)),
        "base": [rounded(base_one), rounded(base_two)],
        "control": [
            [rounded(point) for point in first_controls],
            [rounded(point) for point in second_controls],
        ],
        "length": round(tail_length, 4),
        "policy_version": "organic-cubic-v1",
        "source_gap": round(source_gap, 4),
        "speaker_anchor": [float(value) for value in speaker_anchor],
        "tip": rounded(tip),
        "voice_source": voice_source,
        "width": round(half_base * 2, 4),
    }


def _draw_antialiased_balloon(
    draw: ImageDraw.ImageDraw,
    bounds: tuple[int, int, int, int],
    tail: dict[str, object] | None,
) -> None:
    """Draw one seamless supersampled balloon and composite it onto the panel."""
    image = draw._image
    scale = BALLOON_SUPERSAMPLE
    image_width, image_height = image.size
    x0, y0, x1, y1 = bounds
    points = [(float(x0), float(y0)), (float(x1), float(y1))]
    if tail is not None:
        base = tail.get("base")
        control = tail.get("control")
        tip_value = tail.get("tip")
        if not isinstance(base, list) or not isinstance(control, list) or not isinstance(tip_value, list):
            raise ValueError("tail geometry has an invalid point record")
        points.extend(tuple(point) for point in base)
        points.extend(tuple(point) for side in control for point in side)
        points.append(tuple(tip_value))
    margin = 5
    left = max(0, math.floor(min(x for x, _ in points)) - margin)
    top = max(0, math.floor(min(y for _, y in points)) - margin)
    right = min(image_width, math.ceil(max(x for x, _ in points)) + margin + 1)
    bottom = min(image_height, math.ceil(max(y for _, y in points)) + margin + 1)
    mask = Image.new("L", ((right - left) * scale, (bottom - top) * scale), 0)
    mask_draw = ImageDraw.Draw(mask)

    def scaled_point(point: tuple[float, float]) -> tuple[int, int]:
        return round((point[0] - left) * scale), round((point[1] - top) * scale)

    if tail is not None:
        base = tail.get("base")
        control = tail.get("control")
        tip_value = tail.get("tip")
        if not isinstance(base, list) or not isinstance(control, list) or not isinstance(tip_value, list):
            raise ValueError("tail geometry has an invalid point record")
        base_one, base_two = (tuple(point) for point in base)
        first_control, second_control = (
            tuple(tuple(point) for point in side) for side in control
        )
        tip = tuple(tip_value)

        def cubic_points(start, controls, end):
            first, second = controls
            result = []
            for index in range(25):
                t = index / 24
                inverse = 1 - t
                result.append((
                    inverse ** 3 * start[0]
                    + 3 * inverse ** 2 * t * first[0]
                    + 3 * inverse * t ** 2 * second[0]
                    + t ** 3 * end[0],
                    inverse ** 3 * start[1]
                    + 3 * inverse ** 2 * t * first[1]
                    + 3 * inverse * t ** 2 * second[1]
                    + t ** 3 * end[1],
                ))
            return result

        first_side = cubic_points(base_one, first_control, tip)
        second_side = cubic_points(base_two, second_control, tip)
        silhouette = first_side + list(reversed(second_side))
        mask_draw.polygon(tuple(scaled_point(point) for point in silhouette), fill=255)
    mask_draw.ellipse(
        ((x0 - left) * scale, (y0 - top) * scale,
         (x1 - left) * scale, (y1 - top) * scale),
        fill=255,
    )

    # Expand the merged silhouette before downsampling. This avoids the seam,
    # doubled edge, and jagged notch produced by outlining two separate shapes.
    outline = mask.filter(ImageFilter.MaxFilter(25))
    resample = Image.Resampling.LANCZOS
    local_size = (right - left, bottom - top)
    mask = mask.resize(local_size, resample)
    outline = outline.resize(local_size, resample)
    image.paste((15, 15, 15, 255), (left, top), outline)
    image.paste((255, 255, 255, 255), (left, top), mask)


def _item_font(
    draw: ImageDraw.ImageDraw,
    item: dict,
    rect: dict[str, int],
) -> ImageFont.FreeTypeFont:
    kind = item.get("kind")
    content = _display_content(kind, item.get("content", ""))
    for size in range(42, 23, -2):
        font = _load_font(size)
        layout = _layout_styled_text(
            draw,
            content,
            font,
            _text_wrap_width(kind, rect["width"]),
            emphasis=kind == "dialogue",
        )
        if layout is None:
            continue
        if kind == "dialogue":
            box_width, box_height = _balloon_box(layout)
            if box_width <= rect["width"] and box_height <= rect["height"]:
                return font
        elif layout.height <= rect["height"] - 2 * _text_padding(kind):
            return font
    item_id = item.get("id", "unknown")
    raise ValueError(f"text item {item_id} does not fit inside the panel")


def render_text_item(
    draw: ImageDraw.ImageDraw,
    item: dict,
    rect: dict,
    font: ImageFont.FreeTypeFont,
    character_bible: list[dict],
) -> None:
    """Draw one validated text item inside an explicit bounded rectangle."""
    kind = item.get("kind")
    content = _display_content(kind, item.get("content", ""))
    if not content:
        raise ValueError(f"text item {item.get('id', 'unknown')} has empty content")
    if kind not in {"dialogue", "caption", "sfx"}:
        raise ValueError(f"text item {item.get('id', 'unknown')} has unknown kind")
    if kind == "dialogue" and not _known_character(character_bible, item.get("speaker")):
        raise ValueError(f"unknown dialogue character: {item.get('speaker')}")
    if kind == "sfx":
        return

    image_width, image_height = draw._image.size
    x0 = max(0, int(rect["x"]))
    y0 = max(0, int(rect["y"]))
    x1 = min(image_width - 1, x0 + max(1, int(rect["width"])))
    y1 = min(image_height - 1, y0 + max(1, int(rect["height"])))
    bounded = {"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0}
    padding = _text_padding(kind)
    layout = _layout_styled_text(
        draw,
        content,
        font,
        _text_wrap_width(kind, bounded["width"]),
        emphasis=kind == "dialogue",
    )
    if layout is None:
        raise ValueError(f"text item {item.get('id', 'unknown')} cannot be wrapped")
    text_height = layout.height
    text_y = y0 + max(padding, (bounded["height"] - text_height) / 2)
    if kind == "caption":
        text_y = y0 + max(0, (bounded["height"] - text_height) / 2)

    if kind == "dialogue":
        tail = item.get("speaker_anchor")
        voice_source = item.get("voice_source")
        if isinstance(tail, list) and len(tail) == 2 and all(isinstance(value, (int, float)) for value in tail):
            if not isinstance(voice_source, str):
                raise ValueError("dialogue voice source must be human or device")
            tail_geometry = _organic_tail_geometry(
                bounded | {"x": x0, "y": y0},
                tail,
                image_width,
                image_height,
                voice_source,
            )
            _draw_antialiased_balloon(draw, (x0, y0, x1, y1), tail_geometry)
        else:
            _draw_antialiased_balloon(draw, (x0, y0, x1, y1), None)
        assert layout is not None
        _draw_styled_layout(
            draw,
            layout,
            x0 + bounded["width"] / 2,
            text_y,
            (10, 10, 10, 255),
        )
    elif kind == "caption":
        draw.rectangle(
            (x0, y0, x1, y1),
            fill=(255, 255, 255, 200), outline=(15, 15, 15, 200), width=2,
        )
        assert layout is not None
        _draw_styled_layout(
            draw,
            layout,
            x0 + bounded["width"] / 2,
            text_y,
            (15, 15, 15, 255),
        )


def _validate_decoded_pixels(size: tuple[int, int], path: Path) -> None:
    """Reject panel images whose decoded pixel count exceeds the project cap."""
    width, height = size
    if width * height > MAX_DECODED_PIXELS:
        raise ValueError(
            f"panel exceeds the {MAX_DECODED_PIXELS} pixel decode limit: {path}"
        )


def letter_panel(
    output_path: str,
    panel_width: int,
    panel_height: int,
    text_items: list[dict],
    character_bible: list[dict],
    *,
    source_bytes: bytes | None = None,
) -> dict:
    """Letter a panel atomically and return a compact output summary."""
    if not isinstance(panel_width, int) or not isinstance(panel_height, int) or panel_width <= 0 or panel_height <= 0:
        raise ValueError("panel dimensions must be positive integers")
    if not isinstance(text_items, list) or not isinstance(character_bible, list):
        raise TypeError("text_items and character_bible must be lists")
    path = Path(output_path)
    try:
        stream = io.BytesIO(source_bytes) if source_bytes is not None else open_path_nofollow(path)
        with stream, Image.open(stream) as source:
            _validate_decoded_pixels(source.size, path)
            base = ImageOps.exif_transpose(source).convert("RGBA")
            if base.size != (panel_width, panel_height):
                base = ImageOps.fit(base, (panel_width, panel_height), method=Image.Resampling.LANCZOS)
    except (OSError, Image.DecompressionBombError, Image.DecompressionBombWarning) as error:
        detail = type(error).__name__
        errno_value = getattr(error, "errno", None)
        if errno_value is not None:
            detail += f" errno={errno_value}"
        raise ValueError(f"panel is not a readable image ({detail}): {path}") from error

    ordered = sorted(
        (dict(item) for item in text_items),
        key=lambda item: (item.get("priority", 0), str(item.get("id", ""))),
    )
    for item in ordered:
        if item.get("kind") == "dialogue" and not _known_character(character_bible, item.get("speaker")):
            raise ValueError(f"unknown dialogue character: {item.get('speaker')}")
        if item.get("kind") == "dialogue":
            if "tail_target" in item:
                raise ValueError(
                    "balloon-tail-migration-required: replace tail_target with "
                    "explicit voice_source and speaker_anchor"
                )
            if item.get("voice_source") not in {"human", "device"}:
                raise ValueError(
                    f"text item {item.get('id', 'unknown')} voice_source must be human or device"
                )
            speaker_anchor = item.get("speaker_anchor")
            if (
                not isinstance(speaker_anchor, list)
                or len(speaker_anchor) != 2
                or any(
                    isinstance(value, bool)
                    or not isinstance(value, (int, float))
                    or not math.isfinite(float(value))
                    or not 0 <= value <= 1
                    for value in speaker_anchor
                )
            ):
                raise ValueError(
                    f"text item {item.get('id', 'unknown')} speaker_anchor must be "
                    "finite normalized coordinates"
                )
        content = normalize_content(item.get("content", ""))
        limit = {"dialogue": 32, "caption": 45, "sfx": 3}.get(item.get("kind"))
        if limit is None:
            raise ValueError(f"text item {item.get('id', 'unknown')} has unknown kind")
        if not content or normalized_word_count(content) > limit:
            raise ValueError(f"text item {item.get('id', 'unknown')} exceeds its content limit")
        anchor = item.get("anchor", "top-left")
        if anchor not in ANCHORS:
            raise ValueError(f"text item {item.get('id', 'unknown')} has unknown anchor")
        item["content"] = content

    renderable = [item for item in ordered if item.get("kind") != "sfx"]
    rendered_text_count = len(renderable)
    sfx_count = len(ordered) - rendered_text_count
    word_count = sum(normalized_word_count(item["content"]) for item in ordered)
    summary = {
        "font_used": str(Path(_load_font(12).path)),
        "lettered_path": str(path),
        "rendered_text_count": rendered_text_count,
        "sfx_count": sfx_count,
        "text_count": len(ordered),
        "word_count": word_count,
        "placements": [],
    }
    if rendered_text_count == 0:
        return summary

    canvas = base.copy()
    draw = ImageDraw.Draw(canvas, "RGBA")
    occupied: list[dict[str, int]] = []
    for reading_order, item in enumerate(renderable, 1):
        requested = item.get("anchor", "top-left")
        start = ANCHORS.index(requested)
        rect = None
        font = None
        selected_anchor = None
        for offset in range(len(ANCHORS)):
            candidate_anchor = ANCHORS[(start + offset) % len(ANCHORS)]
            candidate = _anchor_rect(candidate_anchor, panel_width, panel_height)
            candidate_item = dict(item)
            candidate_item["anchor"] = candidate_anchor
            candidate_font = _item_font(draw, candidate_item, candidate)
            fitted = _fitted_item_rect(draw, candidate_item, candidate, candidate_font)
            if not any(_overlap(fitted, prior) for prior in occupied):
                rect = fitted
                font = candidate_font
                selected_anchor = candidate_anchor
                break
        if rect is None:
            raise ValueError(f"text item {item.get('id', 'unknown')} has no non-overlapping placement")
        assert font is not None
        assert selected_anchor is not None
        render_text_item(draw, item, rect, font, character_bible)
        display = _display_content(item.get("kind"), item.get("content", ""))
        font_runs = [
            {
                "font_id": Path(run_font.path).name,
                "style": (
                    "bold"
                    if Path(run_font.path) == FONT_PATH_BOLD
                    else "regular"
                ),
                "text": run_text,
            }
            for run_text, run_font in _styled_font_runs(display, font)
        ]
        tail_geometry = None
        tail = item.get("speaker_anchor")
        voice_source = item.get("voice_source")
        if (
            item.get("kind") == "dialogue"
            and isinstance(tail, list)
            and len(tail) == 2
            and all(isinstance(value, (int, float)) for value in tail)
        ):
            if not isinstance(voice_source, str):
                raise ValueError("dialogue voice source must be human or device")
            tail_geometry = _organic_tail_geometry(
                rect,
                tail,
                panel_width,
                panel_height,
                voice_source,
            )
        summary["placements"].append({
            "anchor": selected_anchor,
            "box": {key: int(rect[key]) for key in ("x", "y", "width", "height")},
            "font_runs": font_runs,
            "id": item.get("id"),
            "kind": item.get("kind"),
            "reading_order": reading_order,
            "tail": tail_geometry,
        })
        occupied.append(rect)

    encoded = io.BytesIO()
    canvas.convert("RGB").save(encoded, format="PNG", optimize=False, compress_level=9)
    atomic_write_bytes(path, encoded.getvalue())
    return summary


def _letter_project_with_summaries(
    project_dir: Path,
) -> tuple[list[Path], list[dict]]:
    """Letter every accepted project panel and collect outputs plus summaries."""
    project_dir = Path(project_dir)
    storyboard = read_json(
        contained_project_path(project_dir, "plan/storyboard.json", must_exist=True)
    )
    pages = storyboard.get("pages")
    if not isinstance(pages, list) or not pages:
        raise ValueError("storyboard pages must be a non-empty array")
    panels: list[dict] = []
    for page_index, page in enumerate(pages):
        if not isinstance(page, dict):
            raise ValueError(f"storyboard page {page_index + 1} must be an object")
        page_panels = page.get("panels")
        if not isinstance(page_panels, list) or not page_panels:
            raise ValueError(f"storyboard page {page_index + 1} panels must be a non-empty array")
        for panel_index, panel in enumerate(page_panels):
            if not isinstance(panel, dict):
                raise ValueError(
                    f"storyboard page {page_index + 1} panel {panel_index + 1} must be an object"
                )
            panel_id = panel.get("id")
            if not isinstance(panel_id, str) or re.fullmatch(r"p[0-9]{2}-[0-9]{2}", panel_id) is None:
                raise ValueError(
                    f"storyboard page {page_index + 1} panel {panel_index + 1} has an invalid ID"
                )
            if not isinstance(panel.get("text"), list):
                raise ValueError(f"storyboard panel {panel_id} text must be an array")
            panels.append(panel)
    bible = read_json(
        contained_project_path(project_dir, "plan/character-bible.json", must_exist=True)
    ).get("characters")
    if not isinstance(bible, list) or any(not isinstance(character, dict) for character in bible):
        raise ValueError("character bible characters must be an array of objects")

    font_policy = {
        "regular": FONT_PATH,
        "bold": FONT_PATH_BOLD,
        "fallback": FONT_PATH_FALLBACK,
    }
    preflights: dict[str, dict[str, object]] = {}
    for panel in panels:
        preflights[panel["id"]] = preflight_text_items(
            panel.get("text", []), font_policy
        )

    outputs: list[Path] = []
    summaries: list[dict] = []
    staged: list[tuple[str, bytes, bytes, bytes, dict]] = []
    storyboard_path = contained_project_path(
        project_dir, "plan/storyboard.json", must_exist=True
    )
    storyboard_sha256 = sha256_file(storyboard_path)
    with tempfile.TemporaryDirectory(prefix="comic-sol-lettering-") as temporary:
        temporary_root = Path(temporary)
        for panel in panels:
            panel_id = panel["id"]
            canonical_source = f"panels/{panel_id}/clean.png"
            legacy_source = f"panels/clean/{panel_id}.png"
            source_relative = (
                canonical_source
                if contained_project_path(project_dir, canonical_source).is_file()
                else legacy_source
            )
            try:
                source = contained_project_path(project_dir, source_relative, must_exist=True)
                source_bytes = read_contained_bytes(project_dir, source_relative)
                with Image.open(io.BytesIO(source_bytes)) as image:
                    _validate_decoded_pixels(image.size, source)
                    image.load()
                    width, height = image.size
            except (OSError, SyntaxError, Image.DecompressionBombError, Image.DecompressionBombWarning) as error:
                raise ValueError(f"panel {panel_id} is not a readable image") from error
            staged_path = temporary_root / f"{panel_id}.png"
            summary = letter_panel(
                str(staged_path), width, height, panel.get("text", []), bible,
                source_bytes=source_bytes,
            )
            destination_relative = f"panels/{panel_id}/lettered.png"
            destination = contained_project_path(project_dir, destination_relative)
            summary["lettered_path"] = str(destination)
            lettered_payload = staged_path.read_bytes()
            preflight = preflights[panel_id]
            geometry: dict[str, object] = {
                "bindings": {
                    "clean_path": source_relative,
                    "clean_sha256": hashlib.sha256(source_bytes).hexdigest(),
                    "font_policy_sha256": preflight["font_policy_sha256"],
                    "storyboard_path": "plan/storyboard.json",
                    "storyboard_sha256": storyboard_sha256,
                    "typography_sha256": hashlib.sha256(
                        canonical_artifact_bytes(preflight)
                    ).hexdigest(),
                },
                "items": summary["placements"],
                "kind": "lettering-geometry",
                "lettered": {
                    "path": destination_relative,
                    "sha256": hashlib.sha256(lettered_payload).hexdigest(),
                },
                "panel_id": panel_id,
                "schema_version": "1.0",
            }
            geometry["geometry_sha256"] = lettering_geometry_hash(geometry)
            staged.append((
                panel_id,
                lettered_payload,
                canonical_artifact_bytes(preflight),
                canonical_artifact_bytes(geometry),
                summary,
            ))

        with ProjectTransaction(project_dir, "lettering") as transaction:
            for panel_id, image_payload, preflight_payload, geometry_payload, _ in staged:
                transaction.stage_bytes(
                    f"panels/{panel_id}/lettered.png", image_payload
                )
                transaction.stage_bytes(
                    f"panels/{panel_id}/typography.json", preflight_payload
                )
                transaction.stage_bytes(
                    f"panels/{panel_id}/lettering.json", geometry_payload
                )
            transaction.commit()

        for panel_id, _, _, _, summary in staged:
            outputs.append(contained_project_path(
                project_dir, f"panels/{panel_id}/lettered.png", must_exist=True
            ))
            summaries.append(summary)
    return outputs, summaries


def letter_project(project_dir: Path) -> list[Path]:
    """Letter every accepted project panel and return its output paths."""
    outputs, _ = _letter_project_with_summaries(project_dir)
    return outputs


class _LetteringArgumentParser(argparse.ArgumentParser):
    def error(self, message: str) -> None:
        raise ValueError(f"invalid invocation: {message}")


def _build_parser() -> argparse.ArgumentParser:
    parser = _LetteringArgumentParser(prog="letter_panels.py")
    parser.add_argument("project_dir", type=Path)
    parser.add_argument("--font", type=Path, default=DEFAULT_FONT_PATH)
    return parser


def main(argv: list[str] | None = None) -> int:
    global FONT_PATH
    previous_font = FONT_PATH
    try:
        arguments = _build_parser().parse_args(argv)
        try:
            _load_font_path(str(arguments.font), 12)
        except OSError as error:
            raise ValueError(f"font is not a readable TrueType/OpenType file: {arguments.font}") from error
        FONT_PATH = arguments.font
        _, summaries = _letter_project_with_summaries(arguments.project_dir)
        print(json.dumps(summaries, ensure_ascii=False, indent=2, sort_keys=True))
        return 0
    except (OSError, TypeError, ValueError, json.JSONDecodeError) as error:
        print(f"ERROR {type(error).__name__}: {error}", file=sys.stderr)
        return 1
    finally:
        FONT_PATH = previous_font


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: d2f28a740eb423d138324cf6eb845de701c60ce8bedd16a638b9d24a2db5c3e4