#!/usr/bin/env python3 # hangulmath-type1.py -- part of the hangulmath package # Copyright (C) 2026 Inkyu Park # 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 .pfb a Type 1 font with glyph names uniXXXX .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()