- 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>
335 lines
14 KiB
PHP
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');
|
|
}
|
|
}
|