diff --git a/app/Services/FontService.php b/app/Services/FontService.php index 1572fbdb..475c16b5 100644 --- a/app/Services/FontService.php +++ b/app/Services/FontService.php @@ -321,6 +321,35 @@ class FontService * Used by the PDF fonts partial so dompdf can resolve CJK families * via standard CSS — no separate registerFont() dance required. */ + /** + * Absolute paths of every installed font file, keyed by filename. + * + * getInstalledFontFaces() writes those absolute paths straight into + * `src: url(...)`, which only works for a renderer sharing this filesystem. + * Gotenberg runs Chromium in a separate container, so it needs the files + * sent alongside the document — see GotenbergPdfDriver. + * + * @return array + */ + public function getInstalledFontFilePaths(): array + { + $paths = []; + + foreach (self::FONT_PACKAGES as $package) { + if (! $this->isInstalled($package)) { + continue; + } + + $dir = $this->packageDir($package); + + foreach ($package['files'] as $entry) { + $paths[$entry['file']] = $dir.'/'.$entry['file']; + } + } + + return $paths; + } + public function getInstalledFontFaces(): array { $faces = []; diff --git a/app/Support/Pdf/GotenbergPdfDriver.php b/app/Support/Pdf/GotenbergPdfDriver.php index 90bb91d3..8872845c 100644 --- a/app/Support/Pdf/GotenbergPdfDriver.php +++ b/app/Support/Pdf/GotenbergPdfDriver.php @@ -2,6 +2,7 @@ namespace App\Support\Pdf; +use App\Services\FontService; use App\Support\Net\BlockedUrlException; use App\Support\Net\PrivateNetworkGuard; use Gotenberg\Gotenberg; @@ -89,17 +90,57 @@ class GotenbergPdfDriver implements PdfDriver $chromium->footer(Stream::string('footer.html', $footer)); } + $html = view($template)->render(); + + // Fonts must travel with the document; see attachFonts(). + [$html, $fonts] = $this->attachFonts($html); + + if ($fonts !== []) { + $chromium->assets(...$fonts); + } + return $chromium->html( // The SDK renames this to index.html regardless of what we pass // (ChromiumPdf::html()), so name it that way rather than implying // a choice we do not have. - Stream::string( - 'index.html', - view($template)->render(), - ) + Stream::string('index.html', $html) ); } + /** + * Send the installed font files alongside the document, and point the + * font-face rules at them. + * + * FontService emits `src: url("/var/www/html/storage/fonts/...")` — an + * absolute path on this container's filesystem. dompdf shares that + * filesystem so it resolves; Chromium runs inside the Gotenberg container + * and cannot see any of it, so every package silently failed to load and + * documents fell back to whatever fonts that image happens to ship. The + * docs recommend Gotenberg precisely for mixed-script documents, which made + * this the wrong way round. + * + * Gotenberg unpacks assets next to index.html, so a bare filename resolves. + * Only fonts actually referenced by the markup are sent, to keep a CJK + * package off every request that does not use it. + * + * @return array{0: string, 1: list} + */ + private function attachFonts(string $html): array + { + $streams = []; + + foreach (app(FontService::class)->getInstalledFontFilePaths() as $filename => $path) { + if (! str_contains($html, $path) || ! is_readable($path)) { + continue; + } + + $html = str_replace($path, $filename, $html); + $streams[] = Stream::path($path, $filename); + } + + return [$html, $streams]; + } + /** * A `{template}_header` or `{template}_footer` view rendered alongside the * document, repeated by Chromium on every page. diff --git a/tests/Unit/GotenbergFontDeliveryTest.php b/tests/Unit/GotenbergFontDeliveryTest.php new file mode 100644 index 00000000..4b99bf8d --- /dev/null +++ b/tests/Unit/GotenbergFontDeliveryTest.php @@ -0,0 +1,94 @@ + 'http://gotenberg.example.com:3000']); + + $this->views = sys_get_temp_dir().'/is-fonts-'.uniqid(); + File::ensureDirectoryExists($this->views); + View::addNamespace('fonttest', $this->views); + + $this->fonts = app(FontService::class)->getInstalledFontFilePaths(); +}); + +afterEach(function () { + File::deleteDirectory($this->views); +}); + +/** + * Distinct view names per case: Blade caches compiled views by path, so reusing + * one filename can silently render the previous case's markup. + */ +function fontRequestBody(string $markup, string $dir, string $name = 'doc'): string +{ + File::put("{$dir}/{$name}.blade.php", $markup); + View::getFinder()->flush(); + + return (string) (new GotenbergPdfDriver)->buildRequest("fonttest::{$name}")->getBody(); +} + +test('a document using the font partial gets the files sent with it', function () { + expect($this->fonts)->not->toBeEmpty('no bundled fonts installed to test against'); + + $body = fontRequestBody( + '@include("app.pdf.partials.fonts")x', + $this->views, + 'with-fonts' + ); + + $filename = array_key_first($this->fonts); + + expect($body)->toContain($filename); +}); + +/** + * Gotenberg unpacks assets next to index.html, so the rule has to name the file + * rather than a path this container happens to have. + */ +test('the absolute host path is rewritten out of the markup', function () { + $body = fontRequestBody( + '@include("app.pdf.partials.fonts")x', + $this->views, + 'rewritten' + ); + + foreach ($this->fonts as $path) { + expect($body)->not->toContain($path); + } +}); + +/** + * A CJK package is several megabytes, so it should not ride along on a request + * whose document never mentions it. + */ +test('fonts the document does not reference are not sent', function () { + $body = fontRequestBody('no font rules here', $this->views, 'plain'); + + foreach ($this->fonts as $filename => $path) { + expect($body)->not->toContain($filename); + } +}); + +test('the font files themselves are attached, not just referenced', function () { + $withFonts = fontRequestBody( + '@include("app.pdf.partials.fonts")x', + $this->views, + 'payload-with' + ); + $without = fontRequestBody('x', $this->views, 'payload-without'); + + // The difference is the font payload, which is far larger than the markup. + expect(strlen($withFonts))->toBeGreaterThan(strlen($without) + 100_000); +});