mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4043 from vitorpamplona/perf/amethyst-icon-font
perf(icons): draw Amethyst's own icons from a generated icon font
This commit is contained in:
+27
-1
@@ -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
|
||||
|
||||
@@ -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.ui.stringRes
|
||||
@@ -109,8 +111,8 @@ fun LikedIcon(
|
||||
modifier: Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Liked,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Liked,
|
||||
contentDescription = stringRes(id = R.string.like_description),
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
@@ -135,8 +137,8 @@ fun LikeIcon(
|
||||
iconSizeModifier: Modifier,
|
||||
grayTint: Color,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Like,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Like,
|
||||
contentDescription = stringRes(id = R.string.like_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = grayTint,
|
||||
@@ -148,8 +150,8 @@ fun RepostIcon(
|
||||
modifier: Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Repost,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Repost,
|
||||
contentDescription = stringRes(id = R.string.boost_or_quote_description),
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
@@ -161,8 +163,8 @@ fun RepostedIcon(
|
||||
modifier: Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Reposted,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Reposted,
|
||||
contentDescription = stringRes(id = R.string.boost_or_quote_description),
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
@@ -318,8 +320,8 @@ fun CommentIcon(
|
||||
iconSizeModifier: Modifier,
|
||||
tint: Color,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Reply,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Reply,
|
||||
contentDescription = stringRes(id = R.string.reply_description),
|
||||
modifier = iconSizeModifier,
|
||||
tint = tint,
|
||||
@@ -350,8 +352,8 @@ fun SearchIcon(
|
||||
modifier: Modifier,
|
||||
tint: Color = Color.Unspecified,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Search,
|
||||
AmethystIconGlyph(
|
||||
symbol = AmethystIcons.Search,
|
||||
contentDescription = stringRes(id = R.string.search_button),
|
||||
modifier = modifier,
|
||||
tint = tint,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Binary file not shown.
+33
@@ -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")
|
||||
}
|
||||
+99
@@ -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<FontFamily?> = 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)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 <icons-dir> <out.ttf> <out.kt>
|
||||
|
||||
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`.
|
||||
Executable
+168
@@ -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 <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:])
|
||||
Reference in New Issue
Block a user