Files
KantSites/src/Content/FontLibrary.php
T
kantarciandClaude Opus 5.5 5e5d380fd3 Build Kantarci Markt as an eighth installation, allow content in own CSS and keep other fonts when fetching
- Coming-soon page "Kantarci Markt – Neueröffnung" rebuilt from the delivered package with
  imprint, privacy and 404 (scripts/reset-kantarci-markt.sh, ports 8100/8101).
- Own CSS accepts a short quoted string for `content`, so ::before/::after work.
- cms:fonts:fetch replaces only the rules of the fetched families in fonts.css.
- A variable font is linked and preloaded once with a weight range instead of once per weight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:37:54 +02:00

335 lines
14 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);
// Eine variable Schrift (etwa DM Sans) liefert Google als eine Datei für alle
// Schnitte, mit einer @font-face-Regel je Schnitt. Die Datei liegt trotzdem je Schnitt
// im Ordner, damit missing() weiter nach Schnitten fragen kann, aber das Stylesheet
// nennt sie nur einmal, mit einem Gewichtsbereich. Sonst lädt der Browser dieselben
// Bytes je Schnitt einmal, und preload() holt sie dreimal vorab.
$groups = [];
foreach ($this->parse($css, $family) as $face) {
$name = $this->slug($family).'-'.$face['weight'].'-'.$face['subset'].'.woff2';
$path = $this->directory.'/'.$name;
if (!is_file($path)) {
if (isset($groups[$face['url']])) {
copy($this->directory.'/'.$groups[$face['url']]['name'], $path);
} else {
file_put_contents($path, $this->download($face['url']));
++$files;
}
}
if (isset($groups[$face['url']])) {
$groups[$face['url']]['weights'][] = $face['weight'];
continue;
}
$bytes += (int) filesize($path);
$groups[$face['url']] = ['name' => $name, 'weights' => [$face['weight']], 'face' => $face];
}
foreach ($groups as $group) {
$face = $group['face'];
$low = min($group['weights']);
$high = max($group['weights']);
$rules[] = sprintf(
"/* %s */\n@font-face{font-family:'%s';font-style:%s;font-weight:%s;font-display:swap;src:url(/assets/fonts/%s) format('woff2');unicode-range:%s}",
$face['subset'],
$family,
$face['style'],
$low === $high ? (string) $low : $low.' '.$high,
$group['name'],
$face['range'],
);
}
}
if ([] === $rules) {
throw new \RuntimeException('Google Fonts hat keine passende Schriftdatei geliefert.');
}
$stylesheet = $this->directory.'/fonts.css';
$existing = is_file($stylesheet) ? (string) file_get_contents($stylesheet) : '';
file_put_contents($stylesheet, $this->combine($existing, $rules, array_keys($wanted)));
return ['files' => $files, 'bytes' => $bytes, 'families' => array_keys($wanted)];
}
/**
* Setzt fonts.css aus den neuen Regeln und den Regeln anderer Familien zusammen.
*
* Bisher schrieb jeder Abruf die Datei neu und nur mit den Familien des aktuellen Designs.
* Teilen sich mehrere Websites den Ordner (lokal alle Installationen) oder wechselt ein Design
* die Schrift und wieder zurück, verschwanden damit Schriften, deren Dateien noch daliegen.
* Regeln für Familien, die gerade neu geholt wurden, werden ersetzt; die übrigen bleiben.
* Eine unbenutzte @font-face-Regel kostet nichts: der Browser lädt die Datei erst, wenn Text
* in dieser Familie steht.
*
* @param list<string> $rules je Regel ein Kommentar mit dem Bereich und die @font-face
* @param list<string> $families die Familien, zu denen $rules gehören
*/
public function combine(string $existing, array $rules, array $families): string
{
$kept = [];
preg_match_all('~(?:/\*[^*]*\*/\s*)?@font-face\s*\{[^}]*\}~', $existing, $matches);
foreach ($matches[0] as $rule) {
if (1 !== preg_match("~font-family:\\s*'([^']+)'~", $rule, $family) || in_array($family[1], $families, true)) {
continue;
}
$kept[] = trim($rule);
}
return implode("\n", [...$kept, ...$rules])."\n";
}
/**
* 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
{
// Nur Dateien, die das Stylesheet auch nennt: bei einer variablen Schrift steht dort eine
// Datei für alle Schnitte, die Kopien je Schnitt holt der Browser nie.
$stylesheet = is_file($this->directory.'/fonts.css') ? (string) file_get_contents($this->directory.'/fonts.css') : null;
$preload = [];
foreach ($this->wanted($theme) as $family => $weights) {
foreach ($weights as $weight) {
$name = $this->slug($family).'-'.$weight.'-latin.woff2';
$genannt = null === $stylesheet || !str_contains($stylesheet, '/assets/fonts/'.$this->slug($family).'-')
|| str_contains($stylesheet, '/assets/fonts/'.$name.')');
if (is_file($this->directory.'/'.$name) && $genannt) {
$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');
}
}