feat: better notification storage and image caching + blobs
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
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
This commit is contained in:
@@ -0,0 +1,598 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user