Alle Varianten aus dem Symbol der Vorgabe per Skript (pnpm marke); Texte als Pfade, damit Wortmarke und Banner ohne installierte Schrift gleich aussehen. Closes #017 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
13 KiB
TypeScript
297 lines
13 KiB
TypeScript
/**
|
||
* 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<string> {
|
||
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<Schriften> {
|
||
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 [
|
||
`<path d="${SECHSECK}" stroke="${rahmen}" stroke-width="3.5" stroke-linejoin="round"/>`,
|
||
`<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="${akzent}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||
`<path d="M24 47 H40" stroke="${akzent}" stroke-width="4" stroke-linecap="round"/>`,
|
||
].join("\n ");
|
||
}
|
||
|
||
const svg = (breite: number, hoehe: number, inhalt: string, extra = "") =>
|
||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${breite} ${hoehe}" width="${breite}" height="${hoehe}" fill="none"${extra}>\n ${inhalt}\n</svg>\n`;
|
||
|
||
/** Favicon-Variante: ohne Sockel, dicke Linien, passt sich dem hellen oder dunklen Browser an. */
|
||
function iconKlein(): string {
|
||
return svg(
|
||
64,
|
||
64,
|
||
[
|
||
`<style>.h{stroke:${FARBE.paper}}.p{stroke:${FARBE.teal}}@media (prefers-color-scheme: light){.h{stroke:${FARBE.ink}}.p{stroke:${FARBE.tealDunkel}}}</style>`,
|
||
`<path class="h" d="${SECHSECK}" stroke-width="5.5" stroke-linejoin="round"/>`,
|
||
`<path class="p" d="M32 43 V18 M22 28 L32 18 L42 28" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||
].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
|
||
? `<rect width="64" height="64" fill="${FARBE.teal}"/>`
|
||
: `<rect width="64" height="64" rx="14" fill="${FARBE.teal}"/>`;
|
||
// 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 <g transform="translate(${versatz} ${versatz}) scale(${massstab})">\n ${symbolInhalt(FARBE.ink, FARBE.ink)}\n </g>`,
|
||
);
|
||
}
|
||
|
||
/** Favicon als Rasterbild (16/32 px): dicke Linien ohne Sockel auf Teal – auf jedem Tab-Hintergrund lesbar. */
|
||
function faviconRaster(): string {
|
||
return svg(
|
||
64,
|
||
64,
|
||
[
|
||
`<rect width="64" height="64" rx="12" fill="${FARBE.teal}"/>`,
|
||
`<g transform="translate(4 4) scale(0.875)">`,
|
||
`<path d="${SECHSECK}" stroke="${FARBE.ink}" stroke-width="6" stroke-linejoin="round"/>`,
|
||
`<path d="M32 43 V18 M22 28 L32 18 L42 28" stroke="${FARBE.ink}" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||
`</g>`,
|
||
].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: [
|
||
`<path d="${prox.d}" fill="${text1}"/>`,
|
||
`<path d="${deploy.d}" fill="${akzent}"/>`,
|
||
`<rect x="${cursorX.toFixed(2)}" y="${cursorOben.toFixed(2)}" width="${cursorBreite.toFixed(2)}" height="${cursorHoehe.toFixed(2)}" fill="${akzent}"/>`,
|
||
].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[] = [`<rect width="1280" height="640" fill="${FARBE.ink}"/>`];
|
||
// Großes, sehr transparentes Sechseck im Hintergrund.
|
||
teile.push(
|
||
`<g transform="translate(700 -60) scale(12)" opacity="0.07"><path d="${SECHSECK}" stroke="${FARBE.paper}" stroke-width="1.2" stroke-linejoin="round"/></g>`,
|
||
);
|
||
// Logo groß, links.
|
||
const massstab = 2;
|
||
teile.push(
|
||
`<g transform="translate(96 120) scale(${massstab})">\n ${symbolInhalt(FARBE.paper, FARBE.teal)}\n ${wortmarke(s, 78, 43, 30, false).inhalt}\n </g>`,
|
||
);
|
||
// 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(`<path d="${zeile.d}" fill="${FARBE.paper}"/>`);
|
||
// 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(
|
||
`<rect x="${x}" y="378" width="${breite.toFixed(1)}" height="40" rx="20" stroke="${FARBE.linie}" stroke-width="2"/>`,
|
||
`<path d="${text(s.mono, tag, x + 18, 405, 20).d}" fill="${FARBE.muted}"/>`,
|
||
);
|
||
x += breite + 12;
|
||
}
|
||
// Kleine Terminal-Karte rechts unten.
|
||
const karte = { x: 744, y: 470, b: 440, h: 110 };
|
||
teile.push(
|
||
`<rect x="${karte.x}" y="${karte.y}" width="${karte.b}" height="${karte.h}" rx="14" fill="${FARBE.panel}" stroke="${FARBE.linie}" stroke-width="2"/>`,
|
||
`<path d="${text(s.mono, "# proxdeploy lxc dns01 --profile base", karte.x + 24, karte.y + 44, 18).d}" fill="${FARBE.muted}"/>`,
|
||
);
|
||
const haken = text(s.mono, "✓", karte.x + 24, karte.y + 80, 18);
|
||
teile.push(
|
||
`<path d="${haken.d}" fill="${FARBE.teal}"/>`,
|
||
`<path d="${text(s.mono, " dns01 bereit", karte.x + 24 + haken.breite, karte.y + 80, 18).d}" fill="${FARBE.paper}"/>`,
|
||
);
|
||
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<string, string> = {
|
||
"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();
|