Files
amethyst/tools/icon-font/build_icon_font.py
T
Vitor PamplonaandClaude Opus 5 f54194f70d perf(icons): draw Amethyst's own icons from a generated icon font
Icon(imageVector = …) calls rememberVectorPainter, and a VectorPainter rasterises
its paths into a cached graphics layer per instance, so the feed re-rasterised the
same glyphs once per card. A font glyph is a blit from the shared text atlas,
shared across every call site for free.

tools/icon-font/build_icon_font.py converts the Kotlin ImageVector DSL to SVG paths
and builds a TTF with fontTools. Font metrics mirror the bundled Material Symbols
font (upem 960, ascent 1056, descent -96, advance 960) so glyphs align with existing
call sites; generated outlines land within a few units of Google's own.

Measured on the uniform-corpus feed benchmark (SM-T220, three arms A/B/A, 0.2%
identical-arm noise floor, gate 18/18/18 cards):

  frame duration P90   -10.7%
  frame overrun  P90   -17.4%
  DrawReactions        114.8 -> 76.7 ms/iteration

For reference, ablating the reaction icons entirely gives frame P90 -13.5%, so this
captures ~84% of the available headroom. It supersedes the shared-VectorPainter
approach (-8.2%), which needed CompositionLocal plumbing and hand-scoping to avoid
cross-size cache thrashing; glyphs are atlas-shared automatically.

Artwork is unchanged: this converts Amethyst's existing vectors rather than
substituting Google's glyphs. Verified on device by pixel comparison -- unconverted
icons are 0-diff, and the converted ones differ only by sub-pixel antialiasing
between the text and vector rasterisers.

Stroked icons are deliberately NOT converted. A glyph outline can only be filled, so
converting Zap (strokeLineWidth 1.2) turned a thin outline bolt into a solid one; the
build script now detects a stroke and skips the icon, leaving Following, Zap and
ZapSplit on their ImageVectors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 10:21:59 -04:00

169 lines
6.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build a custom icon font from Amethyst's Kotlin ImageVector icons.
Why: drawing an ImageVector rasterises its paths into a per-instance cached layer,
so a feed re-rasterises the same glyph once per card. A font glyph is a blit from
the shared text atlas instead. Measured on the uniform-corpus macrobenchmark
(SM-T220, 0.2% noise floor): swapping the three reaction icons to font glyphs gave
frame P90 -10.4% vs -8.2% for per-icon shared painters, against a -12.7% ceiling.
This keeps Amethyst's own artwork -- it converts the existing ImageVector path data
rather than substituting Google's glyphs, so the icons look identical.
Font metrics deliberately mirror the bundled material_symbols_outlined.ttf
(unitsPerEm 960, ascent 1056, descent -96, advance 960) so the glyphs align with
existing MaterialSymbols call sites and Icon() sizing.
Usage: build_icon_font.py <icons-dir> <out.ttf> <out.kt>
"""
import re, sys, os
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.pens.cu2quPen import Cu2QuPen
from fontTools.pens.transformPen import TransformPen
from fontTools.misc.transform import Transform
from fontTools.svgLib.path.parser import parse_path
UPEM, ASCENT, DESCENT, ADVANCE = 960, 1056, -96, 960
FIRST_CODEPOINT = 0xE900
MAX_ERR = 1.0 # cubic->quadratic tolerance, in font units
# ImageVector DSL -> SVG path command. No arcTo: none of the icons use one.
CMDS = {
"moveTo": "M", "moveToRelative": "m",
"lineTo": "L", "lineToRelative": "l",
"horizontalLineTo": "H", "horizontalLineToRelative": "h",
"verticalLineTo": "V", "verticalLineToRelative": "v",
"curveTo": "C", "curveToRelative": "c",
"reflectiveCurveTo": "S", "reflectiveCurveToRelative": "s",
"quadTo": "Q", "quadToRelative": "q",
"reflectiveQuadTo": "T", "reflectiveQuadToRelative": "t",
"close": "Z",
}
CALL_RE = re.compile(r"\b(" + "|".join(CMDS) + r")\(([^()]*)\)")
NUM_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
STROKE_W_RE = re.compile(r"strokeLineWidth\s*=\s*([\d.]+)f?")
def is_stroked(src: str) -> bool:
"""True if the icon paints a stroke, not just a fill.
A glyph outline is filled: there is no pen width in a TrueType glyph. Converting a
stroked icon would emit only its fill and silently change the artwork -- Zap is drawn
as a thin outline (strokeLineWidth 1.2) and came out as a solid bolt. Such icons must
keep their ImageVector.
"""
if any(float(w) > 0 for w in STROKE_W_RE.findall(src)):
return True
return "stroke = SolidColor" in src
def kotlin_to_svg_path(src: str):
"""Extract viewport and an SVG 'd' string from one ImageVector .kt file."""
vw = re.search(r"viewportWidth\s*=\s*([\d.]+)f?", src)
vh = re.search(r"viewportHeight\s*=\s*([\d.]+)f?", src)
if vw and vh:
viewport = (float(vw.group(1)), float(vh.group(1)))
elif "materialIcon(" in src:
# materialIcon() sets the viewport itself; Material's convention is 24x24.
viewport = (24.0, 24.0)
else:
return None, None
# Only look inside the vector builder, never the @Preview composable above it.
# Only look inside the vector builder, never the @Preview composable above it, and never
# a helper like materialOutlinedPath() declared after it.
start = src.find(".apply {")
if start == -1:
start = src.find("materialIcon(")
body = src[start:] if start != -1 else src
end = body.find("\ninline fun ")
if end != -1:
body = body[:end]
parts = []
for m in CALL_RE.finditer(body):
cmd, args = m.group(1), m.group(2)
letter = CMDS[cmd]
if cmd == "close":
parts.append("Z")
continue
nums = NUM_RE.findall(args)
if not nums:
continue
parts.append(letter + " " + " ".join(nums))
return viewport, " ".join(parts)
def build_glyph(d: str, viewport):
vw, vh = viewport
# Uniform scale on the larger axis keeps non-square viewports undistorted.
s = UPEM / max(vw, vh)
pen = TTGlyphPen(None)
# y flips: SVG grows downward, font outlines grow upward from the baseline.
tp = TransformPen(Cu2QuPen(pen, MAX_ERR), Transform(s, 0, 0, -s, 0, vh * s))
parse_path(d, tp)
return pen.glyph()
def main(icons_dir, out_ttf, out_kt):
files = sorted(f for f in os.listdir(icons_dir) if f.endswith(".kt"))
glyphs, cmap, names, skipped = {".notdef": TTGlyphPen(None).glyph()}, {}, [".notdef"], []
cp = FIRST_CODEPOINT
for fn in files:
name = fn[:-3]
src = open(os.path.join(icons_dir, fn), encoding="utf-8").read()
if is_stroked(src):
skipped.append((name, "draws a stroke; a glyph can only be filled"))
continue
viewport, d = kotlin_to_svg_path(src)
if not d:
skipped.append((name, "no path data"))
continue
try:
glyphs[name] = build_glyph(d, viewport)
except Exception as e: # noqa: BLE001 - report and continue, don't kill the build
skipped.append((name, f"{type(e).__name__}: {e}"))
continue
cmap[cp] = name
names.append(name)
print(f" {name:<12} U+{cp:04X} viewport {viewport[0]:g}x{viewport[1]:g} {len(d)} chars")
cp += 1
fb = FontBuilder(UPEM, isTTF=True)
fb.setupGlyphOrder(names)
fb.setupCharacterMap(cmap)
fb.setupGlyf(glyphs)
fb.setupHorizontalMetrics({n: (ADVANCE, 0) for n in names})
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
fb.setupNameTable({
"familyName": "Amethyst Icons", "styleName": "Regular",
"psName": "AmethystIcons-Regular", "version": "1.0",
})
fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=DESCENT,
usWinAscent=ASCENT, usWinDescent=abs(DESCENT))
fb.setupPost(keepGlyphNames=False)
fb.save(out_ttf)
with open(out_kt, "w", encoding="utf-8") as fh:
fh.write("// GENERATED by tools/icon-font/build_icon_font.py -- do not edit by hand.\n")
fh.write("package com.vitorpamplona.amethyst.commons.icons.symbols\n\n")
fh.write("/** Amethyst's own icons as font glyphs. See the build script for why. */\n")
fh.write("object AmethystIcons {\n")
for code, name in sorted(cmap.items()):
fh.write(f' val {name} = MaterialSymbol("\\u{code:04X}")\n')
fh.write("}\n")
print(f"\nwrote {out_ttf} ({os.path.getsize(out_ttf)} bytes), {len(cmap)} glyphs")
for name, why in skipped:
print(f" SKIPPED {name}: {why}")
if __name__ == "__main__":
if len(sys.argv) != 4:
sys.exit(__doc__)
main(*sys.argv[1:])