← Files Comic SolARCHIVED FILE

skills/comic-sol/scripts/typography.py

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

↓ Download file

#!/usr/bin/env python3
"""Deterministic typography policy and preflight records for Comic Sol."""

from __future__ import annotations

import hashlib
import json
import re
import unicodedata
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Mapping, Sequence

from comic_sol import atomic_write_json
from font_cmap import font_supports
from project_io import contained_project_path


SUPPORTED_STYLES = ("regular", "bold")
FONT_ROLES = ("regular", "bold", "fallback")
REMEDIATION = "choose supported text or bundle a tested font"


@dataclass(frozen=True)
class TypographyIssue:
    category: str
    item_id: str
    codepoint: str
    character: str
    style: str
    checked_fonts: tuple[str, ...]
    remediation: str


class TypographyPreflightError(ValueError):
    """Raised when authored text cannot be rendered under the pinned policy."""

    def __init__(self, issues: Sequence[TypographyIssue]):
        self.issues = tuple(issues)
        details = "; ".join(
            f"{issue.category}: {issue.codepoint} in {issue.item_id} "
            f"({issue.style}; checked {', '.join(issue.checked_fonts)}; "
            f"{issue.remediation})"
            for issue in self.issues
        )
        if any(issue.category == "unsupported-shaping" for issue in self.issues):
            details = f"unsupported shaping policy; {details}"
        super().__init__(details)


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


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


def lettering_geometry_hash(record: Mapping[str, object]) -> str:
    """Hash geometry by canonical semantic JSON rather than source formatting."""
    payload = dict(record)
    payload.pop("geometry_sha256", None)
    return _sha256_bytes(_canonical_bytes(payload))


def _normalize_content(text: str) -> str:
    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 _style_spans(text: str) -> tuple[tuple[str, str], ...]:
    parts = text.split("**")
    if len(parts) % 2 == 0 or any(
        not parts[index].strip() for index in range(1, len(parts), 2)
    ):
        return ((text, "regular"),)
    return tuple(
        (part, "bold" if index % 2 else "regular")
        for index, part in enumerate(parts)
        if part
    )


def _shaping_supported(character: str) -> bool:
    codepoint = ord(character)
    if codepoint >= 0x1F000:
        return False
    name = unicodedata.name(character, "")
    return not (
        "ARABIC" in name
        or "CJK" in name
        or "IDEOGRAPH" in name
        or 0x3040 <= codepoint <= 0x30FF
        or 0x3400 <= codepoint <= 0x9FFF
    )


def _font_policy(font_policy: Mapping[str, object]) -> tuple[dict[str, Path], dict[str, str]]:
    paths: dict[str, Path] = {}
    identifiers: dict[str, str] = {}
    for role in FONT_ROLES:
        value = font_policy.get(role)
        if not isinstance(value, (str, Path)):
            raise ValueError(f"font policy requires {role}")
        path = Path(value)
        if not path.is_file():
            raise ValueError(f"font policy {role} is unavailable: {path.name}")
        paths[role] = path
        identifiers[role] = path.name
    return paths, identifiers


def preflight_text_items(
    items: Sequence[Mapping[str, object]],
    font_policy: Mapping[str, object],
) -> dict[str, object]:
    """Validate all normalized visible characters before lettering mutates output."""
    if not isinstance(items, (list, tuple)):
        raise TypeError("items must be an array")
    paths, identifiers = _font_policy(font_policy)
    canonical_items = [dict(value) for value in items]
    glyphs: list[dict[str, object]] = []
    non_glyphs: list[dict[str, str]] = []
    issues: list[TypographyIssue] = []

    for item_index, text_item in enumerate(canonical_items):
        item_id = text_item.get("id")
        if not isinstance(item_id, str) or not item_id:
            raise ValueError(f"text item {item_index + 1} requires an ID")
        raw_content = text_item.get("content", "")
        if not isinstance(raw_content, str):
            raise TypeError(f"text item {item_id} content must be a string")
        content = _normalize_content(raw_content)
        for span, style in _style_spans(content):
            role = "bold" if style == "bold" else "regular"
            for character in span:
                codepoint = f"U+{ord(character):04X}"
                if character == "\n":
                    non_glyphs.append({
                        "codepoint": codepoint,
                        "item_id": item_id,
                        "policy": "line-break",
                    })
                    continue
                if character.isspace():
                    non_glyphs.append({
                        "codepoint": codepoint,
                        "item_id": item_id,
                        "policy": "normalized-space",
                    })
                    continue
                shaping = _shaping_supported(character)
                checked = (identifiers[role], identifiers["fallback"])
                selected_role: str | None = None
                if font_supports(paths[role], character):
                    selected_role = role
                elif font_supports(paths["fallback"], character):
                    selected_role = "fallback"
                category = None
                if not shaping:
                    category = "unsupported-shaping"
                elif selected_role is None:
                    category = "missing-glyph"
                if category is not None:
                    issues.append(TypographyIssue(
                        category=category,
                        item_id=item_id,
                        codepoint=codepoint,
                        character=character,
                        style=style,
                        checked_fonts=checked,
                        remediation=REMEDIATION,
                    ))
                    continue
                assert selected_role is not None
                glyphs.append({
                    "character": character,
                    "codepoint": codepoint,
                    "coverage": "supported",
                    "font_id": identifiers[selected_role],
                    "item_id": item_id,
                    "shaping": "supported",
                    "style": style,
                })

    if issues:
        raise TypographyPreflightError(issues)

    policy_descriptor = dict(sorted(identifiers.items()))
    policy_binding = {
        role: {
            "font_id": identifiers[role],
            "sha256": _sha256_bytes(paths[role].read_bytes()),
        }
        for role in sorted(paths)
    }
    return {
        "font_policy": policy_descriptor,
        "font_policy_sha256": _sha256_bytes(_canonical_bytes(policy_binding)),
        "glyphs": glyphs,
        "input_sha256": _sha256_bytes(_canonical_bytes(canonical_items)),
        "issues": [],
        "kind": "typography-preflight",
        "non_glyphs": non_glyphs,
        "schema_version": "1.0",
        "status": "pass",
    }


def write_typography_preflight(
    project_dir: Path,
    panel_id: str,
    result: Mapping[str, object],
) -> Path:
    if re.fullmatch(r"p[0-9]{2}-[0-9]{2}", panel_id) is None:
        raise ValueError("invalid panel ID")
    destination = contained_project_path(
        Path(project_dir), f"panels/{panel_id}/typography.json"
    )
    atomic_write_json(destination, dict(result))
    return destination

SHA-256: 80c56f83c44603fcbc18550ae7a9d4960f1d53586f0e5e16f62b122e0517c938