# Copyright (c) 2026 Simon Rubinstein-Salzedo. # Distributed under the LaTeX Project Public License, version 1.3c or later. # Maintenance status: maintained. Current Maintainer: Simon Rubinstein-Salzedo. # License: https://www.latex-project.org/lppl.txt # See README.md for the list of files and attribution. import fontforge import re import subprocess from pathlib import Path # --- Global font metrics (TFM designsize = 10pt; EM=1000) --- EM = 1000 LSB_REL, RSB_REL = 100, 100 # default bearings for relations LSB_BIN, RSB_BIN = 90, 90 # for binary operators LSB_ORD, RSB_ORD = 60, 60 # for ordinary symbols # Reference dimensions from Computer Modern at 10pt. # Heights/depths (em units) GT_HT, GT_DP = 539, 39 # '>' UP_HT, UP_DP = 694, 194 # \uparrow DOWN_HT, DOWN_DP = 694, 194 # \downarrow CIRC_HT, CIRC_DP = 694, 194 # \bigcirc # Widths (em units) PLUS_W = 778 # '+' advance width UP_W = 500 DUP_W = 611 STEP_W = DUP_W - UP_W TRIPLE_W = DUP_W + STEP_W QUAD_W = DUP_W + 2*STEP_W MINUS_HT, MINUS_DP = 583, 83 MINUS_CENTER = 0.5*(MINUS_HT - MINUS_DP) # SVG sources and private-use Unicode codepoints. GLYPHS = [ (0xE100, "combgames-symbols-0.svg", "tripleup", ("both", UP_HT, UP_DP, TRIPLE_W, (LSB_ORD, RSB_ORD))), (0xE101, "combgames-symbols-1.svg", "tripledown", ("both", UP_HT, UP_DP, TRIPLE_W, (LSB_ORD, RSB_ORD))), (0xE102, "combgames-symbols-2.svg", "quadup", ("both", UP_HT, UP_DP, QUAD_W, (LSB_ORD, RSB_ORD))), (0xE103, "combgames-symbols-3.svg", "quaddown", ("both", UP_HT, UP_DP, QUAD_W, (LSB_ORD, RSB_ORD))), (0xE104, "combgames-symbols-15.svg", "gtfuzzy", ("htdp", GT_HT, GT_DP, (LSB_REL,RSB_REL))), (0xE105, "combgames-symbols-16.svg", "ltfuzzy", ("htdp", GT_HT, GT_DP, (LSB_REL,RSB_REL))), (0xE106, "combgames-symbols-17.svg", "fuzzy", ("htdp", GT_HT, GT_DP, (LSB_REL,RSB_REL))), (0xE107, "combgames-symbols-30.svg", "tinysymbol", ("width", PLUS_W, (LSB_BIN, RSB_BIN))), (0xE108, "combgames-symbols-31.svg", "minysymbol", ("width-center", PLUS_W, (LSB_BIN, RSB_BIN), MINUS_CENTER)), (0xE109, "combgames-symbols-40.svg", "upsum", ("htdp", UP_HT, UP_DP, (LSB_BIN, RSB_BIN))), (0xE110, "combgames-symbols-41.svg", "downsum", ("htdp", UP_HT, UP_DP, (LSB_BIN, RSB_BIN))), (0xE111, "combgames-symbols-50.svg", "ko", ("htdp", UP_HT, UP_DP, (LSB_BIN, RSB_BIN))), (0xE112, "combgames-symbols-51.svg", "Ko", ("htdp", UP_HT, UP_DP, (LSB_BIN, RSB_BIN))), (0xE113, "combgames-symbols-52.svg", "kobar", ("htdp", UP_HT, UP_DP, (LSB_BIN, RSB_BIN))), (0xE114, "combgames-symbols-53.svg", "Kobar", ("htdp", UP_HT, UP_DP, (LSB_BIN, RSB_BIN))), (0xE115, "combgames-symbols-70.svg", "farstar", ("htdp", UP_HT, UP_DP, (LSB_BIN, RSB_BIN))), (0xE116, "combgames-symbols-80.svg", "loony", ("htdp", UP_HT, UP_DP, (LSB_BIN, RSB_BIN))), (0xE117, "combgames-symbols-81.svg", "sunny", ("htdp", UP_HT, UP_DP, (LSB_BIN, RSB_BIN))), ] # --- Helpers --- def norm_to_htdp(g, ht_em, dp_em, lsb, rsb): """Uniformly scale so top=+ht_em, bottom=-dp_em; set bearings/advance width.""" xmin, ymin, xmax, ymax = g.boundingBox() gh = max(1, ymax - ymin) s = float(ht_em + dp_em) / gh dx = -xmin*s dy = -ymin*s - dp_em g.transform((s,0,0,s, dx,dy)) g.left_side_bearing, g.right_side_bearing = lsb, rsb g.width = lsb + int((xmax - xmin)*s) + rsb g.round() def norm_to_width(g, adv_em, lsb, rsb, lock_htdp=None, lock_center=None): # uniform scale to target advance width xmin, ymin, xmax, ymax = g.boundingBox() gw = max(1, xmax - xmin) inner = max(1, adv_em - lsb - rsb) s = float(inner) / gw dx = -xmin*s dy = -ymin*s g.transform((s,0,0,s, dx,dy)) g.left_side_bearing, g.right_side_bearing = lsb, rsb g.width = adv_em # either lock by (HT,DP) or by vertical center (in em units) if lock_htdp is not None: HT, DP = lock_htdp xmin, ymin, xmax, ymax = g.boundingBox() g.transform((1,0,0,1, 0, -ymin - DP)) # bottom → −DP elif lock_center is not None: xmin, ymin, xmax, ymax = g.boundingBox() cur_center = 0.5*(ymax + ymin) g.transform((1,0,0,1, 0, lock_center - cur_center)) g.round() def norm_to_htdp_and_width(g, ht_em, dp_em, adv_em, lsb, rsb): """Anisotropic: hit both total height and advance width exactly.""" xmin, ymin, xmax, ymax = g.boundingBox() gw = max(1, xmax - xmin) gh = max(1, ymax - ymin) sx = float(max(1, adv_em - lsb - rsb)) / gw sy = float(ht_em + dp_em) / gh dx = -xmin*sx dy = -ymin*sy - dp_em g.transform((sx,0,0,sy, dx,dy)) g.left_side_bearing, g.right_side_bearing = lsb, rsb g.width = adv_em g.round() def norm_overlay_zero(g): """Zero-width overlay glyph (e.g., bars).""" g.left_side_bearing = g.right_side_bearing = 0 g.width = 0 g.round() # --- Build --- f = fontforge.font() f.encoding = "UnicodeFull" f.em = EM f.ascent = 700 f.descent = 300 f.fontname = "CombgamesSymbols" f.familyname = "Combgames Symbols" f.fullname = "Combgames Symbols" f.copyright = "Copyright (c) 2026 Simon Rubinstein-Salzedo. Adapted from combgames by Raymond Chen, David Wolfe and Aaron N. Siegel. Licensed under LPPL 1.3c or later." for cp, svg, name, pol in GLYPHS: g = f.createChar(cp, name) g.importOutlines(svg) kind = pol[0] if kind == "htdp": _, HT, DP, (lsb, rsb) = pol norm_to_htdp(g, HT, DP, lsb, rsb) if name == 'sunny': # Circular glyph: preserve its advance despite SVG rounding noise. g.width = lsb + HT + DP + rsb elif kind == "width": # ("width", W, (lsb,rsb)) OR ("width", W, (lsb,rsb), (HT,DP)) if len(pol) == 3: _, W, (lsb, rsb) = pol norm_to_width(g, W, lsb, rsb) else: _, W, (lsb, rsb), (HT, DP) = pol norm_to_width(g, W, lsb, rsb, lock_htdp=(HT, DP)) elif kind == "width-center": # ("width-center", W, (lsb,rsb), center_em) _, W, (lsb, rsb), center = pol norm_to_width(g, W, lsb, rsb, lock_center=center) elif kind == "both": _, HT, DP, W, (lsb, rsb) = pol norm_to_htdp_and_width(g, HT, DP, W, lsb, rsb) elif kind == "overlay0": norm_overlay_zero(g) else: raise ValueError(f"Unknown policy for {name}: {pol}") # Type 1 uses the explicit combgames-symbols encoding and needs no extra glyph. f.generate("combgames-symbols.pfb") # Temporary input for otftotfm: preserve pdfLaTeX spacing parameters. f.generate("combgames-symbols-metrics.otf") # fontspec probes a normal space when loading the OpenType font. # Add it after the symbol glyphs so their names, codepoints and outlines stay fixed. space = f.createChar(0x20, "space") space.width = 250 f.generate("combgames-symbols.otf") print("Wrote combgames-symbols.otf") # otftotfm computes the TeX metrics. Use our stable encoding filename in the # runtime map after checking that its glyph order matches the generated one. subprocess.run([ 'otftotfm', '--x-height=font', '-e', 'combgames-symbols.enc', '-n', 'combgames-symbols', '--map-file=combgames-symbols.map', 'combgames-symbols-metrics.otf' ], check=True) mapping = Path('combgames-symbols.map') text = mapping.read_text() match = re.search(r'<\[([^\s]+)', text) if not match: raise RuntimeError('No encoding file found in generated map') generated = Path(match.group(1)) def slots(text): text = re.sub(r'%[^\n]*', '', text) return re.findall(r'/([^\s\[\]]+)', text.split('[', 1)[1].split(']', 1)[0]) if slots(generated.read_text()) != slots(Path('combgames-symbols.enc').read_text()): raise RuntimeError('Generated encoding differs from the supplied encoding') mapping.write_text('combgames-symbols CombgamesSymbols ' '"CombgamesSymbolsEncoding ReEncodeFont" ' '<[combgames-symbols.enc