#!/usr/bin/env python3
# hangulmath-type1.py -- part of the hangulmath package
# Copyright (C) 2026 Inkyu Park <icpark00@gmail.com>
# The script is distributed under the LaTeX Project Public License 1.3c
# or later; the fonts it writes stay under the SIL Open Font License 1.1.
"""Build the pdfLaTeX fonts of hangulmath from the bundled OpenType fonts.

For each of hangulmath-sans.otf and hangulmath-sans-light.otf this writes

  <name>.pfb   a Type 1 font with glyph names uniXXXX
  <name>.tfm   TeX metrics (via pltotf), 1 em = 1000 units
  hangulmath.map  the map line for pdfTeX / dvips / dvipdfmx

The 8-bit slot of a jamo is its Unicode value minus 0x3131, so
U+3131 (giyeok) is slot 0 and U+3163 (i) is slot 50. hangulmath.sty
uses the same rule. Advance widths and outlines are copied unchanged,
so hangulmath-metrics.def is valid for both the OpenType and the
Type 1 fonts.

Requires Python 3.10+, fontTools (pip install fonttools) and pltotf
(part of every TeX distribution). Run it in the package directory:

    python3 hangulmath-type1.py
"""
import subprocess
import tempfile
from pathlib import Path

from fontTools.misc import eexec
from fontTools.misc.psCharStrings import T1CharString
from fontTools.pens.basePen import BasePen
from fontTools.pens.boundsPen import BoundsPen
from fontTools.t1Lib import (ND_values, PD_values, RD_value, StandardEncoding,
                             T1Font, std_subrs)
from fontTools.ttLib import TTFont

FIRST, LAST = 0x3131, 0x3163          # Hangul compatibility jamo used
FONTS = {                              # otf -> (pfb/tfm base name, weight)
    "hangulmath-sans.otf": ("hangulmath-sans", "DemiLight"),
    "hangulmath-sans-light.otf": ("hangulmath-sans-light", "Light"),
}
NOTICE = ("Subset of Noto Sans CJK, Copyright 2014-2021 Adobe. "
          "SIL Open Font License 1.1.")


def slot(code: int) -> int:
    return code - FIRST


def glyph_name(code: int) -> str:
    return f"uni{code:04X}"


class T1Pen(BasePen):
    """Draw outlines as a Type 1 charstring program (integer coordinates)."""

    def __init__(self, glyphset):
        super().__init__(glyphset)
        self.program = []
        self.cur = (0, 0)

    def _rel(self, pt):
        x, y = round(pt[0]), round(pt[1])
        dx, dy = x - self.cur[0], y - self.cur[1]
        self.cur = (x, y)
        return [dx, dy]

    def _moveTo(self, pt):
        self.program += self._rel(pt) + ["rmoveto"]

    def _lineTo(self, pt):
        self.program += self._rel(pt) + ["rlineto"]

    def _curveToOne(self, p1, p2, p3):
        self.program += (self._rel(p1) + self._rel(p2) + self._rel(p3)
                         + ["rrcurveto"])

    def _closePath(self):
        self.program.append("closepath")

    _endPath = _closePath


def stems(program):
    """Collect (horizontal, vertical) stem hints from a Type 2 program.

    Type 2 hint replacement (hintmask) has no direct Type 1 equivalent
    without subroutines, so all stems are collected and later reduced to a
    non-overlapping set that is valid for the whole glyph.
    """
    h, v, args, first = [], [], [], True

    def take(target, values):
        pos = 0
        for i in range(0, len(values) - 1, 2):
            edge, width = values[i] + pos, values[i + 1]
            pos = edge + width
            if width > 0:                      # skip ghost (edge) hints
                target.append((edge, width))

    for item in program:
        if isinstance(item, (int, float)):
            args.append(item)
            continue
        if isinstance(item, bytes):            # hintmask data
            continue
        op = item
        if op in ("hstem", "hstemhm", "vstem", "vstemhm"):
            if first and len(args) % 2:
                args = args[1:]                # leading width
            take(h if op.startswith("h") else v, args)
        elif op in ("hintmask", "cntrmask"):
            if args:                           # implicit vstemhm
                if first and len(args) % 2:
                    args = args[1:]
                take(v, args)
        first = first and op not in (
            "hstem", "hstemhm", "vstem", "vstemhm", "hintmask", "cntrmask",
            "rmoveto", "hmoveto", "vmoveto", "endchar")
        args = []
    return non_overlapping(h), non_overlapping(v)


def non_overlapping(hints):
    chosen = []
    for edge, width in sorted(set(hints), key=lambda s: s[1]):
        if all(edge + width < e or e + w < edge for e, w in chosen):
            chosen.append((edge, width))
    return sorted(chosen)


def build_charstrings(otf: TTFont):
    cff = otf["CFF "].cff
    cff.desubroutinize()
    t2 = cff.topDictIndex[0].CharStrings
    glyphset = otf.getGlyphSet()
    cmap = otf.getBestCmap()
    charstrings, metrics = {}, {}

    notdef = T1CharString(program=[0, 1000, "hsbw", "endchar"])
    charstrings[".notdef"] = notdef
    for code in range(FIRST, LAST + 1):
        gname = cmap[code]
        width = otf["hmtx"][gname][0]
        pen = T1Pen(glyphset)
        glyphset[gname].draw(pen)
        t2cs = t2[gname]
        t2cs.decompile()
        hstems, vstems = stems(t2cs.program)
        hints = []
        for y, dy in hstems:
            hints += [y, dy, "hstem"]
        for x, dx in vstems:
            hints += [x, dx, "vstem"]
        program = [0, width, "hsbw"] + hints + pen.program + ["endchar"]
        charstrings[glyph_name(code)] = T1CharString(program=program)
        bp = BoundsPen(glyphset)
        glyphset[gname].draw(bp)
        metrics[code] = (width, bp.bounds)
    return charstrings, metrics


def fix_layout(data: bytes) -> bytes:
    """Adjust fontTools' Type 1 output to the layout pdfTeX expects.

    fontTools writes "currentfile eexec " and the encrypted part on one
    line, and ends the encrypted part with "mark" and "currentfile
    closefile" on separate lines. pdfTeX (like the Type 1 specification)
    wants a line break after "eexec" and the single line
    "mark currentfile closefile".
    """
    head, sep, rest = data.partition(b"currentfile eexec ")
    assert sep, "eexec marker not found"
    end = rest.find(b"\n" + b"0" * 64)
    assert end > 0, "trailing zeros not found"
    cipher, trailer = rest[:end], rest[end:]
    plain, _ = eexec.decrypt(cipher, 55665)
    old = b"mark\ncurrentfile closefile"
    assert old in plain, "closefile line not found"
    plain = plain.replace(old, b"mark currentfile closefile")
    cipher, _ = eexec.encrypt(plain, 55665)
    return head + b"currentfile eexec\n" + cipher + trailer


def write_pfb(otf: TTFont, charstrings, out: Path, weight: str):
    cff = otf["CFF "].cff
    top = cff.topDictIndex[0]
    order = otf.getGlyphOrder()
    fd = top.FDArray[top.FDSelect[order.index(otf.getBestCmap()[FIRST])]]
    priv_src = fd.Private
    ps_name = cff.fontNames[0]

    encoding = [".notdef"] * 256
    for code in range(FIRST, LAST + 1):
        encoding[slot(code)] = glyph_name(code)

    private = {"RD": RD_value, "ND": ND_values[0], "NP": PD_values[0]}
    for key in ("BlueValues", "OtherBlues", "BlueScale", "BlueShift",
                "BlueFuzz", "StdHW", "StdVW", "StemSnapH", "StemSnapV",
                "LanguageGroup"):
        if key in priv_src.rawDict:
            val = getattr(priv_src, key)
            private[key] = [val] if key in ("StdHW", "StdVW") else val
    private["MinFeature"] = (16, 16)
    private["password"] = 5839
    private["OtherSubrs"] = None
    private["Subrs"] = [T1CharString(bytecode=b) for b in std_subrs[:4]]

    t1 = T1Font.__new__(T1Font)        # build from scratch, not from a file
    t1.encoding = "ascii"
    t1.font = {
        "FontInfo": {
            "version": "1.000",
            "Notice": NOTICE,
            "FullName": f"HangulMath Sans {weight}",
            "FamilyName": "HangulMath Sans",
            "Weight": weight,
            "ItalicAngle": 0,
            "isFixedPitch": False,
            "UnderlinePosition": -100,
            "UnderlineThickness": 50,
        },
        "FontName": ps_name,
        "Encoding": encoding,
        "PaintType": 0,
        "FontType": 1,
        "FontMatrix": [0.001, 0, 0, 0.001, 0, 0],
        "FontBBox": tuple(top.FontBBox),
        "Private": private,
        "CharStrings": charstrings,
    }
    assert encoding != StandardEncoding
    t1.data = fix_layout(t1.createData())
    t1.saveAs(str(out), "PFB")
    return ps_name


def write_tfm(metrics, base: str, outdir: Path):
    lines = [
        "(FAMILY HANGULMATH)",
        "(CODINGSCHEME HANGULMATH JAMO U+3131-U+3163)",
        "(DESIGNSIZE R 10.0)",
        "(DESIGNUNITS R 1000)",
        "(FONTDIMEN",
        "   (SLANT R 0)", "   (SPACE R 0)", "   (STRETCH R 0)",
        "   (SHRINK R 0)", "   (XHEIGHT R 500)", "   (QUAD R 1000)",
        "   (EXTRASPACE R 0)",
        "   )",
    ]
    for code, (width, (x0, y0, x1, y1)) in metrics.items():
        lines.append(f"(CHARACTER O {slot(code):o}")
        lines.append(f"   (CHARWD R {width})")
        lines.append(f"   (CHARHT R {max(y1, 0)})")
        if y0 < 0:
            lines.append(f"   (CHARDP R {-y0})")
        lines.append("   )")
    with tempfile.TemporaryDirectory() as tmp:
        pl = Path(tmp) / f"{base}.pl"
        pl.write_text("\n".join(lines) + "\n", encoding="ascii")
        res = subprocess.run(["pltotf", str(pl), str(outdir / f"{base}.tfm")],
                             capture_output=True, text=True)
        if res.returncode or res.stdout.strip():
            raise SystemExit(f"pltotf failed for {base}:\n{res.stdout}{res.stderr}")


def main():
    here = Path(".")
    map_lines = ["% hangulmath.map -- Type 1 fonts of the hangulmath package"]
    for otf_name, (base, weight) in FONTS.items():
        otf = TTFont(otf_name)
        charstrings, metrics = build_charstrings(otf)
        ps_name = write_pfb(otf, charstrings, here / f"{base}.pfb", weight)
        write_tfm(metrics, base, here)
        map_lines.append(f"{base} {ps_name} <{base}.pfb")
        print(f"wrote {base}.pfb, {base}.tfm ({ps_name})")
    (here / "hangulmath.map").write_text("\n".join(map_lines) + "\n",
                                         encoding="ascii")
    print("wrote hangulmath.map")


if __name__ == "__main__":
    main()
