C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 11s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 12s
JS/TS / lint (pull_request) Successful in 10s
Python / static (pull_request) Failing after 27s
Rust / build (pull_request) Successful in 54s
Python / verify (pull_request) Failing after 1m26s
Rust / fmt (pull_request) Successful in 46s
Rust / clippy (pull_request) Successful in 1m6s
599 lines
24 KiB
Python
599 lines
24 KiB
Python
#!/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' <div class="slider"><span>{tag}</span>'
|
||
f'<input id="{sid}" type="range"><output id="{sid}v"></output></div>'
|
||
)
|
||
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 = """<!doctype html>
|
||
<html lang="en" data-theme="dark">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Material Symbols — {count} icons</title>
|
||
<style>
|
||
{font_faces}
|
||
:root {{
|
||
--pad-max: 28px;
|
||
--cell-min: 112px;
|
||
--gap: 8px;
|
||
--cols-min: 4;
|
||
--grid-min: calc(var(--cols-min) * (var(--cell-min) + var(--gap)) - var(--gap));
|
||
--sidebar: 260px;
|
||
/* padding shrinks with the available width and reaches 0 exactly where reflow starts.
|
||
100% resolves against .grid/.top's containing block (main), which already excludes
|
||
the sidebar, so the sidebar is not subtracted here. */
|
||
--pad: clamp(0px, calc((100% - var(--grid-min)) / 2), var(--pad-max));
|
||
--sym: "Sym-Rounded";
|
||
--bg: #121214; --panel: #1a1a1e; --line: #2b2b31; --fg: #ececec; --dim: #a2a2ac;
|
||
--accent: #8ab4f8; --hover: #222228;
|
||
}}
|
||
html[data-theme="light"] {{
|
||
--bg: #fafafa; --panel: #ffffff; --line: #e2e2e6; --fg: #1c1c1f; --dim: #6a6a72;
|
||
--accent: #1a73e8; --hover: #f0f0f3;
|
||
}}
|
||
* {{ box-sizing: border-box; }}
|
||
body {{ margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, sans-serif; }}
|
||
|
||
/* sidebar */
|
||
aside {{
|
||
position: fixed; inset: 0 auto 0 0; width: 260px; background: var(--panel);
|
||
border-right: 1px solid var(--line); padding: 18px 16px 24px; overflow-y: auto; z-index: 20;
|
||
transition: transform .22s ease;
|
||
}}
|
||
aside h1 {{ font-size: 15px; margin: 0 0 2px; }}
|
||
aside .sub {{ font-size: 12px; color: var(--dim); margin: 0 0 18px; }}
|
||
.group {{ margin: 0 0 20px; }}
|
||
.group > label {{ display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
|
||
color: var(--dim); margin: 0 0 8px; }}
|
||
.seg {{ display: flex; gap: 4px; background: var(--bg); border: 1px solid var(--line);
|
||
border-radius: 10px; padding: 4px; }}
|
||
.seg button {{ flex: 1; padding: 7px 0; border: 0; border-radius: 7px; background: transparent;
|
||
color: var(--dim); font: inherit; font-size: 13px; cursor: pointer; }}
|
||
.seg button[aria-pressed="true"] {{ background: var(--accent); color: #fff; }}
|
||
html[data-theme="light"] .seg button[aria-pressed="true"] {{ color: #fff; }}
|
||
.slider {{ display: grid; grid-template-columns: 46px 1fr 42px; align-items: center; gap: 10px;
|
||
margin-bottom: 12px; font-size: 12px; color: var(--dim); }}
|
||
.slider output {{ text-align: right; color: var(--fg); font-variant-numeric: tabular-nums; }}
|
||
input[type=range] {{ accent-color: var(--accent); width: 100%; }}
|
||
.row {{ display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||
font-size: 12px; color: var(--dim); margin-bottom: 10px; }}
|
||
.ghost {{ padding: 7px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
|
||
color: var(--fg); font: inherit; font-size: 13px; cursor: pointer; }}
|
||
.ghost:hover {{ background: var(--hover); }}
|
||
|
||
/* hide sidebar on narrow viewports, reveal via toggle */
|
||
#menu {{ display: none; }}
|
||
@media (max-width: 900px) {{
|
||
:root {{ --sidebar: 0px; }}
|
||
aside {{ transform: translateX(-100%); box-shadow: 0 0 40px rgba(0,0,0,.35); }}
|
||
body.nav-open aside {{ transform: none; }}
|
||
#menu {{ display: inline-block; }}
|
||
}}
|
||
|
||
/* content */
|
||
main {{ margin-left: var(--sidebar); }}
|
||
.top {{ position: sticky; top: 0; z-index: 10; background: var(--bg);
|
||
padding: 14px var(--pad);
|
||
display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }}
|
||
.top input {{ flex: 1; min-width: 0; padding: 9px 12px; background: var(--panel); color: var(--fg);
|
||
border: 1px solid var(--line); border-radius: 9px; font: inherit; }}
|
||
.count {{ font-size: 12px; color: var(--dim); white-space: nowrap; }}
|
||
.pager {{ display: flex; align-items: center; gap: 6px; }}
|
||
.pager.bottom {{ justify-content: center; padding: 6px var(--pad) 30px; }}
|
||
.page {{ font-size: 12px; color: var(--dim); min-width: 64px; text-align: center;
|
||
font-variant-numeric: tabular-nums; }}
|
||
.ghost:disabled {{ opacity: .35; cursor: default; }}
|
||
|
||
/* padding shrinks with the viewport until it hits 0; only then does the grid reflow */
|
||
.grid {{ display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fill, minmax(var(--cell-min), 1fr));
|
||
padding: 16px var(--pad) 28px; }}
|
||
.cell {{ display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px 4px;
|
||
border: 1px solid var(--line); border-radius: 12px; background: var(--panel); cursor: pointer;
|
||
transition: border-color .15s, background .15s; }}
|
||
.cell:hover {{ border-color: var(--accent); background: var(--hover); }}
|
||
.icon {{ font-family: var(--sym); font-feature-settings: "rlig" 1, "rclt" 1; line-height: 1; }}
|
||
.name {{ font-size: 11px; color: var(--dim); text-align: center; word-break: break-all; }}
|
||
.cp {{ font-size: 10px; color: var(--accent); opacity: .8; display: none; }}
|
||
html[data-cp="1"] .cp {{ display: block; }}
|
||
.cell.miss {{ opacity: .3; cursor: default; }}
|
||
#toast {{ position: fixed; right: 16px; bottom: 16px; background: var(--panel); color: var(--fg);
|
||
border: 1px solid var(--line); border-radius: 9px; padding: 8px 12px; font-size: 12px;
|
||
opacity: 0; transition: opacity .2s; pointer-events: none; }}
|
||
#toast.show {{ opacity: 1; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<aside>
|
||
<h1>Material Symbols</h1>
|
||
<p class="sub">{count} icons · styles: {style_list}</p>
|
||
|
||
<div class="group">
|
||
<label>Style</label>
|
||
<div class="seg" id="styles">{style_buttons}</div>
|
||
</div>
|
||
|
||
<div class="group">
|
||
<label>Variable axes</label>
|
||
{axis_sliders}
|
||
</div>
|
||
|
||
<div class="group">
|
||
<label>Layout</label>
|
||
<div class="slider"><span>size</span><input id="size" type="range"><output id="sizev"></output></div>
|
||
<div class="slider"><span>cell</span><input id="cell" type="range"><output id="cellv"></output></div>
|
||
<div class="slider"><span>per pg</span><input id="per" type="range"><output id="perv"></output></div>
|
||
</div>
|
||
|
||
<div class="group">
|
||
<label>Options</label>
|
||
<div class="row"><span>Show codepoints</span><button class="ghost" id="cp">off</button></div>
|
||
<div class="row"><span>Theme</span><button class="ghost" id="theme">dark</button></div>
|
||
</div>
|
||
|
||
<button class="ghost" id="reset">Reset</button>
|
||
</aside>
|
||
|
||
<main>
|
||
<div class="top">
|
||
<button class="ghost" id="menu">Controls</button>
|
||
<input id="q" type="search" placeholder="Search icon names…" autocomplete="off">
|
||
<span class="count" id="count"></span>
|
||
<div class="pager">
|
||
<button class="ghost" id="prev">‹</button>
|
||
<span class="page" id="page"></span>
|
||
<button class="ghost" id="next">›</button>
|
||
</div>
|
||
</div>
|
||
<div class="grid" id="grid"></div>
|
||
<div class="pager bottom">
|
||
<button class="ghost" id="prev2">‹ Prev</button>
|
||
<span class="page" id="page2"></span>
|
||
<button class="ghost" id="next2">Next ›</button>
|
||
</div>
|
||
</main>
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
const ICONS = {icons};
|
||
const STYLES = {styles};
|
||
const AXES = {axes};
|
||
const el = id => document.getElementById(id);
|
||
const val = id => el(id).value;
|
||
const grid = el("grid");
|
||
const toast = el("toast");
|
||
|
||
const SLIDERS = Object.assign({{}}, AXES, {{
|
||
size: {{ min: 16, max: 64, step: 1, def: 24 }},
|
||
cell: {{ min: 70, max: 200, step: 1, def: 112 }},
|
||
per: {{ min: 24, max: 600, step: 24, def: 120 }},
|
||
}});
|
||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||
|
||
// remember options across reloads: sliders, style, theme, codepoint toggle
|
||
const STATE_KEY = "symbols-preview-v1";
|
||
let saved = {{}};
|
||
try {{ saved = JSON.parse(localStorage.getItem(STATE_KEY)) || {{}}; }} catch (e) {{}}
|
||
function save() {{
|
||
const st = {{ style: styleKey, theme: document.documentElement.dataset.theme,
|
||
cp: document.documentElement.dataset.cp }};
|
||
for (const id of Object.keys(SLIDERS)) st[id] = val(id);
|
||
try {{ localStorage.setItem(STATE_KEY, JSON.stringify(st)); }} catch (e) {{}}
|
||
}}
|
||
|
||
for (const [id, s] of Object.entries(SLIDERS)) {{
|
||
const e = el(id);
|
||
e.min = s.min; e.max = s.max; e.step = s.step;
|
||
e.value = clamp(Number(saved[id] ?? s.def), s.min, s.max);
|
||
}}
|
||
|
||
// cell objects exist for every icon, but only the current page is ever in the DOM
|
||
const cells = ICONS.map(([name, cp]) => {{
|
||
const cell = document.createElement("div");
|
||
cell.className = "cell";
|
||
const icon = document.createElement("div");
|
||
icon.className = "icon";
|
||
icon.textContent = name;
|
||
const label = document.createElement("div");
|
||
label.className = "name";
|
||
label.textContent = name;
|
||
const hex = document.createElement("div");
|
||
hex.className = "cp";
|
||
hex.textContent = cp;
|
||
cell.append(icon, label, hex);
|
||
cell.title = name + " " + cp;
|
||
cell.onclick = async () => {{
|
||
if (cell.classList.contains("miss")) return;
|
||
await navigator.clipboard.writeText(name);
|
||
toast.textContent = "copied " + name;
|
||
toast.classList.add("show");
|
||
setTimeout(() => toast.classList.remove("show"), 1400);
|
||
}};
|
||
return cell;
|
||
}});
|
||
|
||
let styleKey = "{default_style}";
|
||
let haveSet = null;
|
||
let filtered = [];
|
||
let page = 0;
|
||
let rendered = [];
|
||
|
||
function settings() {{
|
||
// Axis tags MUST be quoted: Firefox drops unquoted forms like `FILL 1` as
|
||
// invalid (computed = "normal"); only `"FILL" 1` is accepted.
|
||
return Object.keys(AXES)
|
||
.map(id => `"${{AXES[id].tag}}" ${{val(id)}}`).join(", ");
|
||
}}
|
||
function paint() {{
|
||
// only the rendered page needs variation settings — applying to all 4k+ cells
|
||
// is what made slider drags stutter
|
||
const vs = settings();
|
||
const px = val("size");
|
||
for (const cell of rendered) {{
|
||
const icon = cell.firstElementChild;
|
||
icon.style.fontVariationSettings = vs;
|
||
icon.style.fontSize = px + "px";
|
||
}}
|
||
}}
|
||
function apply() {{
|
||
paint();
|
||
for (const id of Object.keys(SLIDERS)) el(id + "v").textContent = val(id);
|
||
document.documentElement.style.setProperty("--cell-min", val("cell") + "px");
|
||
}}
|
||
|
||
// Every token must appear somewhere in the name, so "edit notif" matches
|
||
// edit_notifications. Hyphens are treated as underscores. Results are ranked:
|
||
// name prefix > word-boundary prefix > substring, then shorter names first.
|
||
function score(name, tokens) {{
|
||
let total = 0;
|
||
for (const t of tokens) {{
|
||
let s = 0;
|
||
if (name.startsWith(t)) s = 8;
|
||
else if (name.split("_").some(w => w.startsWith(t))) s = 5;
|
||
else if (name.includes(t)) s = 2;
|
||
else return -1;
|
||
total += s - name.length / 1000;
|
||
}}
|
||
return total;
|
||
}}
|
||
function filter() {{
|
||
const q = val("q").trim().toLowerCase().replace(/-/g, "_");
|
||
const tokens = q ? q.split(/[_\\s]+/).filter(Boolean) : [];
|
||
const hits = [];
|
||
for (let i = 0; i < ICONS.length; i++) {{
|
||
const s = tokens.length ? score(ICONS[i][0], tokens) : 0;
|
||
if (s >= 0) hits.push([i, s]);
|
||
}}
|
||
if (tokens.length)
|
||
hits.sort((a, b) => b[1] - a[1] || ICONS[a[0]][0].length - ICONS[b[0]][0].length);
|
||
filtered = hits.map(h => h[0]);
|
||
}}
|
||
|
||
function pages() {{ return Math.max(1, Math.ceil(filtered.length / Number(val("per")))); }}
|
||
function render() {{
|
||
page = clamp(page, 0, pages() - 1);
|
||
const n = Number(val("per"));
|
||
const start = page * n;
|
||
const frag = document.createDocumentFragment();
|
||
rendered = [];
|
||
for (const i of filtered.slice(start, start + n)) {{
|
||
const cell = cells[i];
|
||
cell.classList.toggle("miss", haveSet !== null && !haveSet.has(ICONS[i][0]));
|
||
frag.append(cell);
|
||
rendered.push(cell);
|
||
}}
|
||
grid.replaceChildren(frag);
|
||
paint();
|
||
const m = filtered.length;
|
||
el("count").textContent = m ? (start + 1) + "-" + Math.min(start + n, m) + " of " + m
|
||
: "0 of " + ICONS.length;
|
||
const label = (page + 1) + " / " + pages();
|
||
el("page").textContent = label;
|
||
el("page2").textContent = label;
|
||
el("prev").disabled = el("prev2").disabled = page <= 0;
|
||
el("next").disabled = el("next2").disabled = page >= pages() - 1;
|
||
}}
|
||
function go(delta) {{ page += delta; render(); }}
|
||
|
||
function setStyle(key, persist = true) {{
|
||
styleKey = key;
|
||
document.documentElement.style.setProperty("--sym", STYLES[key].family);
|
||
haveSet = STYLES[key].names ? new Set(STYLES[key].names) : null;
|
||
document.querySelectorAll("#styles button").forEach(b =>
|
||
b.setAttribute("aria-pressed", String(b.dataset.style === key)));
|
||
render();
|
||
if (persist) save();
|
||
}}
|
||
|
||
for (const id of Object.keys(SLIDERS)) el(id).addEventListener("input", () => {{
|
||
if (id === "per") page = 0;
|
||
apply(); render(); save();
|
||
}});
|
||
let timer = null;
|
||
el("q").addEventListener("input", () => {{
|
||
clearTimeout(timer);
|
||
timer = setTimeout(() => {{ filter(); page = 0; render(); }}, 120);
|
||
}});
|
||
el("styles").addEventListener("click", e => {{
|
||
if (e.target.dataset.style) setStyle(e.target.dataset.style);
|
||
}});
|
||
el("cp").onclick = e => {{
|
||
const on = document.documentElement.dataset.cp === "1";
|
||
document.documentElement.dataset.cp = on ? "0" : "1";
|
||
e.target.textContent = on ? "off" : "on";
|
||
save();
|
||
}};
|
||
el("theme").onclick = e => {{
|
||
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||
document.documentElement.dataset.theme = next;
|
||
e.target.textContent = next;
|
||
save();
|
||
}};
|
||
el("menu").onclick = () => document.body.classList.toggle("nav-open");
|
||
for (const id of ["prev", "prev2"]) el(id).onclick = () => go(-1);
|
||
for (const id of ["next", "next2"]) el(id).onclick = () => go(1);
|
||
el("reset").onclick = () => {{
|
||
try {{ localStorage.removeItem(STATE_KEY); }} catch (e) {{}}
|
||
for (const [id, s] of Object.entries(SLIDERS)) el(id).value = s.def;
|
||
document.documentElement.dataset.theme = "dark";
|
||
el("theme").textContent = "dark";
|
||
document.documentElement.dataset.cp = "0";
|
||
el("cp").textContent = "off";
|
||
page = 0;
|
||
apply(); filter(); setStyle("{default_style}", false);
|
||
}};
|
||
document.addEventListener("keydown", e => {{
|
||
if (e.key === "/" && document.activeElement !== el("q")) {{
|
||
e.preventDefault(); el("q").focus(); return;
|
||
}}
|
||
if (document.activeElement && document.activeElement.tagName === "INPUT") return;
|
||
if (e.key === "ArrowLeft") {{ e.preventDefault(); go(-1); }}
|
||
if (e.key === "ArrowRight") {{ e.preventDefault(); go(1); }}
|
||
}});
|
||
|
||
// init from saved state
|
||
document.documentElement.dataset.theme = saved.theme === "light" ? "light" : "dark";
|
||
el("theme").textContent = document.documentElement.dataset.theme;
|
||
document.documentElement.dataset.cp = saved.cp === "1" ? "1" : "0";
|
||
el("cp").textContent = document.documentElement.dataset.cp === "1" ? "on" : "off";
|
||
apply(); filter();
|
||
setStyle(saved.style && STYLES[saved.style] ? saved.style : "{default_style}", false);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def build(styles, source="local", out_path="/tmp/opencode/symbols-preview.html"):
|
||
"""source: local | url | embed
|
||
|
||
local() is the default: with the fontconfig advertisement in place
|
||
(conf.d/90-material-symbols-variable.conf), Firefox treats the local() match
|
||
as variable and applies all four axes. url()/embed remain as fallbacks for
|
||
machines without that config.
|
||
"""
|
||
fonts = {}
|
||
for key in styles:
|
||
fonts[key] = ttLib.TTFont(installed()[key])
|
||
|
||
# name -> codepoint, from the first style; other styles checked for coverage
|
||
primary_key = styles[0]
|
||
primary_names = names_of(fonts[primary_key])[0]
|
||
per_style = {key: names_of(fonts[key])[0] for key in styles}
|
||
icons = sorted(primary_names.items())
|
||
|
||
outdir = os.path.dirname(os.path.abspath(out_path)) or "."
|
||
faces, style_js, written = [], {}, []
|
||
for key in styles:
|
||
font = fonts[key]
|
||
locs = ", ".join(f'local("{n}")' for n in local_names(font))
|
||
parts = []
|
||
if source in ("url", "embed"):
|
||
cmap_data = names_of(font)
|
||
if source == "url":
|
||
fname = f"symbols-{key}.woff2"
|
||
dest = os.path.join(outdir, fname)
|
||
written.append((fname, subset(installed()[key], all_glyphs(font, cmap_data[2]), dest)))
|
||
# url() first: applies all axes without needing the fontconfig advertisement
|
||
parts.append(f'url("{fname}") format("woff2")')
|
||
parts.append(locs)
|
||
else:
|
||
tmp = os.path.join(tempfile.mkdtemp(), f"{key}.woff2")
|
||
size = subset(installed()[key], all_glyphs(font, cmap_data[2]), tmp)
|
||
data = base64.b64encode(open(tmp, "rb").read()).decode()
|
||
written.append((f"(embedded {key})", size))
|
||
parts.append(f'url(data:font/woff2;base64,{data}) format("woff2")')
|
||
else:
|
||
parts.append(locs)
|
||
faces.append(f' @font-face {{ font-family: "Sym-{key.title()}"; src: {", ".join(parts)}; }}')
|
||
same = set(per_style[key]) == set(primary_names)
|
||
style_js[key] = {"family": f"Sym-{key.title()}", "names": None if same else sorted(per_style[key])}
|
||
axes = axes_of(fonts[primary_key])
|
||
slider_html, axis_js = axis_sliders(axes)
|
||
|
||
buttons = "".join(
|
||
f'<button data-style="{key}" aria-pressed="false">{FAMILY[key].replace("Material Symbols ", "")}</button>'
|
||
for key in styles
|
||
)
|
||
html = TEMPLATE.format(
|
||
font_faces="\n".join(faces),
|
||
count=len(icons),
|
||
style_list=", ".join(FAMILY[k] for k in styles),
|
||
style_buttons=buttons,
|
||
axis_sliders=slider_html,
|
||
default_style=primary_key,
|
||
icons=json.dumps([[n, f"{hex(c)}"] for n, c in icons]),
|
||
styles=json.dumps(style_js),
|
||
axes=json.dumps(axis_js),
|
||
)
|
||
open(out_path, "w").write(html)
|
||
counts = {k: (len(icons) if v["names"] is None else len(v["names"])) for k, v in style_js.items()}
|
||
return out_path, len(html), counts, written
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||
ap.add_argument("out", nargs="?", default="/tmp/opencode/symbols-preview.html")
|
||
ap.add_argument("--styles", default=",".join(STYLES))
|
||
ap.add_argument("--source", choices=("local", "url", "embed"), default="local",
|
||
help="local = system font via local() (all axes work with the fontconfig "
|
||
"advertisement); url = sibling woff2 subsets (all axes work anywhere); "
|
||
"embed = self-contained but multi-MB page")
|
||
args = ap.parse_args()
|
||
want = [s for s in args.styles.split(",") if s in STYLES]
|
||
have = installed()
|
||
styles = [s for s in want if s in have]
|
||
if not styles:
|
||
raise SystemExit("no installed Material Symbols style found")
|
||
for s in want:
|
||
if s not in have:
|
||
print(f" skipping {s}: not installed")
|
||
path, size, counts, written = build(styles, source=args.source, out_path=args.out)
|
||
print(f"{path}: {size} bytes, source={args.source}")
|
||
print(" styles:", ", ".join(f"{k} ({v} icons)" for k, v in counts.items()))
|
||
for name, bytes_ in written:
|
||
print(f" wrote {name}: {bytes_} bytes")
|
||
for s in styles:
|
||
print(f" {FAMILY[s]}: {installed()[s]}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|