/** * Erzeugt alle Marken-Dateien unter public/brand/ aus dem Symbol der Vorgabe: * * symbol.svg, symbol-light.svg, icon-small.svg, avatar.svg, logo.svg, logo-light.svg, banner.svg, * favicon-16.png, favicon-32.png, apple-touch-icon.png, icon-192.png, icon-512.png, * icon-maskable-512.png * * Texte (Wortmarke, Banner) werden zu Pfaden – die SVGs sehen damit überall gleich aus, auch ohne * installierte Schrift (README auf Gitea, Social Preview). Die Schriften (JetBrains Mono 2.304, * Space Grotesk 2.0.0, beide SIL OFL) werden dafür einmal geladen, per SHA-256 geprüft und unter * node_modules/.cache abgelegt; eingecheckt werden nur die Ergebnisse. * * pnpm marke */ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { Resvg } from "@resvg/resvg-js"; import opentype from "opentype.js"; const ZIEL = "public/brand"; const CACHE = "node_modules/.cache/proxdeploy-schriften"; const FARBE = { ink: "#11161C", panel: "#1A2129", linie: "#2A333D", muted: "#8B98A5", paper: "#E6EDF3", teal: "#2DD4BF", tealDunkel: "#0F766E", }; // ── Schriften ─────────────────────────────────────────────────────────────────────────────────── const ARCHIVE = { jetbrains: { url: "https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip", sha256: "6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf", }, grotesk: { url: "https://github.com/floriankarsten/space-grotesk/releases/download/2.0.0/SpaceGrotesk-2.0.0.zip", sha256: "53b415577d4139248555300710bea0d268c7a5be67b93de53b716a9736cabffd", }, }; async function archivHolen(name: keyof typeof ARCHIVE): Promise { const { url, sha256 } = ARCHIVE[name]; mkdirSync(CACHE, { recursive: true }); const pfad = join(CACHE, `${name}.zip`); if (!existsSync(pfad)) { const antwort = await fetch(url); if (!antwort.ok) throw new Error(`${url}: HTTP ${antwort.status}`); writeFileSync(pfad, Buffer.from(await antwort.arrayBuffer())); } const ist = createHash("sha256").update(readFileSync(pfad)).digest("hex"); if (ist !== sha256) throw new Error(`${pfad}: Prüfsumme ${ist} statt ${sha256}`); return pfad; } function schriftAus(archiv: string, datei: string): opentype.Font { const daten = execFileSync("unzip", ["-p", archiv, datei], { maxBuffer: 16 * 1024 * 1024 }); return opentype.parse(daten.buffer.slice(daten.byteOffset, daten.byteOffset + daten.byteLength)); } type Schriften = { mono: opentype.Font; monoFett: opentype.Font; grotesk: opentype.Font }; async function schriftenLaden(): Promise { const jb = await archivHolen("jetbrains"); const sg = await archivHolen("grotesk"); return { mono: schriftAus(jb, "fonts/ttf/JetBrainsMono-Regular.ttf"), monoFett: schriftAus(jb, "fonts/ttf/JetBrainsMono-Bold.ttf"), grotesk: schriftAus(sg, "SpaceGrotesk-2.0.0/ttf/static/SpaceGrotesk-Regular.ttf"), }; } /** * Pfaddaten selbst schreiben: `toPathData` von opentype.js 1.3.5 liefert an manchen Positionen * „NaN“ (beim Runden), und resvg bricht den Pfad dort ab. */ function pfadDaten(pfad: opentype.Path): string { const z = (wert: number) => { if (!Number.isFinite(wert)) throw new Error(`Ungültige Koordinate ${wert}`); return String(Math.round(wert * 100) / 100); }; return pfad.commands .map((b) => { switch (b.type) { case "M": case "L": return `${b.type}${z(b.x)} ${z(b.y)}`; case "Q": return `Q${z(b.x1)} ${z(b.y1)} ${z(b.x)} ${z(b.y)}`; case "C": return `C${z(b.x1)} ${z(b.y1)} ${z(b.x2)} ${z(b.y2)} ${z(b.x)} ${z(b.y)}`; default: return "Z"; } }) .join(""); } /** Text als Pfad; liefert Pfaddaten und die Breite. */ function text(schrift: opentype.Font, inhalt: string, x: number, y: number, groesse: number) { for (const zeichen of inhalt) { if (schrift.charToGlyph(zeichen).index === 0 && zeichen !== " ") { throw new Error(`${schrift.names.fullName?.en}: kein Glyph für „${zeichen}“`); } } // Glyph für Glyph: opentype.js 1.3.5 scheitert an den kontextuellen Ersetzungen (Ligaturen) // von JetBrains Mono; Logo und Banner brauchen keine. const faktor = groesse / schrift.unitsPerEm; let lauf = x; const teile: string[] = []; for (const zeichen of inhalt) { const glyph = schrift.charToGlyph(zeichen); teile.push(pfadDaten(glyph.getPath(lauf, y, groesse))); lauf += (glyph.advanceWidth ?? 0) * faktor; } return { d: teile.filter(Boolean).join(" "), breite: lauf - x }; } // ── Symbol ────────────────────────────────────────────────────────────────────────────────────── const SECHSECK = "M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z"; function symbolInhalt(rahmen: string, akzent: string): string { return [ ``, ``, ``, ].join("\n "); } const svg = (breite: number, hoehe: number, inhalt: string, extra = "") => `\n ${inhalt}\n\n`; /** Favicon-Variante: ohne Sockel, dicke Linien, passt sich dem hellen oder dunklen Browser an. */ function iconKlein(): string { return svg( 64, 64, [ ``, ``, ``, ].join("\n "), ); } /** Avatar/App-Icon: abgerundetes Quadrat in Teal, Symbol in Ink. `randlos` für maskierbare Icons. */ function avatar(randlos = false): string { const hintergrund = randlos ? `` : ``; // Maskierbar: Inhalt in der sicheren Zone (80 % in der Mitte). const massstab = randlos ? 0.62 : 0.78; const versatz = 32 * (1 - massstab); return svg( 64, 64, `${hintergrund}\n \n ${symbolInhalt(FARBE.ink, FARBE.ink)}\n `, ); } /** Favicon als Rasterbild (16/32 px): dicke Linien ohne Sockel auf Teal – auf jedem Tab-Hintergrund lesbar. */ function faviconRaster(): string { return svg( 64, 64, [ ``, ``, ``, ``, ``, ].join("\n "), ); } // ── Logo: Symbol + Wortmarke ──────────────────────────────────────────────────────────────────── function wortmarke(s: Schriften, x: number, grundlinie: number, groesse: number, hell: boolean) { const prox = text(s.mono, "Prox", x, grundlinie, groesse); const deploy = text(s.monoFett, "Deploy", x + prox.breite, grundlinie, groesse); const versalhoehe = (s.mono.tables["os2"]?.sCapHeight ?? 730) / s.mono.unitsPerEm; const cursorX = x + prox.breite + deploy.breite + groesse * 0.12; const cursorBreite = groesse * 0.52; const cursorOben = grundlinie - versalhoehe * groesse - groesse * 0.06; const cursorHoehe = versalhoehe * groesse + groesse * 0.2; const text1 = hell ? FARBE.ink : FARBE.paper; const akzent = hell ? FARBE.tealDunkel : FARBE.teal; return { inhalt: [ ``, ``, ``, ].join("\n "), ende: cursorX + cursorBreite, }; } function logo(s: Schriften, hell: boolean): string { const w = wortmarke(s, 78, 43, 30, hell); const breite = Math.ceil(w.ende + 4); return svg( breite, 64, `${symbolInhalt(hell ? FARBE.ink : FARBE.paper, hell ? FARBE.tealDunkel : FARBE.teal)}\n ${w.inhalt}`, ); } // ── Banner 1280×640 ───────────────────────────────────────────────────────────────────────────── function banner(s: Schriften): string { const teile: string[] = [``]; // Großes, sehr transparentes Sechseck im Hintergrund. teile.push( ``, ); // Logo groß, links. const massstab = 2; teile.push( `\n ${symbolInhalt(FARBE.paper, FARBE.teal)}\n ${wortmarke(s, 78, 43, 30, false).inhalt}\n `, ); // Unterzeile in Space Grotesk. const zeile = text( s.grotesk, "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.", 96, 330, 32, ); if (zeile.breite > 1280 - 2 * 96) throw new Error(`Unterzeile zu breit (${zeile.breite})`); teile.push(``); // Tags als Outline-Pills. let x = 96; for (const tag of ["Web-GUI", "CLI", "cloud-init", "LXC", "Debian", "Ubuntu"]) { const t = text(s.mono, tag, 0, 0, 20); const breite = t.breite + 36; teile.push( ``, ``, ); x += breite + 12; } // Kleine Terminal-Karte rechts unten. const karte = { x: 744, y: 470, b: 440, h: 110 }; teile.push( ``, ``, ); const haken = text(s.mono, "✓", karte.x + 24, karte.y + 80, 18); teile.push( ``, ``, ); return svg(1280, 640, teile.join("\n ")); } // ── PNG ───────────────────────────────────────────────────────────────────────────────────────── function png(quelle: string, breite: number, datei: string): void { const bild = new Resvg(quelle, { fitTo: { mode: "width", value: breite } }).render(); writeFileSync(join(ZIEL, datei), bild.asPng()); } async function main() { const s = await schriftenLaden(); mkdirSync(ZIEL, { recursive: true }); const dateien: Record = { "symbol.svg": svg(64, 64, symbolInhalt(FARBE.paper, FARBE.teal)), "symbol-light.svg": svg(64, 64, symbolInhalt(FARBE.ink, FARBE.tealDunkel)), "icon-small.svg": iconKlein(), "avatar.svg": avatar(), "logo.svg": logo(s, false), "logo-light.svg": logo(s, true), "banner.svg": banner(s), }; for (const [name, inhalt] of Object.entries(dateien)) writeFileSync(join(ZIEL, name), inhalt); // Raster-Icons aus dem Avatar: auf hellem und dunklem Grund gleich gut sichtbar. png(faviconRaster(), 16, "favicon-16.png"); png(faviconRaster(), 32, "favicon-32.png"); png(dateien["avatar.svg"]!, 192, "icon-192.png"); png(dateien["avatar.svg"]!, 512, "icon-512.png"); png(avatar(true), 180, "apple-touch-icon.png"); png(avatar(true), 512, "icon-maskable-512.png"); // Vorschau des Banners (Social Preview verlangt ein Rasterbild). png(dateien["banner.svg"]!, 1280, "banner.png"); console.log(`${Object.keys(dateien).length} SVG und 7 PNG unter ${ZIEL}/ geschrieben`); } await main();