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
This commit is contained in:
Darko Gjorgjijoski
2026-08-01 14:41:35 +02:00
committed by GitHub
parent 773670c18f
commit 35a248b48b
3 changed files with 168 additions and 4 deletions

View File

@@ -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<Stream>}
*/
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.