diff --git a/media/Banner/.gitignore b/media/Banner/.gitignore new file mode 100644 index 00000000..3643da60 --- /dev/null +++ b/media/Banner/.gitignore @@ -0,0 +1,4 @@ +# Downloaded during the setup, see README.md: +Inter.ttf +.venv/ +__pycache__/ \ No newline at end of file diff --git a/media/Banner/README.md b/media/Banner/README.md new file mode 100644 index 00000000..9ce33210 --- /dev/null +++ b/media/Banner/README.md @@ -0,0 +1,46 @@ +# Banner + +`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 `` 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). + +## 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. + +In this folder, create a virtual environment and install the packages: + +```bash +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +``` + +Then download the font. The script expects this exact version of Inter and warns about any other one, because a different version changes the outlines: + +```bash +curl -L -o Inter.ttf "https://raw.githubusercontent.com/google/fonts/e1d6480102fed30739fead0faee463101f892c8f/ofl/inter/Inter%5Bopsz,wght%5D.ttf" +``` + +Neither the font nor the virtual environment belongs in the repository; `.gitignore` keeps both out. + +On Windows, use `.venv\Scripts\pip` and `.venv\Scripts\python` instead. + +## Usage + +```bash +.venv/bin/python generate_banner.py +``` + +The script overwrites the banner in the app. To write it somewhere else, for example to compare two versions, pass `--output `. Without any change to the script, the output is identical to the committed banner. + +On macOS, `qlmanage -t -s 1920 -o ` renders a preview with WebKit, the engine of the app on macOS. Any browser shows the file as well. + +## Changing the banner + +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. +- `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. \ No newline at end of file diff --git a/media/Banner/generate_banner.py b/media/Banner/generate_banner.py new file mode 100644 index 00000000..05d5ebbe --- /dev/null +++ b/media/Banner/generate_banner.py @@ -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 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 : + 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 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'') + + 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. from the MudBlazor assembly in the NuGet + cache, in the version and for the framework the app project uses. + """ + version = read_project_value(r'([^<]+)", "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"]*>", 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'') + a('') + a('MindWork AI Studio') + a('') + a(f'' + f'') + sun_x, sun_y, sun_r = lay["sun"] + a(f'' + f'' + f'' + f'') + a('') + a(f'') + a(f'') + + # Wordmark on the left: + left = lay["left"] + a(f'') + a(f'') + a(f'') + a(f'') + a('') + + # 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'') + 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'') + a(f'') + + # Icon and label centred in the chip: + content_x = x + (w - content_widths[r][c]) / 2 + for d in icons[icon]: + a(f'') + + # 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('') + x += w + gap + + a('') + a('') + + # The glyphs of the chip labels go into : + i = out.index('') + out[i:i] = [f'' 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() diff --git a/media/Banner/requirements.txt b/media/Banner/requirements.txt new file mode 100644 index 00000000..5de8afd3 --- /dev/null +++ b/media/Banner/requirements.txt @@ -0,0 +1,3 @@ +dnfile==0.18.0 +fonttools==4.66.1 +uharfbuzz==0.56.3 \ No newline at end of file diff --git a/media/Banners.afdesign b/media/Banners.afdesign deleted file mode 100644 index 41cade90..00000000 --- a/media/Banners.afdesign +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:021b4670a57577be50a9b317286cd2afaec9007227868b6076b3933c9ac80fe3 -size 67539 diff --git a/media/Banners.ai b/media/Banners.ai deleted file mode 100644 index fdb0fb2e..00000000 --- a/media/Banners.ai +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:643ff1ec3e586883873a1c0b4a81fe7f63be000245ad99ddca90bbc3b138437b -size 259110 diff --git a/media/Flyer German.afpub b/media/Flyer German.afpub deleted file mode 100644 index d0d98ac4..00000000 --- a/media/Flyer German.afpub +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c33c6ee49ef146381f83c55209ed087fa659d1bdafc621de42c685bb16f96d40 -size 150784 diff --git a/media/Flyer Header.afdesign b/media/Flyer Header.afdesign deleted file mode 100644 index 5e4e2d2d..00000000 --- a/media/Flyer Header.afdesign +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:216b613ec46eecf07dea691af2e10127e6002d7259a79ee982bd0f9078b32e9f -size 99999 diff --git a/media/Flyer English US.afpub b/media/Flyer/Flyer English US.afpub similarity index 100% rename from media/Flyer English US.afpub rename to media/Flyer/Flyer English US.afpub diff --git a/media/Flyer/Flyer German.afpub b/media/Flyer/Flyer German.afpub new file mode 100644 index 00000000..7958a832 --- /dev/null +++ b/media/Flyer/Flyer German.afpub @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ca2554399c3bdb9819ba3ec282990777c64647e4d33b8b133f6d7bda072f4c8 +size 150784 diff --git a/media/Flyer/Flyer Header.afdesign b/media/Flyer/Flyer Header.afdesign new file mode 100644 index 00000000..d388ea9b --- /dev/null +++ b/media/Flyer/Flyer Header.afdesign @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b4d27f503a9a74cda12dec9b6d7b857ab45095fb12bb5ff8a764353c7bf9d04 +size 99999 diff --git a/media/Icon Source - Adobe Firefly.jpg b/media/Icon Source - Adobe Firefly.jpg deleted file mode 100644 index 67665ecd..00000000 --- a/media/Icon Source - Adobe Firefly.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:42e2020483643190575a21a99c890fa8c0e0fca3b36b384f2802c620c510d0ef -size 369841 diff --git a/media/Icon.psd b/media/Icon.psd deleted file mode 100644 index baec17e1..00000000 --- a/media/Icon.psd +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:685379710b0582ba351862ccbf8524a379f45cdb96d6850b766c5d7156eb1920 -size 2139980 diff --git a/media/Icon.svg b/media/Icon.svg new file mode 100644 index 00000000..9088f21f --- /dev/null +++ b/media/Icon.svg @@ -0,0 +1,496 @@ + + + + AI Studio Logo + GrĂ¼ne Waldlandschaft mit Sprechblase, drei Punkten, Wolke und warmer Sonne. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/media/Startup Icon.psd b/media/Startup Icon.psd deleted file mode 100644 index 2e392b5c..00000000 --- a/media/Startup Icon.psd +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6330c250dfaa88bca9668d7ff050d664b78fa424ab4595a1a651cbbbc85bb206 -size 559978 diff --git a/media/macOS App Icon Template.psd b/media/macOS App Icon Template.psd deleted file mode 100644 index 5d7800c3..00000000 --- a/media/macOS App Icon Template.psd +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dedf4b1f979f22585771efdad73b0f6b5fc6d48bb413573c3397da2dd7945edc -size 521664