Changed all icons of AI Studio to Lucide (#1187)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-11 14:20:49 +01:00
1 parent 9a676101f7
commit 7ef26cd5c8
352 files changed
+6261 -1620

No files matched your search

+52 -72
View File
@@ -6,19 +6,19 @@ app/MindWork AI Studio/wwwroot/svg/banner.svg
The app shows the banner through an <img> element, where neither the app's CSS nor its web fonts
apply. Therefore, every text is converted into outlines, so the banner looks the same on Windows,
macOS, Linux, and GitHub. The text uses Inter (SIL Open Font License 1.1); the icons are the
Material Icons the app itself uses, read from the MudBlazor package (Apache License 2.0).
Lucide icons the app itself uses, read from the Lucide font it ships (ISC License, with parts of
Feather under the MIT License).
See README.md in this folder for the setup.
"""
import argparse
import hashlib
import os
import re
import json
import logging
import sys
from pathlib import Path
import dnfile
import uharfbuzz as hb
from fontTools.pens.basePen import BasePen
from fontTools.pens.transformPen import TransformPen
@@ -26,9 +26,13 @@ from fontTools.ttLib import TTFont
HERE = Path(__file__).resolve().parent
REPO_ROOT = HERE.parents[1]
APP_PROJECT = REPO_ROOT / "app" / "MindWork AI Studio" / "MindWork AI Studio.csproj"
DEFAULT_OUTPUT = REPO_ROOT / "app" / "MindWork AI Studio" / "wwwroot" / "svg" / "banner.svg"
# The Lucide font the app ships and the code points of its icons, so the banner always shows the
# icons of the app in the version of the app. See app/SourceGeneratedMappings/Lucide/README.md.
LUCIDE_FONT_PATH = REPO_ROOT / "app" / "MindWork AI Studio" / "wwwroot" / "fonts" / "lucide.woff2"
LUCIDE_CODEPOINTS_PATH = REPO_ROOT / "app" / "SourceGeneratedMappings" / "Lucide" / "codepoints.json"
# The variable font Inter[opsz,wght].ttf from google/fonts, pinned to one commit. Another version
# of the font changes the outlines and therefore the generated file.
FONT_PATH = HERE / "Inter.ttf"
@@ -43,16 +47,16 @@ NAME = "AI Studio"
TAGLINE = "Any model. Your data. Your rules."
# Two rows of four chips; the chips of one column share their width, so pair labels of similar
# length in a column. Each entry: (id, label, name of the icon in Icons.Material.Filled).
# length in a column. Each entry: (id, label, name of the Lucide icon as lucide.dev shows it).
CHIPS = [
("chat", "Chat", "Chat"),
("assistants", "Assistants", "Apps"),
("builder", "No-code builder", "AutoMode"),
("tools", "Agentic tools", "Build"),
("research", "Research", "Language"),
("transcribe", "Transcribe", "Mic"),
("documents", "Your documents", "Description"),
("emails", "Your e-mails", "Email"),
("chat", "Chat", "message-square-text"),
("assistants", "Assistants", "layout-grid"),
("builder", "No-code builder", "wrench"),
("tools", "Agentic tools", "bot"),
("research", "Research", "globe"),
("transcribe", "Transcribe", "audio-lines"),
("documents", "Your documents", "file-text"),
("emails", "Your e-mails", "mail"),
]
#
@@ -252,65 +256,40 @@ class Font:
return elements
def read_project_value(pattern, what):
match = re.search(pattern, APP_PROJECT.read_text(encoding="utf-8-sig"))
if match is None:
sys.exit(f"Could not find {what} in {APP_PROJECT}.")
class IconFont:
"""Draws the Lucide icons of the app from the font it ships, by their names on lucide.dev."""
return match.group(1)
def __init__(self, font_path, codepoints_path):
for path in (font_path, codepoints_path):
if not path.exists():
sys.exit(f"Could not find {path}.")
# fontTools complains about a few spare bytes in the post table of the Lucide font, which
# changes neither the outlines nor the names:
logging.getLogger("fontTools").setLevel(logging.ERROR)
def load_material_icons(names):
"""
Reads the SVG paths of Icons.Material.Filled.<name> from the MudBlazor assembly in the NuGet
cache, in the version and for the framework the app project uses.
"""
version = read_project_value(r'<PackageReference\s+Include="MudBlazor"\s+Version="([^"]+)"', "the MudBlazor version")
framework = read_project_value(r"<TargetFramework>([^<]+)</TargetFramework>", "the target framework")
packages = Path(os.environ.get("NUGET_PACKAGES", Path.home() / ".nuget" / "packages"))
assembly = packages / "mudblazor" / version / "lib" / framework / "MudBlazor.dll"
if not assembly.exists():
sys.exit(f"Could not find {assembly}. Build the app once, so NuGet restores MudBlazor {version}.")
self.tt = TTFont(font_path)
self.upem = self.tt["head"].unitsPerEm
self.ascent = self.tt["hhea"].ascent
self.glyph_set = self.tt.getGlyphSet()
self.cmap = self.tt.getBestCmap()
self.codepoints = json.loads(codepoints_path.read_text(encoding="utf-8"))
tables = dnfile.dnPE(str(assembly)).net.mdtables
types = tables.TypeDef.rows
def check(self, names):
"""Stops the script when the font lacks one of the icons."""
missing = sorted(name for name in names if self.codepoints.get(name) not in self.cmap)
if missing:
sys.exit(f"The Lucide font of the app has no icon named {', '.join(missing)}. See lucide.dev for the names.")
# Which type declares which field, and which type encloses which nested type:
field_owner = {}
for index, type_def in enumerate(types, start=1):
for field in type_def.FieldList:
field_owner[field.row_index] = index
enclosing = {n.NestedClass.row_index: str(n.EnclosingClass.row.TypeName) for n in tables.NestedClass.rows}
# The icons are string constants of the nested class Icons.Material.Filled:
icons = {}
for constant in tables.Constant.rows:
parent = constant.Parent
if parent.table.name != "Field":
continue
owner = field_owner.get(parent.row_index)
if owner is None or str(types[owner - 1].TypeName) != "Filled" or enclosing.get(owner) != "Material":
continue
name = str(tables.Field.rows[parent.row_index - 1].Name)
if name not in names:
continue
value = constant.Value
raw = value.value if hasattr(value, "value") else value
svg = raw.decode("utf-16le") if isinstance(raw, (bytes, bytearray)) else str(raw)
# Skip the invisible 24 x 24 frames some icons carry:
tags = re.findall(r"<path\b[^>]*>", svg)
icons[name] = [re.search(r'\bd="([^"]+)"', tag).group(1) for tag in tags if 'fill="none"' not in tag]
missing = sorted(set(names) - icons.keys())
if missing:
sys.exit(f"Icons.Material.Filled has no icon named {', '.join(missing)} in MudBlazor {version}.")
return icons
def path(self, name, x, y, size):
"""
Returns the path data of the icon with its top left corner at (x, y). Lucide draws every icon
into the full em of the font, which is its 24 x 24 grid.
"""
scale = size / self.upem
pen = RecordingPen(self.glyph_set)
self.glyph_set[self.cmap[self.codepoints[name]]].draw(TransformPen(pen, (scale, 0, 0, -scale, x, y + self.ascent * scale)))
return compact_path(pen.ops)
def load_font():
@@ -329,7 +308,7 @@ def build_banner(font, icons):
out = []
a = out.append
a(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {WIDTH} {HEIGHT}" role="img" aria-label="MindWork AI Studio">')
a('<!-- Text outlined from Inter (SIL Open Font License 1.1), icons from Material Icons via MudBlazor (Apache License 2.0). -->')
a('<!-- Text outlined from Inter (SIL Open Font License 1.1), icons outlined from Lucide (ISC License; parts from Feather, MIT License). -->')
a('<title>MindWork AI Studio</title>')
a('<defs>')
a(f'<linearGradient id="bg" x1="0" y1="0" x2="{WIDTH}" y2="{HEIGHT}" gradientUnits="userSpaceOnUse">'
@@ -371,8 +350,7 @@ def build_banner(font, icons):
# Icon and label centred in the chip:
content_x = x + (w - content_widths[r][c]) / 2
for d in icons[icon]:
a(f'<path transform="translate({num(content_x)} {num(y + (chip_h - icon_size) / 2)}) scale({icon_size / 24:g})" d="{d}"/>')
a(f'<path d="{icons.path(icon, content_x, y + (chip_h - icon_size) / 2, icon_size)}"/>')
# Centre the cap height of Inter (0.727 em) vertically:
baseline = y + chip_h / 2 + 0.727 * size / 2
@@ -394,7 +372,9 @@ def main():
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help=f"where to write the SVG file (default: {DEFAULT_OUTPUT})")
args = parser.parse_args()
svg = build_banner(load_font(), load_material_icons({icon for (_, _, icon) in CHIPS}))
icons = IconFont(LUCIDE_FONT_PATH, LUCIDE_CODEPOINTS_PATH)
icons.check({icon for (_, _, icon) in CHIPS})
svg = build_banner(load_font(), icons)
args.output.write_text(svg, encoding="utf-8")
print(f"Wrote {args.output} ({len(svg.encode())} bytes).")