#!/usr/bin/env python3
"""
star_font_decoder.py - Decoder for the SGI ".sf" / ".of" / ".mf" font family.

All three files share the SAME 24-byte header and 20-byte glyph-record table.
They describe the SAME typeface (identical bounding boxes); they differ only in
how each glyph's outline is encoded, selected by the `version` byte at offset 8:

    version 3  ->  .sf   per-point opcode stream (Bezier curves)   [FULL]
    version 2  ->  .of   flattened polygon contours                [FULL]
    version 1  ->  .mf   progressive/subdivision point order        [PARTIAL]

------------------------------------------------------------------ HEADER (24 B)
  +0  u32  magic        0x93339333
  +4  u32  reserved
  +8  u16  version      1 | 2 | 3
  +10 u16  first_char
  +12 u16  last_char
  +14 u16  glyph_count  (= last - first + 1)
  +16 u16  units_per_em
  +18 u16  flags
  +20 u32  runtime_ptr  original IRIX VM address (ignored on disk)

----------------------------------------------------- GLYPH TABLE (count * 20 B)
  +0  u16  tag          0x04e5 normal, 0x0000 empty
  +2  u16  pad
  +4..+10  s16 x4        bbox xmin, ymin, xmax, ymax
  +12 u32  runtime_ptr   VM address (NOT a file offset; ignored)
  +16 u32  data_length   bytes of outline data for this glyph

Outline blobs are concatenated in character order with no padding; a glyph's
blob is located by the running sum of the data_length fields.

------------------------------------------------------------ OUTLINE ENCODINGS
version 3 (.sf): stream of [u16 op] (+ s16 x,y pairs):
    1 moveto(pt)  2 lineto(pt)  3 cubic(c1,c2,end)  4 close  5 close+end  6 empty

version 2 (.of): repeated [u16 marker][u16 count][count*(x,y) s16]; marker 1 =
    first contour, 2 = subsequent contour; a lone u16 == 3 terminates. Every
    contour is a closed straight-line polygon (curves are pre-flattened).

version 1 (.mf): same [marker][count][points] block grammar, markers 1/2/3 with
    a lone u16 == 4 terminator. The point set matches the other versions but is
    emitted in a progressive subdivision order, not path order, so a faithful
    path reconstruction is not available here; `reconstruct=True` applies a
    nearest-neighbour heuristic that recovers many (not all) glyphs. For exact
    rendering use the .of or .sf file (identical geometry).

Metrics note: the format stores only a bounding box, no explicit advance width;
`advance` is reported as xmax (right extent) as the conventional fallback.
"""

import argparse
import json
import struct
from dataclasses import dataclass, field
from typing import List, Tuple, Optional

MAGIC = 0x93339333


# ----------------------------------------------------------------- data model
@dataclass
class Glyph:
    char: int
    tag: int
    bbox: Tuple[int, int, int, int]
    length: int
    runtime_ptr: int
    contours: List[List[Tuple[int, int]]] = field(default_factory=list)  # polylines
    segments: Optional[list] = None       # v3 only: list of (op,[pts]) per contour
    approximate: bool = False             # True for v1 heuristic reconstruction

    @property
    def is_empty(self) -> bool:
        return not any(self.contours)

    @property
    def advance(self) -> int:
        return 0 if self.bbox[2] in (30000,) else self.bbox[2]


@dataclass
class Header:
    magic: int
    version: int
    first_char: int
    last_char: int
    glyph_count: int
    units_per_em: int
    flags: int
    runtime_ptr: int


# --------------------------------------------------------------- the decoder
class StarFont:
    HEADER_SIZE = 24
    RECORD_SIZE = 20

    def __init__(self, header, glyphs):
        self.header = header
        self.glyphs = glyphs
        self._by_char = {g.char: g for g in glyphs}

    def glyph(self, ch):
        return self._by_char.get(ord(ch) if isinstance(ch, str) else ch)

    # ---- top-level parse ----
    @classmethod
    def parse(cls, data: bytes, reconstruct: bool = True) -> "StarFont":
        magic, _res, ver, first, last, count, upm, flags, rptr = struct.unpack(
            ">IIHHHHHHI", data[:cls.HEADER_SIZE])
        if magic != MAGIC:
            raise ValueError(f"bad magic 0x{magic:08x}")
        hdr = Header(magic, ver, first, last, count, upm, flags, rptr)

        table = cls.HEADER_SIZE
        cursor = table + count * cls.RECORD_SIZE
        glyphs = []
        for i in range(count):
            off = table + i * cls.RECORD_SIZE
            tag, _p, x0, y0, x1, y1, ptr, length = struct.unpack(
                ">HHhhhhII", data[off:off + cls.RECORD_SIZE])
            blob = data[cursor:cursor + length]
            cursor += length
            g = Glyph(first + i, tag, (x0, y0, x1, y1), length, ptr)
            cls._decode(g, blob, ver, reconstruct)
            glyphs.append(g)
        if cursor != len(data):
            raise ValueError(f"data size mismatch: used {cursor}, file {len(data)}")
        return cls(hdr, glyphs)

    @classmethod
    def _decode(cls, g, blob, ver, reconstruct):
        if len(blob) <= 2:
            return
        if ver == 3:
            cls._decode_v3(g, blob)
        elif ver == 2:
            g.contours = cls._decode_blocks(blob, end_marker=3)[0]
        elif ver == 1:
            raw, _ = cls._decode_blocks(blob, end_marker=4, with_markers=True)
            g.contours = cls._mf_contours(raw)
            if reconstruct:
                g.contours = [cls._nn_chain(c) for c in g.contours]
                g.approximate = True
        else:
            raise ValueError(f"unsupported version {ver}")

    # ---- version 3: opcode stream (Bezier) ----
    @staticmethod
    def _decode_v3(g, blob):
        OP_PTS = {1: 1, 2: 1, 3: 3, 4: 0, 5: 0, 6: 0}
        contours, segs = [], []
        cur_poly, cur_seg = [], []
        p, n = 0, len(blob)

        def pt():
            nonlocal p
            x, y = struct.unpack(">hh", blob[p:p + 4]); p += 4
            return (x, y)

        def flatten_cubic(p0, c1, c2, p3, steps=8):
            out = []
            for s in range(1, steps + 1):
                t = s / steps; mt = 1 - t
                x = (mt**3*p0[0] + 3*mt*mt*t*c1[0] + 3*mt*t*t*c2[0] + t**3*p3[0])
                y = (mt**3*p0[1] + 3*mt*mt*t*c1[1] + 3*mt*t*t*c2[1] + t**3*p3[1])
                out.append((x, y))
            return out

        last = (0, 0)
        while p < n:
            (op,) = struct.unpack(">H", blob[p:p + 2]); p += 2
            if op == 6:
                break
            if op == 1:
                if cur_poly:
                    contours.append(cur_poly); segs.append(cur_seg)
                last = pt(); cur_poly = [last]; cur_seg = [("move", [last])]
            elif op == 2:
                last = pt(); cur_poly.append(last); cur_seg.append(("line", [last]))
            elif op == 3:
                c1, c2, e = pt(), pt(), pt()
                cur_poly.extend(flatten_cubic(last, c1, c2, e))
                cur_seg.append(("cubic", [c1, c2, e])); last = e
            elif op in (4, 5):
                if cur_poly:
                    contours.append(cur_poly); segs.append(cur_seg)
                cur_poly, cur_seg = [], []
                if op == 5:
                    break
        if cur_poly:
            contours.append(cur_poly); segs.append(cur_seg)
        g.contours, g.segments = contours, segs

    # ---- shared block grammar for v1/v2 ----
    @staticmethod
    def _decode_blocks(blob, end_marker, with_markers=False):
        sw = struct.unpack(">%dh" % (len(blob) // 2), blob)
        uw = struct.unpack(">%dH" % (len(blob) // 2), blob)
        p, n = 0, len(uw)
        blocks = []
        while p < n:
            m = uw[p]
            if m == end_marker:
                break
            cnt = uw[p + 1]; p += 2
            pts = [(sw[p + 2*k], sw[p + 2*k + 1]) for k in range(cnt)]
            p += 2 * cnt
            blocks.append((m, pts))
        if with_markers:
            return blocks, None
        # v2: marker 1 starts, 2 appends-as-new-contour
        return [pts for _m, pts in blocks], None

    # ---- version 1 helpers ----
    @staticmethod
    def _mf_contours(blocks):
        """Group v1 blocks into contours: markers 1 and 3 start a contour,
        marker 2 continues the current one. (Heuristic grouping.)"""
        contours, cur = [], []
        for m, pts in blocks:
            if m in (1, 3):
                if cur:
                    contours.append(cur)
                cur = list(pts)
            else:
                cur.extend(pts)
        if cur:
            contours.append(cur)
        return contours

    @staticmethod
    def _nn_chain(pts):
        if len(pts) < 3:
            return pts
        used = [False] * len(pts); order = [0]; used[0] = True
        for _ in range(len(pts) - 1):
            cx, cy = pts[order[-1]]; best, bd = -1, 1e18
            for j, (x, y) in enumerate(pts):
                if used[j]:
                    continue
                d = (x - cx) ** 2 + (y - cy) ** 2
                if d < bd:
                    bd, best = d, j
            order.append(best); used[best] = True
        return [pts[i] for i in order]

    # ---- export ----
    def svg_path(self, g: Glyph, use_curves=True) -> str:
        if g.segments is not None and use_curves:    # v3 exact Beziers
            d = []
            for seg in g.segments:
                for op, pts in seg:
                    if op == "move":
                        d.append("M %d %d" % pts[0])
                    elif op == "line":
                        d.append("L %d %d" % pts[0])
                    elif op == "cubic":
                        d.append("C %d %d %d %d %d %d" % (pts[0]+pts[1]+pts[2]))
                if seg:
                    d.append("Z")
            return " ".join(d)
        d = []                                        # polyline contours
        for poly in g.contours:
            if not poly:
                continue
            d.append("M " + " L ".join("%g %g" % (x, y) for x, y in poly) + " Z")
        return " ".join(d)

    def specimen_svg(self, cols=16, cell=64) -> str:
        upm = self.header.units_per_em or 1000
        gs = [g for g in self.glyphs if 32 <= g.char < 127]
        rows = (len(gs) + cols - 1) // cols
        W, H = cols * cell, rows * cell
        s = cell * 0.74 / upm
        fr = "evenodd" if self.header.version == 2 else "nonzero"
        out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
               f'viewBox="0 0 {W} {H}"><rect width="{W}" height="{H}" fill="#fafafa"/>']
        for i, g in enumerate(gs):
            r, c = divmod(i, cols); ox, oy = c * cell, r * cell
            out.append(f'<rect x="{ox}" y="{oy}" width="{cell}" height="{cell}" '
                       f'fill="none" stroke="#e6e6e6"/>')
            if not g.is_empty:
                base, left = oy + cell * 0.82, ox + cell * 0.16
                out.append(f'<g transform="translate({left},{base}) scale({s},{-s})">'
                           f'<path d="{self.svg_path(g)}" fill="#1a1a1a" '
                           f'fill-rule="{fr}"/></g>')
        out.append("</svg>")
        return "".join(out)

    # ---- installable-font export (OTF / TTF) ----
    def _draw_glyph(self, g, pen):
        """Replay a glyph onto a fontTools pen (cubics if available, else lines)."""
        if g.segments is not None:
            for seg in g.segments:
                started = False
                for op, pts in seg:
                    if op == "move":
                        pen.moveTo(pts[0]); started = True
                    elif op == "line":
                        pen.lineTo(pts[0])
                    elif op == "cubic":
                        pen.curveTo(pts[0], pts[1], pts[2])
                if started:
                    pen.closePath()
        else:
            for poly in g.contours:
                if not poly:
                    continue
                pen.moveTo(poly[0])
                for p in poly[1:]:
                    pen.lineTo(p)
                pen.closePath()

    def _font_metrics(self):
        ys = [p[1] for g in self.glyphs for c in g.contours for p in c]
        asc = int(round(max(ys))) if ys else 800
        desc = int(round(min(ys))) if ys else -200
        return asc, desc

    def _build(self, is_ttf, family, style, space_advance, max_err=1.0):
        from fontTools.fontBuilder import FontBuilder
        upm = self.header.units_per_em or 1000
        asc, desc = self._font_metrics()
        order = [".notdef"]
        metrics = {}
        cmap = {}
        glyphs = {}   # name -> charstring (CFF) or TTGlyph

        if is_ttf:
            from fontTools.pens.ttGlyphPen import TTGlyphPen
            from fontTools.pens.cu2quPen import Cu2QuPen

            def make(g, adv):
                tt = TTGlyphPen(None)
                self._draw_glyph(g, Cu2QuPen(tt, max_err, reverse_direction=True))
                return tt.glyph()
            empty = lambda adv: TTGlyphPen(None).glyph()
        else:
            from fontTools.pens.t2CharStringPen import T2CharStringPen

            def make(g, adv):
                pen = T2CharStringPen(adv, None)
                self._draw_glyph(g, pen)
                return pen.getCharString()
            empty = lambda adv: __import__("fontTools.pens.t2CharStringPen",
                                           fromlist=["T2CharStringPen"]
                                           ).T2CharStringPen(adv, None).getCharString()

        glyphs[".notdef"] = empty(space_advance)
        metrics[".notdef"] = (space_advance, 0)
        for g in self.glyphs:
            name = "uni%04X" % g.char
            order.append(name)
            if g.is_empty:
                adv = space_advance if g.char in (32, 160) else 0
                glyphs[name] = empty(adv)
                metrics[name] = (adv, 0)
            else:
                xmin = int(round(min(p[0] for c in g.contours for p in c)))
                xmax = int(round(max(p[0] for c in g.contours for p in c)))
                adv = xmax                      # tight, natural spacing
                glyphs[name] = make(g, adv)
                metrics[name] = (adv, xmin)
            cmap[g.char] = name

        fb = FontBuilder(upm, isTTF=is_ttf)
        fb.setupGlyphOrder(order)
        fb.setupCharacterMap(cmap)
        ps = f"{family}-{style}".replace(" ", "")
        if is_ttf:
            fb.setupGlyf(glyphs)
        else:
            fb.setupCFF(ps, {"FullName": f"{family} {style}", "FamilyName": family},
                        glyphs, {})
        fb.setupHorizontalMetrics(metrics)
        fb.setupHorizontalHeader(ascent=asc, descent=desc, lineGap=0)
        fb.setupNameTable({"familyName": family, "styleName": style,
                           "fullName": f"{family} {style}", "psName": ps,
                           "version": "Version 1.0"})
        fb.setupOS2(sTypoAscender=int(upm * 0.8), sTypoDescender=int(upm * -0.2),
                    sTypoLineGap=0, usWinAscent=max(asc, 1), usWinDescent=abs(min(desc, 0)),
                    sCapHeight=776, sxHeight=540)
        fb.setupPost()
        return fb

    def export_otf(self, path, family="STAR101", style="Regular", space_advance=520):
        """Write an installable OpenType/CFF font (cubic outlines, lossless)."""
        self._build(False, family, style, space_advance).save(path)

    def export_ttf(self, path, family="STAR101", style="Regular",
                   space_advance=520, max_err=1.0):
        """Write an installable TrueType font (cubics converted to quadratics)."""
        self._build(True, family, style, space_advance, max_err).save(path)

    def to_dict(self) -> dict:
        h = self.header
        return {
            "header": {"version": h.version, "first_char": h.first_char,
                       "last_char": h.last_char, "glyph_count": h.glyph_count,
                       "units_per_em": h.units_per_em, "flags": h.flags},
            "glyphs": [{
                "char": g.char,
                "name": chr(g.char) if 32 <= g.char < 127 else None,
                "bbox": list(g.bbox), "advance": g.advance, "empty": g.is_empty,
                "approximate_path": g.approximate,
                "contours": [[list(p) for p in poly] for poly in g.contours],
            } for g in self.glyphs],
        }


def main():
    ap = argparse.ArgumentParser(description="Decode SGI .sf/.of/.mf font files.")
    ap.add_argument("font")
    ap.add_argument("--json", metavar="OUT")
    ap.add_argument("--sheet", metavar="OUT.svg")
    ap.add_argument("--otf", metavar="OUT.otf", help="export installable OpenType/CFF font")
    ap.add_argument("--ttf", metavar="OUT.ttf", help="export installable TrueType font")
    ap.add_argument("--family", default="STAR101")
    ap.add_argument("--style", default="Regular")
    ap.add_argument("--no-reconstruct", action="store_true",
                    help="for .mf: keep raw subdivision order instead of heuristic path")
    args = ap.parse_args()

    font = StarFont.parse(open(args.font, "rb").read(),
                          reconstruct=not args.no_reconstruct)
    h = font.header
    ver_name = {1: ".mf (subdivision, partial)", 2: ".of (polygon)",
                3: ".sf (bezier)"}.get(h.version, "?")
    nonempty = sum(1 for g in font.glyphs if not g.is_empty)
    print(f"version    {h.version}  {ver_name}")
    print(f"chars      {h.first_char}..{h.last_char} ({h.glyph_count} glyphs, "
          f"{nonempty} with outlines)")
    print(f"units/em   {h.units_per_em}")
    if h.version == 1:
        print("note       .mf path order is heuristically reconstructed; use .of/.sf "
              "for exact rendering (identical geometry).")

    if args.json:
        json.dump(font.to_dict(), open(args.json, "w"), indent=1)
        print(f"wrote {args.json}")
    if args.sheet:
        open(args.sheet, "w").write(font.specimen_svg())
        print(f"wrote {args.sheet}")
    if args.otf:
        if font.header.version == 1:
            print("warning: exporting from .mf uses approximate path reconstruction; "
                  "prefer the .sf or .of file for a faithful font.")
        font.export_otf(args.otf, args.family, args.style)
        print(f"wrote {args.otf}")
    if args.ttf:
        if font.header.version == 1:
            print("warning: exporting from .mf uses approximate path reconstruction; "
                  "prefer the .sf or .of file for a faithful font.")
        font.export_ttf(args.ttf, args.family, args.style)
        print(f"wrote {args.ttf}")


if __name__ == "__main__":
    main()
