mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 15:12:12 +00:00
* fix(pdf): put the page margin back on the report PDFs
The report templates carry no inset of their own. They set
`.sub-container { padding: 0px 20px }` and nothing else, and relied entirely on
dompdf's built-in 1.2cm page margin. #727 made DompdfDriver always inject an
`@page` rule from PdfPageSetup, and #735 defaulted those margins to zero so
invoice2 and estimate2 could bleed their header band to the paper edge. The
document templates were fine, they carry their own 30px/50px insets. The reports
were not: every one of them now renders flush against the paper, with the
company name's glyph box actually clipped 1.2pt above the top edge.
The zero default has to stay for documents, so reports get a margin of their
own: `pdf.page.report_margin`, PDF_REPORT_MARGIN, defaulting to the 1.2cm they
were drawn against. It is a separate key on purpose, so an operator tuning the
document margins for their invoice template does not silently reflow every
report as a side effect.
A page margin rather than padding on the templates because reports run to
several pages and padding only insets the first one. Sales by customer already
spans two on the demo data, and page two moves with the rest.
Plumbed as an optional PdfPageSetup on the driver contract, defaulting to the
configured page, so every existing call site renders exactly as before and only
the five report controllers ask for anything different.
Measured on the five reports, page 595.28 x 841.89pt, 1.2cm = 34.02pt: first
page ink moves from xMin 15.0-15.8 / yMin -1.2 to xMin 49.0-49.8 / yMin 32.8,
every axis shifting by exactly the margin.
Also fixes three untranslated keys this exposed: the expenses report printed its
column headings as the literal strings "expenses.date", "expenses.note" and
"expenses.amount", which have never existed in lang/en.json. They are now
pdf_expense_{date,note,amount}_label, and a test pins that every translation key
a report template uses resolves in English.
* fix(pdf): put the minus sign in front of the currency symbol
format_money_pdf() formatted the signed value and then concatenated the symbol,
so a negative amount came out as "$-24,738.00". Credit notes made that common:
every line on a credit note PDF reads negative, and one credit note in a period
is enough to make the customer sales report show a negative total.
The magnitude is formatted first now and a single minus is prefixed to the whole
assembled string, so the sign leads and the symbol stays glued to the digits.
Only the symbol-first branch changes. number_format() already put the sign in
front of the digits, so a trailing-symbol currency read "-24,738.00$" before and
is byte-identical after.
The sign is decided on the formatted digits rather than on the raw input, so an
amount that rounds away at the currency's precision renders as zero rather than
as "-0". A stray cent on a zero-precision currency is the case that needs it.
* refactor(pdf): one shared chrome for the report PDFs
The five report templates were five drifted copies of one 2018 stylesheet, and
the insets had stopped agreeing with each other. profit-loss alone put its
header and income row at +20px, its "Expenses" heading at +23px, its category
rows at +30px, and its total rule and NET PROFIT band at +0, because that markup
sat outside the container everything above it was in. Four left edges on one
page. Every report also carried the same self-cancelling total rule, where
`padding: 0px` follows the two longhands it silently overrides, and expenses
carried six rule blocks nothing referenced at all, including the only horizontal
rule in the file.
There is now one layout partial and one stylesheet, and each report is content
only: 236 lines down to 47 for profit-loss, and about 1200 lines deleted across
the five. One content edge, measured: every band starts at 34.016pt and every
amount ends at 561.260pt, on every page of every report.
What changed on the page:
- Real tables with a thead, so column headings repeat across page breaks. Only
expenses had headings before and none of them used thead. sales-items emitted
a separate table per item, which is why its rows never lined up.
- The company logo in the header, the same fallback-to-name pattern the document
templates use.
- An empty period renders a "no records" row. profit-loss, sales-items and
tax-summary rendered their total row and rule unconditionally, so a month with
no data showed a heading, a gap, a rule and a lone $0.00.
- Sections stay whole across a page break where they fit, and a section heading
never sits at the foot of a page with its rows overleaf.
- Credit notes stay in the sales totals, since a reversal netting the sale out is
correct, but the line is tagged so a CN- number is not read as a sale. It
reuses the document's own label, which is already in the shipped locales.
- Labels stopped carrying their own presentation: "TOTAL EXPENSE" (also
singular) is "Total expenses" and the stylesheet does the uppercasing.
The five controllers drop the dead colour-settings block: nine *_color settings
were queried and shared by every report, no template ever read them, and no
migration, seeder or UI ever wrote them, so the query always returned an empty
collection. Every other shared variable name is untouched, because a custom
report template is a copy that references them by name.
make:template had to learn the same lesson: it only ever copied
partials/table.blade.php, so a cloned report would extend a layout that does not
exist in its namespace and die on render. It now copies every partial a type
ships and rewrites references by view name, including partial-to-partial ones,
so each custom template still gets its own copies.
179 lines
7.2 KiB
PHP
179 lines
7.2 KiB
PHP
<?php
|
|
|
|
namespace App\Support\Pdf;
|
|
|
|
use App\Services\FontService;
|
|
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 = [], ?PdfPageSetup $page = null): ResponseStream
|
|
{
|
|
return new GotenbergPdfResponse(Gotenberg::send($this->buildRequest($template, $metadata, $page)));
|
|
}
|
|
|
|
/**
|
|
* 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 = [], ?PdfPageSetup $page = null): 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));
|
|
}
|
|
|
|
$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', $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.
|
|
*
|
|
* 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();
|
|
}
|
|
}
|