From e65801df593689e9db31b4c5be87c87e2959a6f7 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 28 Oct 2026 15:15:19 -0700 Subject: [PATCH] feat(laravel): papercut:fonts:install saves the family's licence The command downloaded only the TrueType files, so an app that installed an OFL font shipped it without the licence the OFL asks to travel with it. It now fetches the family's OFL.txt, LICENSE.txt (Apache) or UFL.txt from Google's fonts repository and saves it beside the fonts. A licence that can't be fetched is a warning, not a failure; an existing copy is kept unless --force. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ packages/laravel/README.md | 2 +- .../src/Console/InstallFontCommand.php | 55 ++++++++++++++++- .../laravel/tests/Feature/InstallFontTest.php | 59 ++++++++++++++++++- site/src/guide/fonts.md | 4 +- 5 files changed, 115 insertions(+), 9 deletions(-) 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 | |---|---|---|