mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-18 10:18:00 +00:00
Cleaned up the media folder and added a banner generator (#1027)
This commit is contained in:
1 parent
8e0f04301a
commit
f7451d0e38
16 files changed
+958
-24
No files matched your search
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generates the banner of the start page and of the README:
|
||||
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).
|
||||
|
||||
See README.md in this folder for the setup.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import dnfile
|
||||
import uharfbuzz as hb
|
||||
from fontTools.pens.basePen import BasePen
|
||||
from fontTools.pens.transformPen import TransformPen
|
||||
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 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"
|
||||
FONT_URL = "https://raw.githubusercontent.com/google/fonts/e1d6480102fed30739fead0faee463101f892c8f/ofl/inter/Inter%5Bopsz,wght%5D.ttf"
|
||||
FONT_SHA256 = "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031"
|
||||
|
||||
#
|
||||
# Content
|
||||
#
|
||||
EYEBROW = "MINDWORK"
|
||||
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).
|
||||
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"),
|
||||
]
|
||||
|
||||
#
|
||||
# Look
|
||||
#
|
||||
WIDTH, HEIGHT, CORNER_RADIUS = 1920, 320, 24
|
||||
CREAM = "#f6f0d8"
|
||||
|
||||
# When the window is wider than 1536 px, the start page crops up to 32 units at the top and at the
|
||||
# bottom. Keep text and chips between y = 48 and y = 272.
|
||||
LAYOUT = dict(
|
||||
sun=(1860, -50, 660), # centre x, centre y, radius
|
||||
left=104, eyebrow_y=98, eyebrow_size=30, name_y=208, name_size=120, tag_y=258, tag_size=34,
|
||||
chip_font=29, chip_icon=30, chip_pad=22, chip_icon_gap=11, chip_h=62, chip_gap=14, chip_row_gap=14, chip_cy=164,
|
||||
right=104, chip_fill="#0b2e22", chip_fill_op=.35, chip_stroke_op=.3,
|
||||
)
|
||||
|
||||
# Weights and optical sizes of the variable font:
|
||||
EYEBROW_STYLE = dict(wght=600, opsz=32)
|
||||
NAME_STYLE = dict(wght=800, opsz=32)
|
||||
TEXT_STYLE = dict(wght=500, opsz=22)
|
||||
|
||||
|
||||
def num(v):
|
||||
"""Formats a coordinate with one decimal and without needless zeros, e.g. 0.5 -> .5, 2.0 -> 2."""
|
||||
s = f"{v:.1f}"
|
||||
if s.endswith(".0"):
|
||||
s = s[:-2]
|
||||
if s.startswith("0."):
|
||||
s = s[1:]
|
||||
elif s.startswith("-0."):
|
||||
s = "-" + s[2:]
|
||||
return "0" if s in ("-0", "") else s
|
||||
|
||||
|
||||
class RecordingPen(BasePen):
|
||||
"""Records the outline of glyphs as absolute drawing operations."""
|
||||
|
||||
def __init__(self, glyph_set):
|
||||
super().__init__(glyph_set)
|
||||
self.ops = []
|
||||
|
||||
def _moveTo(self, p):
|
||||
self.ops.append(("M", p))
|
||||
|
||||
def _lineTo(self, p):
|
||||
self.ops.append(("L", p))
|
||||
|
||||
def _qCurveToOne(self, c, p):
|
||||
self.ops.append(("Q", c, p))
|
||||
|
||||
def _curveToOne(self, c1, c2, p):
|
||||
self.ops.append(("C", c1, c2, p))
|
||||
|
||||
def _closePath(self):
|
||||
self.ops.append(("Z",))
|
||||
|
||||
def _endPath(self):
|
||||
pass
|
||||
|
||||
|
||||
def compact_path(ops):
|
||||
"""
|
||||
Turns recorded operations into short path data: relative commands, coordinates rounded to
|
||||
tenths, and T wherever a quadratic control point mirrors the previous one, which is the case
|
||||
for every implied on-curve point of TrueType outlines.
|
||||
"""
|
||||
# Rounding the absolute points first keeps the relative steps from drifting:
|
||||
to_tenths = lambda p: (round(p[0] * 10), round(p[1] * 10))
|
||||
out, last_cmd = [], None
|
||||
cur = start = (0, 0)
|
||||
prev_ctrl = None
|
||||
|
||||
def emit(cmd, values):
|
||||
nonlocal last_cmd
|
||||
body = ""
|
||||
for value in values:
|
||||
text = num(value / 10)
|
||||
if body and not text.startswith("-"):
|
||||
body += " "
|
||||
body += text
|
||||
|
||||
# A repeated command may omit its letter:
|
||||
if cmd == last_cmd and cmd not in ("m", "z"):
|
||||
out.append((" " if not body.startswith("-") else "") + body)
|
||||
else:
|
||||
out.append(cmd + body)
|
||||
|
||||
# Further pairs after a moveto are linetos:
|
||||
last_cmd = "l" if cmd == "m" else cmd
|
||||
|
||||
for op in ops:
|
||||
kind = op[0]
|
||||
if kind == "M":
|
||||
p = to_tenths(op[1])
|
||||
emit("m", (p[0] - cur[0], p[1] - cur[1]))
|
||||
cur = start = p
|
||||
prev_ctrl = None
|
||||
elif kind == "L":
|
||||
p = to_tenths(op[1])
|
||||
dx, dy = p[0] - cur[0], p[1] - cur[1]
|
||||
if dx == 0 and dy == 0:
|
||||
pass
|
||||
elif dy == 0:
|
||||
emit("h", (dx,))
|
||||
elif dx == 0:
|
||||
emit("v", (dy,))
|
||||
else:
|
||||
emit("l", (dx, dy))
|
||||
cur = p
|
||||
prev_ctrl = None
|
||||
elif kind == "Q":
|
||||
c, p = to_tenths(op[1]), to_tenths(op[2])
|
||||
mirrored = prev_ctrl is not None and abs(2 * cur[0] - prev_ctrl[0] - c[0]) <= 1 and abs(2 * cur[1] - prev_ctrl[1] - c[1]) <= 1
|
||||
if mirrored:
|
||||
emit("t", (p[0] - cur[0], p[1] - cur[1]))
|
||||
c = (2 * cur[0] - prev_ctrl[0], 2 * cur[1] - prev_ctrl[1])
|
||||
else:
|
||||
emit("q", (c[0] - cur[0], c[1] - cur[1], p[0] - cur[0], p[1] - cur[1]))
|
||||
prev_ctrl = c
|
||||
cur = p
|
||||
elif kind == "C":
|
||||
c1, c2, p = to_tenths(op[1]), to_tenths(op[2]), to_tenths(op[3])
|
||||
emit("c", (c1[0] - cur[0], c1[1] - cur[1], c2[0] - cur[0], c2[1] - cur[1], p[0] - cur[0], p[1] - cur[1]))
|
||||
cur = p
|
||||
prev_ctrl = None
|
||||
elif kind == "Z":
|
||||
emit("z", ())
|
||||
cur = start
|
||||
prev_ctrl = None
|
||||
|
||||
return "".join(out)
|
||||
|
||||
|
||||
class Font:
|
||||
"""Shapes text with HarfBuzz and draws its glyphs with fontTools."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.tt = TTFont(path)
|
||||
self.upem = self.tt["head"].unitsPerEm
|
||||
self.face = hb.Face(hb.Blob.from_file_path(str(path)))
|
||||
self.glyph_order = self.tt.getGlyphOrder()
|
||||
|
||||
# Glyphs of the chip labels, each defined once and placed with <use>:
|
||||
self.shared_glyphs = {}
|
||||
|
||||
def shape(self, text, size, wght, opsz, tracking=0.0):
|
||||
"""Returns the glyphs of the text as (name, x, y) and the width of the text."""
|
||||
font = hb.Font(self.face)
|
||||
font.set_variations({"wght": wght, "opsz": opsz})
|
||||
buf = hb.Buffer()
|
||||
buf.add_str(text)
|
||||
buf.guess_segment_properties()
|
||||
hb.shape(font, buf, {"kern": True, "liga": True})
|
||||
|
||||
scale = size / self.upem
|
||||
glyphs, x = [], 0.0
|
||||
for info, pos in zip(buf.glyph_infos, buf.glyph_positions):
|
||||
glyphs.append((self.glyph_order[info.codepoint], x + pos.x_offset * scale, pos.y_offset * scale))
|
||||
x += pos.x_advance * scale + tracking * size
|
||||
|
||||
return glyphs, x - tracking * size
|
||||
|
||||
def width(self, text, size, wght, opsz):
|
||||
return self.shape(text, size, wght, opsz)[1]
|
||||
|
||||
def path(self, text, x, y, size, wght, opsz, tracking=0.0):
|
||||
"""Returns the path data of the text, starting at the baseline point (x, y)."""
|
||||
glyphs, _ = self.shape(text, size, wght, opsz, tracking)
|
||||
glyph_set = self.tt.getGlyphSet(location={"wght": wght, "opsz": opsz})
|
||||
scale = size / self.upem
|
||||
pen = RecordingPen(glyph_set)
|
||||
for name, gx, gy in glyphs:
|
||||
glyph_set[name].draw(TransformPen(pen, (scale, 0, 0, -scale, x + gx, y - gy)))
|
||||
|
||||
return compact_path(pen.ops)
|
||||
|
||||
def uses(self, text, x, y, size, wght, opsz):
|
||||
"""Returns one <use> element per glyph of the text, registering new glyphs in shared_glyphs."""
|
||||
glyphs, _ = self.shape(text, size, wght, opsz)
|
||||
glyph_set = self.tt.getGlyphSet(location={"wght": wght, "opsz": opsz})
|
||||
scale = size / self.upem
|
||||
elements = []
|
||||
for name, gx, gy in glyphs:
|
||||
key = (name, size, wght, opsz)
|
||||
if key not in self.shared_glyphs:
|
||||
pen = RecordingPen(glyph_set)
|
||||
glyph_set[name].draw(TransformPen(pen, (scale, 0, 0, -scale, 0, 0)))
|
||||
self.shared_glyphs[key] = (f"g{len(self.shared_glyphs)}", compact_path(pen.ops))
|
||||
|
||||
glyph_id, d = self.shared_glyphs[key]
|
||||
|
||||
# Spaces have no outline:
|
||||
if d:
|
||||
elements.append(f'<use href="#{glyph_id}" x="{num(x + gx)}" y="{num(y - gy)}"/>')
|
||||
|
||||
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}.")
|
||||
|
||||
return match.group(1)
|
||||
|
||||
|
||||
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}.")
|
||||
|
||||
tables = dnfile.dnPE(str(assembly)).net.mdtables
|
||||
types = tables.TypeDef.rows
|
||||
|
||||
# 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 load_font():
|
||||
if not FONT_PATH.exists():
|
||||
sys.exit(f"Could not find {FONT_PATH}. Download it first, see README.md:\ncurl -L -o \"{FONT_PATH}\" \"{FONT_URL}\"")
|
||||
|
||||
digest = hashlib.sha256(FONT_PATH.read_bytes()).hexdigest()
|
||||
if digest != FONT_SHA256:
|
||||
print(f"Warning: {FONT_PATH.name} is not the pinned version of Inter. The outlines may differ from the committed banner.", file=sys.stderr)
|
||||
|
||||
return Font(FONT_PATH)
|
||||
|
||||
|
||||
def build_banner(font, icons):
|
||||
lay = LAYOUT
|
||||
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('<title>MindWork AI Studio</title>')
|
||||
a('<defs>')
|
||||
a(f'<linearGradient id="bg" x1="0" y1="0" x2="{WIDTH}" y2="{HEIGHT}" gradientUnits="userSpaceOnUse">'
|
||||
f'<stop offset="0" stop-color="#0f3b2c"/><stop offset=".6" stop-color="#1f6b45"/><stop offset="1" stop-color="#4a9a55"/></linearGradient>')
|
||||
sun_x, sun_y, sun_r = lay["sun"]
|
||||
a(f'<radialGradient id="sun" cx="{sun_x}" cy="{sun_y}" r="{sun_r}" gradientUnits="userSpaceOnUse">'
|
||||
f'<stop offset="0" stop-color="#fdf0bf"/><stop offset=".14" stop-color="#f7d774"/>'
|
||||
f'<stop offset=".2" stop-color="#f7d774" stop-opacity=".7"/><stop offset=".45" stop-color="#f7d774" stop-opacity=".22"/>'
|
||||
f'<stop offset="1" stop-color="#f7d774" stop-opacity="0"/></radialGradient>')
|
||||
a('</defs>')
|
||||
a(f'<rect width="{WIDTH}" height="{HEIGHT}" rx="{CORNER_RADIUS}" fill="url(#bg)"/>')
|
||||
a(f'<rect width="{WIDTH}" height="{HEIGHT}" rx="{CORNER_RADIUS}" fill="url(#sun)"/>')
|
||||
|
||||
# Wordmark on the left:
|
||||
left = lay["left"]
|
||||
a(f'<g id="wordmark" fill="{CREAM}">')
|
||||
a(f'<path id="eyebrow" opacity=".85" d="{font.path(EYEBROW, left + 4, lay["eyebrow_y"], lay["eyebrow_size"], tracking=0.28, **EYEBROW_STYLE)}"/>')
|
||||
a(f'<path id="name" d="{font.path(NAME, left, lay["name_y"], lay["name_size"], tracking=-0.02, **NAME_STYLE)}"/>')
|
||||
a(f'<path id="tagline" opacity=".9" d="{font.path(TAGLINE, left + 2, lay["tag_y"], lay["tag_size"], **TEXT_STYLE)}"/>')
|
||||
a('</g>')
|
||||
|
||||
# Chips on the right, two rows of four:
|
||||
size, icon_size, pad, icon_gap = lay["chip_font"], lay["chip_icon"], lay["chip_pad"], lay["chip_icon_gap"]
|
||||
chip_h, gap, row_gap = lay["chip_h"], lay["chip_gap"], lay["chip_row_gap"]
|
||||
rows = [CHIPS[:4], CHIPS[4:]]
|
||||
content_widths = [[icon_size + icon_gap + font.width(label, size, **TEXT_STYLE) for (_, label, _) in row] for row in rows]
|
||||
columns = [max(content_widths[0][i], content_widths[1][i]) + 2 * pad for i in range(4)]
|
||||
x0 = WIDTH - lay["right"] - (sum(columns) + 3 * gap)
|
||||
top = lay["chip_cy"] - chip_h - row_gap / 2
|
||||
|
||||
a(f'<g id="chips" fill="{CREAM}">')
|
||||
for r, row in enumerate(rows):
|
||||
y = top + r * (chip_h + row_gap)
|
||||
x = x0
|
||||
for c, (chip_id, label, icon) in enumerate(row):
|
||||
w = columns[c]
|
||||
a(f'<g id="chip-{chip_id}">')
|
||||
a(f'<rect x="{num(x)}" y="{num(y)}" width="{num(w)}" height="{chip_h}" rx="{chip_h / 2:g}" fill="{lay["chip_fill"]}" fill-opacity="{lay["chip_fill_op"]}" stroke="{CREAM}" stroke-opacity="{lay["chip_stroke_op"]}" stroke-width="2"/>')
|
||||
|
||||
# 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}"/>')
|
||||
|
||||
# Centre the cap height of Inter (0.727 em) vertically:
|
||||
baseline = y + chip_h / 2 + 0.727 * size / 2
|
||||
out.extend(font.uses(label, content_x + icon_size + icon_gap, baseline, size, **TEXT_STYLE))
|
||||
a('</g>')
|
||||
x += w + gap
|
||||
|
||||
a('</g>')
|
||||
a('</svg>')
|
||||
|
||||
# The glyphs of the chip labels go into <defs>:
|
||||
i = out.index('</defs>')
|
||||
out[i:i] = [f'<path id="{glyph_id}" d="{d}"/>' for (glyph_id, d) in font.shared_glyphs.values() if d]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generates the banner of the start page and of the README.")
|
||||
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}))
|
||||
args.output.write_text(svg, encoding="utf-8")
|
||||
print(f"Wrote {args.output} ({len(svg.encode())} bytes).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user