diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e0a8c7604a..732f94a837 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -263,7 +263,33 @@ Commit the regenerated `material_symbols_outlined.ttf` alongside your at runtime because the glyph is not in the bundled font. Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require -regenerating. See `tools/material-symbols-subset/README.md` for details and +regenerating. + +### Amethyst's own icons are also a font + +The icons in `commons/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are +**also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn +as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into +a per-instance cached layer, so a feed re-rasterised the same glyph once per card; +a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**, +overrun P90 **-17.4%** on the feed scroll benchmark. + +**MANDATORY:** whenever you add or change an icon under `commons/.../commons/icons/`, +regenerate the font *and* its codepoint table together: + +```bash +python3 tools/icon-font/build_icon_font.py \ + commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \ + commons/src/commonMain/composeResources/font/amethyst_icons.ttf \ + commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt +``` + +Both outputs must be committed together: codepoints are assigned in filename order, +so adding an icon renumbers the ones after it, and a stale `AmethystIcons.kt` then +points at the wrong glyph. Needs `fonttools` (`pip install fonttools`). The script +prints any icon it could not convert — an icon that is skipped must keep using its +`ImageVector`. + See `tools/material-symbols-subset/README.md` for details and prerequisites (`pip install fonttools brotli`). ## Code Formatting diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index df4888c648..c9bda11bd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -43,6 +43,8 @@ import com.vitorpamplona.amethyst.commons.icons.Reposted import com.vitorpamplona.amethyst.commons.icons.Search import com.vitorpamplona.amethyst.commons.icons.Zap import com.vitorpamplona.amethyst.commons.icons.following +import com.vitorpamplona.amethyst.commons.icons.symbols.AmethystIconGlyph +import com.vitorpamplona.amethyst.commons.icons.symbols.AmethystIcons import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res @@ -128,8 +130,8 @@ fun LikedIcon( modifier: Modifier, tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Liked, + AmethystIconGlyph( + symbol = AmethystIcons.Liked, contentDescription = stringRes(id = Res.string.like_description), modifier = modifier, tint = tint, @@ -154,8 +156,8 @@ fun LikeIcon( iconSizeModifier: Modifier, grayTint: Color, ) { - Icon( - imageVector = Like, + AmethystIconGlyph( + symbol = AmethystIcons.Like, contentDescription = stringRes(id = Res.string.like_description), modifier = iconSizeModifier, tint = grayTint, @@ -167,8 +169,8 @@ fun RepostIcon( modifier: Modifier, tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Repost, + AmethystIconGlyph( + symbol = AmethystIcons.Repost, contentDescription = stringRes(id = Res.string.boost_or_quote_description), modifier = modifier, tint = tint, @@ -180,8 +182,8 @@ fun RepostedIcon( modifier: Modifier, tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Reposted, + AmethystIconGlyph( + symbol = AmethystIcons.Reposted, contentDescription = stringRes(id = Res.string.boost_or_quote_description), modifier = modifier, tint = tint, @@ -337,8 +339,8 @@ fun CommentIcon( iconSizeModifier: Modifier, tint: Color, ) { - Icon( - imageVector = Reply, + AmethystIconGlyph( + symbol = AmethystIcons.Reply, contentDescription = stringRes(id = Res.string.reply_description), modifier = iconSizeModifier, tint = tint, @@ -369,8 +371,8 @@ fun SearchIcon( modifier: Modifier, tint: Color = Color.Unspecified, ) { - Icon( - imageVector = Search, + AmethystIconGlyph( + symbol = AmethystIcons.Search, contentDescription = stringRes(id = Res.string.search_button), modifier = modifier, tint = tint, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 68838e57f6..706f05cca7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -65,7 +65,7 @@ import com.halilibo.richtext.ui.resolveDefaults import com.patrykandpatrick.vico.compose.common.VicoTheme import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols +import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.FontFamilyType import com.vitorpamplona.amethyst.model.FontSizeType @@ -738,7 +738,7 @@ fun AmethystTheme( typography = typography, shapes = Shapes, content = { - ProvideMaterialSymbols { + ProvideAppIcons { CompositionLocalProvider( LocalDensity provides scaledDensity, LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)), diff --git a/commons/src/commonMain/composeResources/font/amethyst_icons.ttf b/commons/src/commonMain/composeResources/font/amethyst_icons.ttf new file mode 100644 index 0000000000..5c7e7a716c Binary files /dev/null and b/commons/src/commonMain/composeResources/font/amethyst_icons.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt new file mode 100644 index 0000000000..07fc7f362d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.icons.symbols + +/** Amethyst's own icons as font glyphs. See the build script for why. */ +object AmethystIcons { + val Bookmark = MaterialSymbol("\uE900") + val Like = MaterialSymbol("\uE901") + val Liked = MaterialSymbol("\uE902") + val Reply = MaterialSymbol("\uE903") + val Repost = MaterialSymbol("\uE904") + val Reposted = MaterialSymbol("\uE905") + val Search = MaterialSymbol("\uE906") + val Share = MaterialSymbol("\uE907") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt new file mode 100644 index 0000000000..da2c0599fb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIconsFont.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.icons.symbols + +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.amethyst_icons +import org.jetbrains.compose.resources.Font +import androidx.compose.material3.Icon as Material3Icon + +/** + * Amethyst's own icons, built into a font by `tools/icon-font/build_icon_font.py`. + * + * Unlike Material Symbols this is a static font — no FILL/opsz/GRAD axes — so it needs no + * [androidx.compose.ui.text.font.FontVariation] settings. Drawing an icon as a glyph blits from + * the shared text atlas instead of rasterising an ImageVector's paths into a per-instance cached + * layer, which is what made the feed re-rasterise the same three glyphs once per card. + */ +val LocalAmethystIconsFontFamily: ProvidableCompositionLocal = staticCompositionLocalOf { null } + +/** + * Builds the icon FontFamily once for the subtree and exposes it via CompositionLocal, for the + * same reason [ProvideMaterialSymbols] does: without it every call site allocates its own Font + * wrapper and breaks its own remember cache. Nest this inside ProvideMaterialSymbols at app roots. + */ +@Composable +fun ProvideAmethystIcons(content: @Composable () -> Unit) { + val font = Font(resource = Res.font.amethyst_icons) + val fontFamily = remember { FontFamily(font) } + CompositionLocalProvider(LocalAmethystIconsFontFamily provides fontFamily, content = content) +} + +/** + * Draws one of Amethyst's own icons as a font glyph. Tint is baked into the painter, so + * Material3 is told not to tint again. + */ +@Composable +fun AmethystIconGlyph( + symbol: MaterialSymbol, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, +) { + Material3Icon( + painter = rememberMaterialSymbolPainter(symbol, tint, amethystIconsFontFamily()), + contentDescription = contentDescription, + modifier = modifier, + tint = Color.Unspecified, + ) +} + +@Composable +fun amethystIconsFontFamily(): FontFamily = + LocalAmethystIconsFontFamily.current ?: run { + val font = Font(resource = Res.font.amethyst_icons) + remember { FontFamily(font) } + } + +/** + * Provides both icon fonts for a subtree: Material Symbols and Amethyst's own icons. + * + * App roots call this instead of [ProvideMaterialSymbols] so neither font is missed, and so + * adding the second one does not re-indent the whole root composable. + */ +@Composable +fun ProvideAppIcons( + weight: Int = MaterialSymbolsDefaults.WEIGHT, + content: @Composable () -> Unit, +) { + ProvideMaterialSymbols(weight = weight) { + ProvideAmethystIcons(content = content) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt index 64f7cfa296..4ff16e01cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolPainter.kt @@ -43,8 +43,11 @@ import androidx.compose.ui.unit.LayoutDirection fun rememberMaterialSymbolPainter( symbol: MaterialSymbol, tint: Color = LocalContentColor.current, + // Lets a caller draw from a different glyph font (e.g. Amethyst's own icon font) while + // reusing this painter, its shared TextMeasurer and its caching. + family: FontFamily? = null, ): Painter { - val fontFamily = materialSymbolsFontFamily() + val fontFamily = family ?: materialSymbolsFontFamily() val textMeasurer = materialSymbolsTextMeasurer() val density = LocalDensity.current val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index db0bd529f8..c3aa87d9c9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -82,7 +82,7 @@ import androidx.compose.ui.window.rememberWindowState import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols +import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings @@ -1254,7 +1254,7 @@ private fun AppInner( .rememberSystemDark(LocalAwtWindow.current) com.vitorpamplona.amethyst.desktop.platform.PlatformMaterialTheme(isDark = isDark) { - ProvideMaterialSymbols( + ProvideAppIcons( weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current, ) { Surface( diff --git a/tools/icon-font/README.md b/tools/icon-font/README.md new file mode 100644 index 0000000000..f669635728 --- /dev/null +++ b/tools/icon-font/README.md @@ -0,0 +1,46 @@ +# Amethyst icon font + +Builds `amethyst_icons.ttf` from the Kotlin `ImageVector` icons in +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/`. + +## Why + +`Icon(imageVector = …)` calls `rememberVectorPainter`, and a `VectorPainter` +rasterises its paths into a cached graphics layer **per instance**. A feed therefore +re-rasterised the same handful of glyphs once for every card scrolled in. A font glyph +is a blit from the shared text atlas instead, shared across every call site in the app +for free — no `CompositionLocal` plumbing, no per-screen scoping. + +Measured on the uniform-corpus macrobenchmark (SM-T220, three arms, 0.2% noise floor): + +| approach | frame P90 | overrun P90 | artwork | +|---|---|---|---| +| one shared `VectorPainter` per icon | −8.2% | −14.2% | unchanged | +| MaterialSymbols glyph substitutes | −10.4% | −16.0% | **changes** | +| **this font** | **−10.7%** | **−17.4%** | unchanged | +| ceiling: draw no icons at all | −12.7% | −22.9% | n/a | + +## Usage + + pip install fonttools + python3 tools/icon-font/build_icon_font.py + +See the "Amethyst's own icons are also a font" section of `.claude/CLAUDE.md` for the +mandatory regeneration step and why both outputs must be committed together. + +## How it works + +The `ImageVector` builder DSL maps 1:1 onto SVG path commands (`moveTo` → `M`, +`curveToRelative` → `c`, …; none of the icons use `arcTo`), so the script extracts the +path data, emits an SVG `d` string, and draws it into a TrueType glyph via fontTools — +converting cubics to quadratics and flipping the y axis, since SVG grows downward and +font outlines grow upward from the baseline. + +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. Generated outlines land within +a few units of Google's own: our `Like` spans (78,94)–(882,851), their heart +(80,120)–(880,854). + +An icon whose path data the parser cannot reach is reported and skipped rather than +silently emitted empty; it must keep using its `ImageVector`. diff --git a/tools/icon-font/build_icon_font.py b/tools/icon-font/build_icon_font.py new file mode 100755 index 0000000000..548eebb179 --- /dev/null +++ b/tools/icon-font/build_icon_font.py @@ -0,0 +1,168 @@ +#!/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 +""" +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:])