#!/usr/bin/env python3
"""Generate the embedded bitmap font for the native PNG rasterizer.

The Rust core deliberately does not link FreeType, so `to_png`'s native engine
doesn't rasterize text through a system font library at runtime. Instead we bake a small grayscale glyph atlas
here — once, reproducibly — from a bundled TrueType face and emit it as a plain
`const` table into `src/font.rs`. Runtime text is then a coverage blit + bilinear
scale, no font library.

Usage: `uv run --group dev --with matplotlib python scripts/gen_font.py`
(needs Pillow from the dev dependency group plus matplotlib's bundled
DejaVuSans.ttf). Commit the regenerated `src/font.rs`.
"""

# ruff: noqa: RUF001 — the EXTRA glyph list is deliberately unicode.
from __future__ import annotations

from pathlib import Path

from PIL import Image, ImageFont

# Printable ASCII. Axis labels/titles/legends mostly stay within this set.
FIRST, LAST = 0x20, 0x7E
# Latin-1 Supplement + Latin Extended-A letters: every language written in the
# Latin script that xy is likely to label a chart in. Without these a `Café` or
# `Zürich` tick label lost the accented letter *entirely* — no glyph, no
# advance, no warning — while the same chart's SVG rendered it correctly.
# Ranges, not a hand-typed string, so the coverage is auditable.
_LATIN = "".join(chr(c) for c in [*range(0x00C0, 0x0180)] if chr(c).isalpha())
# Currency beyond ASCII `$`. `xy.y_axis(format="€,.0f")` is a first-class
# feature, so its symbol has to survive to_png().
_CURRENCY = "¢£¤¥₣₤₦₩₪₫€₭₮₱₲₴₹₺₽₿"
# Extra codepoints for the pyplot shim's TeX-subset → unicode conversion
# (greek, super/subscripts, math operators), typography mpl emits (−, µ), and
# the precomposed umlauts used by Matplotlib's canonical text_commands example.
EXTRA = sorted(
    set(
        "öü"
        "αβγδεζηθικλμνξοπρστυφχψω"
        "ΓΔΘΛΞΠΣΥΦΨΩ"
        "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ"
        "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₕᵢₖₗₘₙₒₚᵣₛₜᵤᵥₓ"
        "×·±∓≤≥≠≈∞√°→←∂∇∫∝∈−–—‘’“”…µ"
        "§¶†‡•‰≡⌀♣♦♥♠"
        + _LATIN
        + _CURRENCY
        # U+FFFD is the fallback the rasterizer substitutes for anything still
        # missing, so a character xy cannot draw shows up as a visible box
        # instead of vanishing (§28: never silent).
        + "�"
    )
)
PX = 16  # render size; runtime scales this coverage bitmap to the requested size.
# Keep the two historical atlas advances stable across Matplotlib's bundled
# DejaVu Sans revisions; changing them would move unrelated existing labels.
STABLE_ADVANCES = {"?": 8, "K": 10}
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "src" / "font.rs"
# The advance/cell metrics alone, for Python-side static layout: `_svg.layout()`
# has to know how wide a tick label or axis title will actually be *before* it
# can reserve a gutter for it, and the rasterizer that draws it is the Rust
# core. Emitting the same numbers from the same generator keeps the reservation
# and the ink from drifting apart (there is no font library in either process).
OUT_PY = ROOT / "python" / "xy" / "_fontmetrics.py"


def _load_face() -> ImageFont.FreeTypeFont:
    import matplotlib

    ttf = Path(matplotlib.get_data_path()) / "fonts" / "ttf" / "DejaVuSans.ttf"
    return ImageFont.truetype(str(ttf), PX)


def main() -> None:
    face = _load_face()
    ascent, descent = face.getmetrics()
    cell_h = ascent + descent

    glyphs = []  # (advance, w, h, left, top_from_baseline, coverage bytes)
    codepoints = [*range(FIRST, LAST + 1), *(ord(ch) for ch in EXTRA)]
    for code in codepoints:
        ch = chr(code)
        # Advance width (horizontal pen movement).
        advance = STABLE_ADVANCES.get(ch, round(face.getlength(ch)))
        box = face.getbbox(ch)  # (left, top, right, bottom) in the ascent-origin frame
        if box is None or box[2] <= box[0] or box[3] <= box[1]:
            glyphs.append((advance, 0, 0, 0, 0, b""))
            continue
        left, top, right, bottom = box
        w, h = right - left, bottom - top
        img = Image.new("L", (w, h), 0)
        # Draw so the glyph's bbox aligns to (0,0) of `img`.
        from PIL import ImageDraw

        ImageDraw.Draw(img).text((-left, -top), ch, font=face, fill=255)
        # `top` measured downward from the baseline (baseline = ascent).
        top_from_baseline = top - ascent
        glyphs.append((advance, w, h, left, top_from_baseline, img.tobytes()))

    # Flatten coverage into one array with per-glyph offsets.
    blob = bytearray()
    records = []
    for advance, w, h, left, top, cov in glyphs:
        off = len(blob)
        blob.extend(cov)
        records.append((advance, w, h, left, top, off, len(cov)))

    _emit(cell_h, ascent, blob, records)
    print(f"wrote {OUT} — {len(records)} glyphs, {len(blob)} coverage bytes, cell_h={cell_h}")
    _emit_python(cell_h, ascent, records)
    print(f"wrote {OUT_PY} — {len(records)} advances")


def _emit(cell_h: int, ascent: int, blob: bytearray, records: list) -> None:
    lines = [
        "// @generated by scripts/gen_font.py — do not edit by hand.",
        "// Grayscale glyph atlas (DejaVu Sans) for the native PNG rasterizer's",
        "// text: the Rust core deliberately avoids FreeType, so glyph",
        "// coverage is baked here and blitted+scaled at runtime (src/raster.rs).",
        "",
        f"pub const FIRST: u8 = {FIRST};",
        f"pub const LAST: u8 = {LAST};",
        f"pub const BASE_PX: i32 = {PX};",
        f"pub const CELL_H: i32 = {cell_h};",
        f"pub const ASCENT: i32 = {ascent};",
        "",
        "/// Codepoints of the non-ASCII glyphs, sorted; GLYPHS row = 95 + index.",
        f"pub static EXTRA_CODEPOINTS: [u32; {len(EXTRA)}] = [",
        "    " + ", ".join(str(ord(ch)) for ch in EXTRA) + ",",
        "];",
        "",
        "/// Per-glyph metrics at BASE_PX: (advance, w, h, left, top, cov_off, cov_len).",
        "/// `top` is the pixel offset of the glyph's top edge below the baseline",
        "/// (negative = above). Coverage bytes live in `COVERAGE[cov_off..][..cov_len]`,",
        "/// row-major w*h, 0..=255.",
        f"pub static GLYPHS: [(i32, i32, i32, i32, i32, u32, u32); {len(records)}] = [",
    ]
    for advance, w, h, left, top, off, ln in records:
        lines.append(f"    ({advance}, {w}, {h}, {left}, {top}, {off}, {ln}),")
    lines.append("];")
    lines.append("")
    lines.append(f"pub static COVERAGE: [u8; {len(blob)}] = [")
    # 24 bytes per source line.
    for i in range(0, len(blob), 24):
        chunk = ", ".join(str(b) for b in blob[i : i + 24])
        lines.append(f"    {chunk},")
    lines.append("];")
    lines.append("")
    OUT.write_text("\n".join(lines) + "\n", encoding="utf-8")


def _emit_python(cell_h: int, ascent: int, records: list) -> None:
    """Advance/cell metrics for the Python static-layout path (`_svg.layout`)."""
    n_ascii = LAST - FIRST + 1
    ascii_adv = [record[0] for record in records[:n_ascii]]
    extra_adv = {ord(ch): records[n_ascii + i][0] for i, ch in enumerate(EXTRA)}
    lines = [
        '"""Advance widths of the core\'s embedded DejaVu Sans face.',
        "",
        "@generated by scripts/gen_font.py — do not edit by hand.",
        "",
        "`src/font.rs` bakes the same numbers for the Rust rasterizer that draws the",
        "text; this module is how `_svg.layout()` can size an axis gutter from the ink",
        "it is about to reserve for.  DejaVu Sans is also Matplotlib's default face, so",
        "an advance measured here is the advance Matplotlib would lay out.",
        '"""',
        "",
        "from __future__ import annotations",
        "",
        f"BASE_PX = {PX}",
        f"CELL_H = {cell_h}",
        f"ASCENT = {ascent}",
        f"DESCENT = {cell_h - ascent}",
        f"FIRST = {FIRST}",
        f"LAST = {LAST}",
        "",
        "# fmt: off",
        "# Advances at BASE_PX for printable ASCII, `FIRST` first.",
        "ASCII_ADVANCES: tuple[int, ...] = (",
    ]
    for i in range(0, len(ascii_adv), 20):
        lines.append("    " + " ".join(f"{v}," for v in ascii_adv[i : i + 20]))
    lines.append(")")
    lines.append("")
    lines.append("# Advances at BASE_PX for the non-ASCII glyphs, keyed by codepoint.")
    lines.append("EXTRA_ADVANCES: dict[int, int] = {")
    items = sorted(extra_adv.items())
    for i in range(0, len(items), 8):
        chunk = " ".join(f"{cp}: {adv}," for cp, adv in items[i : i + 8])
        lines.append(f"    {chunk}")
    lines.append("}")
    lines.append("# fmt: on")
    lines.append("")
    lines.append("# Fallback advance for a codepoint the atlas has no glyph for: the")
    lines.append("# rasterizer substitutes the visible U+FFFD replacement glyph.")
    lines.append("_MISSING = EXTRA_ADVANCES[0xFFFD]")
    lines.append("")
    lines.append("")
    lines.append("def advance(text: str, font_size: float) -> float:")
    lines.append('    """Advance width in px of `text` rendered at `font_size`."""')
    lines.append("    units = 0")
    lines.append("    for char in text:")
    lines.append("        code = ord(char)")
    lines.append("        if FIRST <= code <= LAST:")
    lines.append("            units += ASCII_ADVANCES[code - FIRST]")
    lines.append("        else:")
    lines.append("            units += EXTRA_ADVANCES.get(code, _MISSING)")
    lines.append("    return font_size * units / BASE_PX")
    OUT_PY.write_text("\n".join(lines) + "\n", encoding="utf-8")


if __name__ == "__main__":
    main()
