From 35a248b48b5b6a0206e0ed68a5f0ff6aaa4aade8 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:41:35 +0200 Subject: [PATCH] fix(pdf): send the installed fonts to Gotenberg (#733) FontService writes absolute host paths into the @font-face rules: src: url("/var/www/html/storage/fonts/NotoSansSC-Regular.ttf") dompdf shares that filesystem so they resolve. Chromium runs inside the Gotenberg container and cannot see any of it, so every installed font package silently failed to load and documents fell back to whatever fonts that image happens to ship. The docs recommend Gotenberg specifically for mixed-script documents, which made this exactly the wrong way round -- it worked only by accident, because Chromium's own font set covers more than dompdf's single-font behaviour. The font files now travel with the document as Gotenberg assets, and the rules are rewritten to name them. Gotenberg unpacks assets next to index.html, so a bare filename resolves. Only fonts the markup actually references are sent. A CJK package is several megabytes and has no business riding along on a request that never mentions it. Confirmed against a stock gotenberg:8, reading the fonts back out of the rendered PDF: before AAAAAA+LiberationSerif (Gotenberg's fallback) after AAAAAA+NotoSans-Regular (the app's own font) Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF --- app/Services/FontService.php | 29 ++++++++ app/Support/Pdf/GotenbergPdfDriver.php | 49 +++++++++++- tests/Unit/GotenbergFontDeliveryTest.php | 94 ++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/GotenbergFontDeliveryTest.php 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); +});