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

+3 -3
View File
@@ -2,11 +2,11 @@
`generate_banner.py` creates `app/MindWork AI Studio/wwwroot/svg/banner.svg`, the banner on the start page of the app and at the top of the repository's README.
The app shows the banner through an `<img>` element, where neither the app's CSS nor its web fonts apply. Therefore, the script converts every text into outlines, so the banner looks the same on Windows, macOS, Linux, and GitHub. The text uses [Inter](https://github.com/rsms/inter) (SIL Open Font License 1.1). The icons are the Material Icons the app itself uses, read from the MudBlazor package of the app (Apache License 2.0).
The app shows the banner through an `<img>` element, where neither the app's CSS nor its web fonts apply. Therefore, the script converts every text into outlines, so the banner looks the same on Windows, macOS, Linux, and GitHub. The text uses [Inter](https://github.com/rsms/inter) (SIL Open Font License 1.1). The icons are the [Lucide](https://lucide.dev) icons the app itself uses (ISC License, with parts of Feather under the MIT License), read from the Lucide font which the app ships, `app/MindWork AI Studio/wwwroot/fonts/lucide.woff2`, with the code points in `app/SourceGeneratedMappings/Lucide/codepoints.json`. The banner therefore always shows the icons in the version of the app.
## Setup
You need Python 3 and a build of the app, so NuGet has restored MudBlazor into its package cache. The script reads the MudBlazor version and the target framework from the app's project file.
You need Python 3. The script needs no build of the app: it reads the Lucide font and its code points straight from the repository.
In this folder, create a virtual environment and install the packages:
@@ -40,7 +40,7 @@ On macOS, `qlmanage -t -s 1920 -o <folder> <file>` renders a preview with WebKit
The content sits at the top of the script:
- `EYEBROW`, `NAME`, and `TAGLINE` form the wordmark on the left.
- `CHIPS` lists the eight capabilities on the right: two rows of four, where the chips of one column share their width. Pair labels of similar length in a column. The icon is the name of an icon in `Icons.Material.Filled`, ideally the one the app uses for that feature.
- `CHIPS` lists the eight capabilities on the right: two rows of four, where the chips of one column share their width. Pair labels of similar length in a column. The icon is the name of a Lucide icon as [lucide.dev](https://lucide.dev/icons/) shows it, such as `message-square-text`, ideally the one the app uses for that feature in `app/MindWork AI Studio/Tools/AppIcons.cs`. When a column would show the same icon twice, pick another one for one of them.
- `LAYOUT` holds positions and sizes, `CREAM` the color of text and icons.
Keep the labels in US English and short. Check the result in a narrow window as well: at 700 pixels, the labels are only about 10 pixels high. On wide windows, the start page crops up to 32 units at the top and the bottom, so text and chips must stay between y = 48 and y = 272.
+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).")
+1 -1
View File
@@ -1,3 +1,3 @@
dnfile==0.18.0
brotli==1.2.1
fonttools==4.66.1
uharfbuzz==0.56.3