diff --git a/CHANGELOG.md b/CHANGELOG.md index d770481..9736c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ and the project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- `papercut:fonts:install` saves the family's licence (`OFL.txt`, `LICENSE.txt` or `UFL.txt`) beside the font files, from Google's fonts repository. If it can't be downloaded, the command warns and installs the fonts anyway; `--force` replaces an existing copy. + ## [0.3.0] - 2026-10-28 The first release on Packagist. 0.1 and 0.2 were never published, so there are no deprecation shims; the table diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 5a7dc96..edd334b 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -438,7 +438,7 @@ Inter (regular, bold, italic, bold italic) is bundled and always available. Regi 'theme' => ['font_family' => 'Merriweather', 'font_size' => 10], ``` -`php artisan papercut:fonts:install "Merriweather" --weights=400,700 --italic` downloads a family's TrueType files from Google Fonts into `resources/fonts//` (`--path` to change, `--force` to overwrite) and prints the `fonts` entry to paste into the config. The files are downloaded once and committed with the app; rendering never touches the network. +`php artisan papercut:fonts:install "Merriweather" --weights=400,700 --italic` downloads a family's TrueType files from Google Fonts into `resources/fonts//` (`--path` to change, `--force` to overwrite), saves the family's licence beside them (`OFL.txt`, `LICENSE.txt` or `UFL.txt`, from Google's [fonts repository](https://github.com/google/fonts)) and prints the `fonts` entry to paste into the config. The files are downloaded once and committed with the app; rendering never touches the network. `php artisan papercut:fonts` lists every available face and reports whether each file is `ok`, `missing` or `invalid`. A glyph missing from the primary family is looked up in `fallback_fonts`; if no family has it, a warning is logged (`[papercut] ...`). diff --git a/packages/laravel/src/Console/InstallFontCommand.php b/packages/laravel/src/Console/InstallFontCommand.php index 19f3519..7c59b7d 100644 --- a/packages/laravel/src/Console/InstallFontCommand.php +++ b/packages/laravel/src/Console/InstallFontCommand.php @@ -9,8 +9,9 @@ use Papercut\Backend\PhpPdf\PhpPdfBackend; /** - * Downloads a family's TrueType files from Google Fonts once, so PDFs can embed them like any local font. - * Rendering never touches the network: the files live in the app and are registered in papercut.fonts. + * Downloads a family's TrueType files from Google Fonts once, so PDFs can embed them like any local font, and the + * family's licence beside them. Rendering never touches the network: the files live in the app and are registered + * in papercut.fonts. */ final class InstallFontCommand extends Command { @@ -25,6 +26,9 @@ final class InstallFontCommand extends Command private const string CSS = 'https://fonts.googleapis.com/css2'; private const string FILES = 'https://fonts.gstatic.com/'; + /** Google Fonts' source repository, which keeps each family's licence in a folder named for the licence. */ + private const string LICENCES = 'https://raw.githubusercontent.com/google/fonts/main/'; + private const array LICENCE_FILES = ['ofl' => 'OFL.txt', 'apache' => 'LICENSE.txt', 'ufl' => 'UFL.txt']; private const array NAMES = [ 100 => 'Thin', 200 => 'ExtraLight', 300 => 'Light', 400 => 'Regular', 500 => 'Medium', @@ -107,16 +111,61 @@ public function handle(): int $this->line(" saved $relative/$file"); } + $licence = $this->saveLicence($family, $dir, $relative); + $this->newLine(); $this->line('Add this to fonts in config/papercut.php:'); $this->newLine(); $this->line($this->snippet($family, $config)); $this->newLine(); - $this->line('Check the family\'s licence on fonts.google.com before you ship it. Most are under the SIL Open Font License.'); + if ($licence !== null) { + $this->line("Keep $relative/$licence with the fonts: the licence asks for a copy wherever they go."); + } else { + $this->warn("Couldn't download $family's licence. Get it from fonts.google.com and keep it with the fonts; most are under the SIL Open Font License."); + } return self::SUCCESS; } + /** Saves the family's licence beside its files and returns the file's name, or null when none was found. */ + private function saveLicence(string $family, string $dir, string $relative): ?string + { + if (!$this->option('force')) { + foreach (self::LICENCE_FILES as $file) { + if (is_file("$dir/$file")) { + $this->line(" exists $relative/$file"); + + return $file; + } + } + } + $slug = strtolower(str_replace(' ', '', $family)); + foreach (self::LICENCE_FILES as $folder => $file) { + try { + $response = Http::timeout(30)->get(self::LICENCES . "$folder/$slug/$file"); + } catch (\Throwable) { + return null; + } + $text = $response->body(); + if (!$response->successful() || !self::isLicenceText($text)) { + continue; + } + file_put_contents("$dir/$file", $text); + $this->line(" saved $relative/$file"); + + return $file; + } + + return null; + } + + /** Plain text of a sensible size, not an error page. */ + private static function isLicenceText(string $text): bool + { + return $text !== '' && strlen($text) <= 256 * 1024 && mb_check_encoding($text, 'UTF-8') + && !str_contains($text, "\0") && !str_starts_with(ltrim($text), '<'); + } + /** @return list|null */ private function weights(string $option): ?array { diff --git a/packages/laravel/tests/Feature/InstallFontTest.php b/packages/laravel/tests/Feature/InstallFontTest.php index 36145ec..e2d2159 100644 --- a/packages/laravel/tests/Feature/InstallFontTest.php +++ b/packages/laravel/tests/Feature/InstallFontTest.php @@ -25,15 +25,22 @@ protected function tearDown(): void } /** - * Fakes Google: each listed "weight,ital" pair has a TrueType file; everything else answers 400. + * Fakes Google: each listed "weight,ital" pair has a TrueType file, and $licences maps paths under the + * google/fonts repository (such as "ofl/opensans/OFL.txt") to their text; everything else answers 400. * * @param list $faces + * @param array $licences */ - private function fakeGoogle(string $family, array $faces, ?string $bytes = null): void + private function fakeGoogle(string $family, array $faces, ?string $bytes = null, array $licences = []): void { $bytes ??= (string) file_get_contents(self::INTER); - Http::fake(function (Request $request) use ($family, $faces, $bytes) { + Http::fake(function (Request $request) use ($family, $faces, $bytes, $licences) { $url = $request->url(); + foreach ($licences as $path => $text) { + if ($url === 'https://raw.githubusercontent.com/google/fonts/main/' . $path) { + return Http::response($text); + } + } if (str_starts_with($url, 'https://fonts.gstatic.com/')) { return Http::response($bytes); } @@ -74,6 +81,52 @@ public function test_downloads_each_face_and_prints_the_config(): void Http::assertSent(fn (Request $r) => str_contains($r->url(), 'googleapis') && $r->header('User-Agent') === ['Papercut']); } + public function test_saves_the_licence_beside_the_fonts(): void + { + $this->fakeGoogle('Open Sans', ['400,0'], licences: ['ofl/opensans/OFL.txt' => "SIL OPEN FONT LICENSE Version 1.1\n"]); + + [$code, $out] = $this->install(['family' => 'Open Sans', '--weights' => '400']); + + $this->assertSame(0, $code, $out); + $this->assertSame("SIL OPEN FONT LICENSE Version 1.1\n", file_get_contents(base_path(self::DIR . '/OpenSans/OFL.txt'))); + $this->assertStringContainsString('Keep ' . self::DIR . '/OpenSans/OFL.txt with the fonts', $out); + } + + public function test_finds_an_apache_licence_when_there_is_no_ofl(): void + { + $this->fakeGoogle('Roboto Slab', ['400,0'], licences: ['ofl/robotoslab/OFL.txt' => 'not found', 'apache/robotoslab/LICENSE.txt' => "Apache License\n"]); + + [$code, $out] = $this->install(['family' => 'Roboto Slab', '--weights' => '400']); + + $this->assertSame(0, $code, $out); + $this->assertFileDoesNotExist(base_path(self::DIR . '/RobotoSlab/OFL.txt')); + $this->assertSame("Apache License\n", file_get_contents(base_path(self::DIR . '/RobotoSlab/LICENSE.txt'))); + } + + public function test_a_missing_licence_warns_but_installs(): void + { + $this->fakeGoogle('Brand', ['400,0']); + + [$code, $out] = $this->install(['family' => 'Brand', '--weights' => '400']); + + $this->assertSame(0, $code, $out); + $this->assertFileExists(base_path(self::DIR . '/Brand/Brand-Regular.ttf')); + $this->assertStringContainsString("Couldn't download Brand's licence", $out); + } + + public function test_an_existing_licence_is_kept_unless_forced(): void + { + $this->fakeGoogle('Brand', ['400,0'], licences: ['ofl/brand/OFL.txt' => "new\n"]); + File::ensureDirectoryExists(base_path(self::DIR . '/Brand')); + file_put_contents(base_path(self::DIR . '/Brand/OFL.txt'), "old\n"); + + $this->install(['family' => 'Brand', '--weights' => '400']); + $this->assertSame("old\n", file_get_contents(base_path(self::DIR . '/Brand/OFL.txt'))); + + $this->install(['family' => 'Brand', '--weights' => '400', '--force' => true]); + $this->assertSame("new\n", file_get_contents(base_path(self::DIR . '/Brand/OFL.txt'))); + } + public function test_the_printed_config_loads(): void { $this->fakeGoogle('Brand', ['400,0']); diff --git a/site/src/guide/fonts.md b/site/src/guide/fonts.md index 1168fab..6f52dc8 100644 --- a/site/src/guide/fonts.md +++ b/site/src/guide/fonts.md @@ -54,7 +54,7 @@ In Laravel, you can download a family from Google Fonts with one command: php artisan papercut:fonts:install "Merriweather" --weights=400,700 --italic ``` -This saves the font files to `resources/fonts/Merriweather/` and prints the entry to paste into `fonts` in your config: +This saves the font files to `resources/fonts/Merriweather/`, along with the family's licence (`OFL.txt` for the SIL Open Font License, or `LICENSE.txt` or `UFL.txt`), and prints the entry to paste into `fonts` in your config: ```php 'Merriweather' => [ @@ -63,7 +63,7 @@ This saves the font files to `resources/fonts/Merriweather/` and prints the entr ], ``` -The fonts are downloaded once and then live in your app like any other font, so commit them with your code. Rendering never goes to the network. +The fonts are downloaded once and then live in your app like any other font, so commit them with your code, licence included: the licences ask for a copy to go wherever the fonts do. If the licence can't be downloaded, the command says so and installs the fonts anyway; get it from the family's page on fonts.google.com. Rendering never goes to the network. | Option | Default | What it does | |---|---|---|