mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 23:22:12 +00:00
Generated files carried no document properties at all, so an archive of them showed a column of blank titles and no author. Title, Subject, Author and Creator are now written from the document number and company, on both drivers: dompdf via addInfo(), Gotenberg via metadata(). dompdf needed more than the API call. It reads Title from the <title> element during render(), which happens after addInfo(), so metadata set through the API alone was silently overwritten by whatever the template put there and the two drivers disagreed about what the file was called. The title is written into the markup as well, escaped. Also adds an archival format setting for Gotenberg: off, PDF/A-1b, -2b or -3b. PDF/A-3 is what the EU e-invoicing formats expect. Verified against a stock gotenberg:8 -- LibreOffice inside the image does the conversion and the output carries the right pdfaid:part in its XMP -- so no extra components are needed. A fixed list rather than free text, because the SDK forwards whatever it is given and an unsupported value would surface only as an HTTP error from the service at render time. Empty is a real choice meaning an ordinary PDF, so it overrides an env default rather than falling through it. Gotenberg only: dompdf cannot produce PDF/A. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
138 lines
5.6 KiB
PHP
138 lines
5.6 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, array $metadata = []): ResponseStream
|
|
{
|
|
return new GotenbergPdfResponse(Gotenberg::send($this->buildRequest($template, $metadata)));
|
|
}
|
|
|
|
/**
|
|
* 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, array $metadata = []): 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();
|
|
}
|
|
|
|
// Archival conformance, converted by LibreOffice inside the Gotenberg
|
|
// image. PDF/A-3 is what the EU e-invoicing formats ask for. The value is
|
|
// passed through unvalidated by the SDK, so an unsupported one surfaces
|
|
// as an HTTP error from the service; the setting is a fixed list for
|
|
// that reason.
|
|
if ($pdfa = config('pdf.connections.gotenberg.pdfa')) {
|
|
$chromium->pdfa($pdfa);
|
|
}
|
|
|
|
if ($metadata !== []) {
|
|
$chromium->metadata($metadata);
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|