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 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} */ 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 */ 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> */ 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 */ 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'); } }