#!/usr/bin/env python3 """Generate a searchable preview page for installed Material Symbols fonts. Usage: symbols-preview.py [out.html] [--styles rounded,outlined,sharp] [--source url|local|embed] --source local (the default) uses local() only: nothing extra on disk, and all four axes apply, because ~/.config/fontconfig/conf.d/90-material-symbols-variable.conf re-scans the installed fonts with "variable: True" so Firefox stops treating the local() match as static. Without that fontconfig edit, Firefox maps only `wght`. local() matches the full font name (name ID 4) and PostScript name (ID 6), NOT the family name, which is why local("Material Symbols Rounded") silently fails. --source url writes one subsetted variable WOFF2 per style next to the HTML and loads them with url(). Webfonts apply all four axes regardless of the fontconfig flag, so this is the fallback if the advertising is ever removed. --source embed puts the subset in the page as a data URL: self-contained, ~2 MB/style. """ import argparse import base64 import json import os import subprocess import tempfile from fontTools import ttLib STYLES = ("rounded", "outlined", "sharp") FAMILY = {"rounded": "Material Symbols Rounded", "outlined": "Material Symbols Outlined", "sharp": "Material Symbols Sharp"} FEATURES = "rlig,rclt" def installed(): """Return {style: ttf path} for every installed Material Symbols style.""" out = subprocess.run(["fc-list", "--format", "%{file}|%{family}\n"], capture_output=True, text=True).stdout found = {} for line in out.splitlines(): path, _, families = line.partition("|") for style, family in FAMILY.items(): if style in found: continue if family in families and path.strip().endswith(".ttf"): found[style] = path.strip() return found def names_of(font): """Icon name -> codepoint, read from the font's rlig/rclt ligature lookups.""" cmap = {} for table in font["cmap"].tables: if table.isUnicode(): cmap.update(table.cmap) rev = {g: u for u, g in cmap.items()} names = {} for lookup in font["GSUB"].table.LookupList.Lookup: for sub in lookup.SubTable: root = sub.ExtSubTable if lookup.LookupType == 7 else sub ligatures = getattr(root, "ligatures", None) if not ligatures: continue for first, entries in ligatures.items(): for lig in entries: comps = (first,) + tuple(lig.Component) if any(c not in rev for c in comps): continue code = rev.get(lig.LigGlyph) if code is not None and code >= 0xE000: names["".join(chr(rev[c]) for c in comps)] = code return names, cmap, rev def local_names(font): """local() matches full font name (id 4) and PostScript name (id 6), not family.""" name = font["name"] def pick(name_id): for record in name.names: if record.nameID == name_id and record.platformID in (1, 3): return record.toUnicode() return None return [n for n in (pick(4), pick(6), pick(1)) if n] def axes_of(font): if "fvar" not in font: return {} return {a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in font["fvar"].axes} # display order; any axis not listed still gets a slider, appended after these AXIS_ORDER = ("FILL", "wght", "GRAD", "opsz") def axis_sliders(axes): """Return (slider markup, JS axis specs) driven by the font's own fvar table.""" order = [t for t in AXIS_ORDER if t in axes] + [t for t in axes if t not in AXIS_ORDER] html, js = [], {} for tag in order: lo, def_, hi = axes[tag] integral = lo.is_integer() and hi.is_integer() and def_.is_integer() # narrow-range axes (e.g. FILL 0..1) need fractional steps to be useful; # wide axes stay at whole-number steps step = 0.01 if hi - lo <= 2 else (1 if integral else 0.01) sid = tag.lower() html.append( f'
' ) js[sid] = {"tag": tag, "min": lo if integral else round(lo, 2), "max": hi if integral else round(hi, 2), "def": def_ if integral else round(def_, 2), "step": step} return "\n".join(html), js def subset(font_path, glyphs, out_path): tmp = tempfile.mkdtemp() glyphs_file = os.path.join(tmp, "glyphs.txt") open(glyphs_file, "w").write(",".join(glyphs)) subprocess.run( ["pyftsubset", font_path, f"--glyphs-file={glyphs_file}", f"--layout-features={FEATURES}", "--flavor=woff2", f"--output-file={out_path}"], check=True, ) return os.path.getsize(out_path) def all_glyphs(font, rev): keep = {g for g, u in rev.items() if chr(u).isalnum() or chr(u) == "_"} for lookup in font["GSUB"].table.LookupList.Lookup: for sub in lookup.SubTable: root = sub.ExtSubTable if lookup.LookupType == 7 else sub ligatures = getattr(root, "ligatures", None) if not ligatures: continue for first, entries in ligatures.items(): keep.add(first) for lig in entries: keep.add(lig.LigGlyph) keep.update(lig.Component) return sorted(keep) TEMPLATE = """