mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-05 07:32:14 +00:00
Takes over #690 by csoscd. The companion-view idea is theirs; this reworks it onto the shared page setup and fills in the gaps that stopped it landing. A `{template}_header` or `{template}_footer` view next to a template is rendered alongside it and repeated by Chromium on every page. The suffix resolves through the pdf_templates:: namespace too, so custom templates get it with no extra wiring. Two things had to change for that to be useful. Companion views are now hidden from the template picker. getFormattedTemplates() lists every .blade.php it finds, so an invoice1_footer would otherwise appear as a separately selectable template with no preview image -- the feature would have introduced that the moment anyone used it. And it does something out of the box. #690 shipped no companion views, so both of its margin settings were visible no-ops until someone hand-wrote a Blade file. Instead there is a pdf_page_numbers setting, off by default, that supplies a footer when a template has none. A template's own companion still wins, so turning page numbers on cannot overwrite a designed footer. The setting sits under Gotenberg because only Chromium can repeat a footer; dompdf has no equivalent. Its value is still carried by the dompdf form so saving from there cannot clear the choice -- the field is absent from that payload, and the controller only writes it when present. Margins come from the page setup rather than #690's separate header_margin and footer_margin. Chromium draws header and footer inside the page margin, so the existing margins are the space they occupy; two more settings for the same distance would have been a second way to say the same thing. Verified against a live gotenberg:8 on a two-page document: off produces no footer, on produces "1/2" and "2/2" on the respective pages, and a companion footer replaces both. #690's own test is not carried over. It asserted nothing: a bare View::shouldReceive('exists') is an allowance rather than an expectation, andReturn(false) never entered the companion branch, and the call sat inside try { } catch (Throwable) { }, so it passed with the feature deleted. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
125 lines
5.1 KiB
PHP
125 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace App\Support\Pdf;
|
|
|
|
use App\Support\Net\BlockedUrlException;
|
|
use App\Support\Net\PrivateNetworkGuard;
|
|
use Gotenberg\Gotenberg;
|
|
use Gotenberg\Stream;
|
|
use Illuminate\Support\Facades\View;
|
|
use Psr\Http\Message\RequestInterface;
|
|
|
|
class GotenbergPdfDriver implements PdfDriver
|
|
{
|
|
public function loadView(string $template): ResponseStream
|
|
{
|
|
return new GotenbergPdfResponse(Gotenberg::send($this->buildRequest($template)));
|
|
}
|
|
|
|
/**
|
|
* Assemble the Chromium request without sending it.
|
|
*
|
|
* Split out so the option wiring can actually be asserted on. Everything
|
|
* below this line used to be inlined into loadView(), which meant the only
|
|
* way to check that an option was set was to run a Gotenberg service.
|
|
*/
|
|
public function buildRequest(string $template): RequestInterface
|
|
{
|
|
$page = PdfPageSetup::fromConfig();
|
|
[$width, $height] = $page->gotenbergPaper();
|
|
[$marginTop, $marginBottom, $marginLeft, $marginRight] = $page->gotenbergMargins();
|
|
|
|
$host = config('pdf.connections.gotenberg.host');
|
|
|
|
// SSRF guard: gotenberg_host is an admin-supplied URL the server POSTs
|
|
// the rendered HTML to, and whose response is streamed back as the PDF.
|
|
// Block private/reserved/link-local targets even if set via env/seed/stale
|
|
// config or reachable through DNS rebinding. The single exception is the
|
|
// host the operator declared in GOTENBERG_ALLOWED_PRIVATE_HOST, which is
|
|
// how a sidecar deployment is supported — see GotenbergHostPolicy.
|
|
if (! GotenbergHostPolicy::isExemptFromPrivateNetworkGuard((string) $host)) {
|
|
try {
|
|
PrivateNetworkGuard::assertAllowed((string) $host);
|
|
} catch (BlockedUrlException $e) {
|
|
throw new \InvalidArgumentException('Invalid Gotenberg host: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
$chromium = Gotenberg::chromium($host)
|
|
->pdf()
|
|
// Only affects the root (body/html) background: Chromium paints
|
|
// element backgrounds either way, verified against gotenberg:8, so
|
|
// no stock template changes. dompdf does paint the body background,
|
|
// so this is here to stop a custom template that sets one from
|
|
// rendering differently depending on the selected driver.
|
|
->printBackground()
|
|
// config/dompdf.php renders as `screen`; Chromium defaults to `print`.
|
|
// Align them so a template with media queries behaves the same either
|
|
// way rather than depending on which driver is selected.
|
|
->emulateScreenMediaType()
|
|
->margins($marginTop, $marginBottom, $marginLeft, $marginRight)
|
|
->paperSize($width, $height);
|
|
|
|
// landscape() swaps the axes itself, so paperSize() above is always given
|
|
// the portrait pair — the same convention dompdf's setPaper() follows.
|
|
if ($page->isLandscape()) {
|
|
$chromium->landscape();
|
|
}
|
|
|
|
// Must be attached before html(), which is terminal: it returns the built
|
|
// request rather than the builder.
|
|
if ($header = $this->companion($template, '_header')) {
|
|
$chromium->header(Stream::string('header.html', $header));
|
|
}
|
|
|
|
if ($footer = $this->companion($template, '_footer') ?? $this->defaultFooter()) {
|
|
$chromium->footer(Stream::string('footer.html', $footer));
|
|
}
|
|
|
|
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(),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A `{template}_header` or `{template}_footer` view rendered alongside the
|
|
* document, repeated by Chromium on every page.
|
|
*
|
|
* The suffix resolves through the `pdf_templates::` namespace too, so a
|
|
* custom template gets this without any extra wiring. PdfTemplateUtils hides
|
|
* the suffixed views from the template picker, which would otherwise list
|
|
* them as separately selectable templates.
|
|
*/
|
|
private function companion(string $template, string $suffix): ?string
|
|
{
|
|
$view = $template.$suffix;
|
|
|
|
return View::exists($view) ? View::make($view)->render() : null;
|
|
}
|
|
|
|
/**
|
|
* Page numbers, when no template supplies a footer of its own.
|
|
*
|
|
* Gotenberg is the only driver that can do this: Chromium repeats a footer
|
|
* template on every page and substitutes the pageNumber/totalPages spans.
|
|
* dompdf has no equivalent, so the setting is presented under Gotenberg.
|
|
*
|
|
* Note the footer draws inside the bottom margin, so it is invisible when
|
|
* that margin is zero. The default of 1.2cm leaves room.
|
|
*/
|
|
private function defaultFooter(): ?string
|
|
{
|
|
if (! config('pdf.page.page_numbers')) {
|
|
return null;
|
|
}
|
|
|
|
return View::make('app.pdf.partials.page-footer')->render();
|
|
}
|
|
}
|