Forms are an editor now: one card per field with add and remove, the technical name derived from the label, options only for a choice, and a new "Aussehen" section with a button label and four colours (field border and background, button and button text) that travel to the page as one rule per form inside the hashed style block. A live preview draws the form with the site's default button and names the text contrast. Statistics open with six figures (views against the same span before, average per day, busiest day, pages, referred views, missing addresses), a chart with grid lines and a title per bar, a weekday chart and ranked lists with share bars. The design screen shows the palette as colour cards with seven ready-made palettes, picks the base colours from the palette, previews every font in itself and the typography as a sample. The sidebar ends in an account card with role and a logout icon. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
281 lines
11 KiB
PHP
281 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
/**
|
|
* Schriften, die die Website selbst ausliefert.
|
|
*
|
|
* Eine Schrift von fonts.googleapis.com kostet zweierlei. Technisch: der Browser muss erst das
|
|
* Stylesheet holen, darin die Adresse der Schriftdatei finden und dann diese holen - zwei fremde
|
|
* Verbindungen hintereinander, bevor die erste Zeile in der richtigen Schrift steht. Rechtlich:
|
|
* dabei erfährt ein Dritter die IP-Adresse jedes Besuchers, und dafür gibt es in Deutschland
|
|
* Abmahnungen (LG München I, 3 O 17493/20).
|
|
*
|
|
* Beides verschwindet, wenn die Dateien auf dem eigenen Server liegen. Die Lizenz erlaubt das:
|
|
* die Schriften bei Google Fonts stehen unter der SIL Open Font License oder Apache 2.0.
|
|
*
|
|
* Heruntergeladen wird einmal auf Zuruf, nicht bei einem Seitenaufruf - ein Besucher soll nie auf
|
|
* einen fremden Server warten, auch nicht mittelbar.
|
|
*/
|
|
final class FontLibrary
|
|
{
|
|
/**
|
|
* Der Browser, als den wir uns ausgeben.
|
|
*
|
|
* Google liefert je nach Browser ein anderes Stylesheet: alten Browsern TTF, neuen WOFF2. Ohne
|
|
* diese Kennung bekämen wir Dateien, die viermal so groß sind.
|
|
*/
|
|
private const AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
|
|
|
/** Zeichenbereiche, die eine deutschsprachige Website braucht. Kyrillisch und Vietnamesisch nicht. */
|
|
private const SUBSETS = ['latin', 'latin-ext'];
|
|
|
|
public function __construct(private string $directory)
|
|
{
|
|
}
|
|
|
|
/** Gibt es ein Stylesheet mit lokalen Schriften - auch wenn nicht jede Schrift des Themes dabei ist? */
|
|
public function hasStylesheet(): bool
|
|
{
|
|
return is_file($this->directory.'/fonts.css');
|
|
}
|
|
|
|
/** Liegen für dieses Theme fertige Dateien bereit? */
|
|
public function hasLocal(array $theme): bool
|
|
{
|
|
return [] === $this->missing($theme) && is_file($this->directory.'/fonts.css');
|
|
}
|
|
|
|
/**
|
|
* Die Schriften des Themes, für die noch keine lokale Datei existiert.
|
|
*
|
|
* @return list<string> Beschriftungen wie „Lato 700“
|
|
*/
|
|
public function missing(array $theme): array
|
|
{
|
|
$missing = [];
|
|
foreach ($this->wanted($theme) as $family => $weights) {
|
|
foreach ($weights as $weight) {
|
|
if ([] === glob($this->directory.'/'.$this->slug($family).'-'.$weight.'-*.woff2')) {
|
|
$missing[] = $family.' '.$weight;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $missing;
|
|
}
|
|
|
|
/**
|
|
* Holt alle Schriften des Themes und schreibt fonts.css daneben.
|
|
*
|
|
* @return array{files: int, bytes: int, families: list<string>}
|
|
*/
|
|
public function fetch(array $theme): array
|
|
{
|
|
$wanted = $this->wanted($theme);
|
|
if ([] === $wanted) {
|
|
throw new \RuntimeException('Dieses Design nutzt keine Schrift von Google Fonts.');
|
|
}
|
|
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
|
throw new \RuntimeException('Der Schriftenordner ist nicht beschreibbar: '.$this->directory);
|
|
}
|
|
$rules = [];
|
|
$files = 0;
|
|
$bytes = 0;
|
|
foreach ($wanted as $family => $weights) {
|
|
$css = $this->stylesheet($family, $weights);
|
|
foreach ($this->parse($css, $family) as $face) {
|
|
$name = $this->slug($family).'-'.$face['weight'].'-'.$face['subset'].'.woff2';
|
|
$path = $this->directory.'/'.$name;
|
|
if (!is_file($path)) {
|
|
file_put_contents($path, $this->download($face['url']));
|
|
++$files;
|
|
}
|
|
$bytes += (int) filesize($path);
|
|
$rules[] = sprintf(
|
|
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%d;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
|
|
$face['subset'],
|
|
$family,
|
|
$face['style'],
|
|
$face['weight'],
|
|
$name,
|
|
$face['range'],
|
|
);
|
|
}
|
|
}
|
|
if ([] === $rules) {
|
|
throw new \RuntimeException('Google Fonts hat keine passende Schriftdatei geliefert.');
|
|
}
|
|
file_put_contents($this->directory.'/fonts.css', implode("\n", $rules)."\n");
|
|
|
|
return ['files' => $files, 'bytes' => $bytes, 'families' => array_keys($wanted)];
|
|
}
|
|
|
|
/**
|
|
* Die Dateien, die für den ersten Eindruck vorab geholt werden sollen.
|
|
*
|
|
* Jeder Schnitt, den das Theme nennt, im lateinischen Grundbereich. Die früheren „ersten
|
|
* zwei je Familie“ ließen genau den Schnitt aus, der die Überschriften trägt: Lato 900 lud
|
|
* erst nach dem Stylesheet, und mit `font-display:swap` sprang die Überschrift sichtbar von
|
|
* der Ersatzschrift auf die richtige. Die erweiterten Bereiche bleiben draußen - sie werden
|
|
* nur für Zeichen gebraucht, die auf den meisten Seiten nicht vorkommen.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public function preload(array $theme): array
|
|
{
|
|
$preload = [];
|
|
foreach ($this->wanted($theme) as $family => $weights) {
|
|
foreach ($weights as $weight) {
|
|
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
|
|
if (is_file($this->directory.'/'.$name)) {
|
|
$preload[] = '/assets/fonts/'.$name;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $preload;
|
|
}
|
|
|
|
/**
|
|
* Welche Familien und Schnitte das Theme von Google bezieht.
|
|
*
|
|
* @return array<string, list<int>>
|
|
*/
|
|
private function wanted(array $theme): array
|
|
{
|
|
$wanted = [];
|
|
foreach ($theme['fonts'] ?? [] as $font) {
|
|
if ('google' !== ($font['source'] ?? '') || '' === ($font['family'] ?? '')) {
|
|
continue;
|
|
}
|
|
$weights = array_map(intval(...), $font['weights'] ?? [400]);
|
|
sort($weights);
|
|
$wanted[$font['family']] = array_values(array_unique([...($wanted[$font['family']] ?? []), ...$weights]));
|
|
sort($wanted[$font['family']]);
|
|
}
|
|
|
|
return $wanted;
|
|
}
|
|
|
|
private function stylesheet(string $family, array $weights): string
|
|
{
|
|
$url = sprintf(
|
|
'https://fonts.googleapis.com/css2?family=%s:wght@%s&display=swap',
|
|
rawurlencode($family),
|
|
implode(';', $weights),
|
|
);
|
|
|
|
$css = $this->get($url);
|
|
if (!str_contains($css, '@font-face')) {
|
|
throw new \RuntimeException(sprintf('Google Fonts liefert für „%s“ keine Schrift. Schreibt sich die Familie genau so?', $family));
|
|
}
|
|
|
|
return $css;
|
|
}
|
|
|
|
private function download(string $url): string
|
|
{
|
|
if (!str_starts_with($url, 'https://fonts.gstatic.com/')) {
|
|
throw new \RuntimeException('Die Schriftdatei käme von einer fremden Adresse: '.$url);
|
|
}
|
|
|
|
$body = $this->get($url);
|
|
// Eine WOFF2-Datei beginnt mit "wOF2". Was anderes ist, wird nicht gespeichert.
|
|
if (!str_starts_with($body, 'wOF2')) {
|
|
throw new \RuntimeException('Die geladene Datei ist keine WOFF2-Schrift.');
|
|
}
|
|
|
|
return $body;
|
|
}
|
|
|
|
/**
|
|
* Liest die @font-face-Blöcke eines Google-Stylesheets.
|
|
*
|
|
* Der Zeichenbereich steht in einem Kommentar vor jedem Block („/* latin *\/“); danach wird
|
|
* ausgewählt, was eine deutschsprachige Website braucht.
|
|
*
|
|
* @return list<array{subset: string, weight: int, style: string, url: string, range: string}>
|
|
*/
|
|
public function parse(string $css, string $family): array
|
|
{
|
|
$faces = [];
|
|
preg_match_all('#/\*\s*([a-z0-9-]+)\s*\*/\s*@font-face\s*\{(.+?)\}#s', $css, $matches, PREG_SET_ORDER);
|
|
foreach ($matches as [, $subset, $block]) {
|
|
if (!in_array($subset, self::SUBSETS, true)) {
|
|
continue;
|
|
}
|
|
if (1 !== preg_match('#src:\s*url\(([^)]+)\)#', $block, $src)) {
|
|
continue;
|
|
}
|
|
preg_match('#font-weight:\s*(\d+)#', $block, $weight);
|
|
preg_match('#font-style:\s*([a-z]+)#', $block, $style);
|
|
preg_match('#unicode-range:\s*([^;}]+)#', $block, $range);
|
|
$faces[] = [
|
|
'subset' => $subset,
|
|
'weight' => (int) ($weight[1] ?? 400),
|
|
'style' => $style[1] ?? 'normal',
|
|
'url' => trim($src[1], '\'" '),
|
|
'range' => trim($range[1] ?? 'U+0000-00FF'),
|
|
'family' => $family,
|
|
];
|
|
}
|
|
|
|
return $faces;
|
|
}
|
|
|
|
/**
|
|
* Holt eine HTTPS-Adresse - erst über curl, sonst über den Dateizeiger.
|
|
*
|
|
* Dieselben zwei Wege wie im Installer, aus demselben Grund: auf einem Webhosting ist mal das
|
|
* eine da und mal das andere. Eine weitere Abhängigkeit dafür ins Projekt zu holen, würde
|
|
* jedes Update auf einem Hosting ohne Composer scheitern lassen.
|
|
*/
|
|
private function get(string $url): string
|
|
{
|
|
if (!str_starts_with($url, 'https://')) {
|
|
throw new \RuntimeException('Geladen wird nur über HTTPS.');
|
|
}
|
|
if (function_exists('curl_init')) {
|
|
$curl = curl_init($url);
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_MAXREDIRS => 3,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_CONNECTTIMEOUT => 15,
|
|
CURLOPT_TIMEOUT => 60,
|
|
CURLOPT_USERAGENT => self::AGENT,
|
|
]);
|
|
$body = curl_exec($curl);
|
|
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
|
$error = curl_error($curl);
|
|
curl_close($curl);
|
|
if (!is_string($body) || 200 !== $status) {
|
|
throw new \RuntimeException(sprintf('Google Fonts antwortet mit %d%s.', $status, '' === $error ? '' : ' ('.$error.')'));
|
|
}
|
|
|
|
return $body;
|
|
}
|
|
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
|
throw new \RuntimeException('Dieser Server darf nicht nach außen verbinden (weder curl noch allow_url_fopen). Die Schriftdateien müssen dann von Hand nach public/assets/fonts/ gelegt werden.');
|
|
}
|
|
$context = stream_context_create([
|
|
'http' => ['timeout' => 60, 'follow_location' => 1, 'max_redirects' => 3, 'header' => 'User-Agent: '.self::AGENT],
|
|
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
|
]);
|
|
$body = @file_get_contents($url, false, $context);
|
|
if (!is_string($body)) {
|
|
throw new \RuntimeException('Google Fonts ist von diesem Server aus nicht erreichbar.');
|
|
}
|
|
|
|
return $body;
|
|
}
|
|
|
|
private function slug(string $family): string
|
|
{
|
|
return strtolower(preg_replace('/[^a-z0-9]+/i', '-', $family) ?? 'schrift');
|
|
}
|
|
}
|