Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/laravel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Family>/` (`--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/<Family>/` (`--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] ...`).

Expand Down
55 changes: 52 additions & 3 deletions packages/laravel/src/Console/InstallFontCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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',
Expand Down Expand Up @@ -107,16 +111,61 @@ public function handle(): int
$this->line(" <info>saved</info> $relative/$file");
}

$licence = $this->saveLicence($family, $dir, $relative);

$this->newLine();
$this->line('Add this to <comment>fonts</comment> 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(" <comment>exists</comment> $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(" <info>saved</info> $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<int>|null */
private function weights(string $option): ?array
{
Expand Down
59 changes: 56 additions & 3 deletions packages/laravel/tests/Feature/InstallFontTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $faces
* @param array<string, string> $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);
}
Expand Down Expand Up @@ -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' => '<html>not found</html>', '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']);
Expand Down
4 changes: 2 additions & 2 deletions site/src/guide/fonts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand All @@ -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 |
|---|---|---|
Expand Down
Loading