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.
206 lines
7.4 KiB
PHP
206 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace App\Support\Pdf;
|
|
|
|
/**
|
|
* The page geometry both drivers render to, resolved once and translated per driver.
|
|
*
|
|
* Paper size used to be a Gotenberg-only setting stored as "210mm 297mm", while
|
|
* dompdf was pinned to whatever `config/dompdf.php` said and had no admin control
|
|
* at all. The two also disagreed about margins: dompdf falls back to its own
|
|
* stylesheet default of 1.2cm, Gotenberg was hardcoded to zero, so the same
|
|
* template came out differently depending on the driver. Both now default to
|
|
* nothing: the stock templates carry their own insets, and invoice2/estimate2
|
|
* are built around a header band that only reaches the paper edge at margin 0.
|
|
*
|
|
* The reports are the exception, which is what forReports() is for. Their
|
|
* templates never carried an inset of their own and were drawn against dompdf's
|
|
* built-in 1.2cm, so they need a real page margin put back, from their own
|
|
* config key rather than the document one.
|
|
*
|
|
* Dimensions are stored as CSS lengths because that is the only representation
|
|
* both drivers take without loss. Gotenberg has no notion of named sizes, only
|
|
* dimensions; dompdf accepts either a name from its own 66-entry table or a
|
|
* points array, and the points array is the branch that can express anything.
|
|
* Named presets are a convenience in the UI that resolve to a pair of lengths.
|
|
*/
|
|
final class PdfPageSetup
|
|
{
|
|
/** Points per unit. CSS px is 1/96in, PDF points are 1/72in. */
|
|
private const POINTS_PER_UNIT = [
|
|
'pt' => 1.0,
|
|
'px' => 0.75,
|
|
'pc' => 12.0,
|
|
'mm' => 72 / 25.4,
|
|
'cm' => 720 / 25.4,
|
|
'in' => 72.0,
|
|
];
|
|
|
|
private function __construct(
|
|
public readonly string $width,
|
|
public readonly string $height,
|
|
public readonly string $orientation,
|
|
public readonly string $marginTop,
|
|
public readonly string $marginRight,
|
|
public readonly string $marginBottom,
|
|
public readonly string $marginLeft,
|
|
) {}
|
|
|
|
public static function fromConfig(): self
|
|
{
|
|
return new self(
|
|
width: self::length('pdf.page.paper_width', '210mm'),
|
|
height: self::length('pdf.page.paper_height', '297mm'),
|
|
orientation: config('pdf.page.orientation') === 'landscape' ? 'landscape' : 'portrait',
|
|
marginTop: self::length('pdf.page.margin_top', '0'),
|
|
marginRight: self::length('pdf.page.margin_right', '0'),
|
|
marginBottom: self::length('pdf.page.margin_bottom', '0'),
|
|
marginLeft: self::length('pdf.page.margin_left', '0'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The configured page, but with the report margin on all four sides.
|
|
*
|
|
* Reports are the one family of templates that carries no inset of its own:
|
|
* they only set `.sub-container { padding: 0px 20px }` and relied on dompdf's
|
|
* built-in 1.2cm page margin, which stopped applying once DompdfDriver began
|
|
* injecting an @page rule from config. The document margins default to zero
|
|
* and must stay that way (invoice2 and estimate2 bleed a header band to the
|
|
* paper edge), so the reports get their own margin instead of inheriting
|
|
* those. Paper size and orientation still come from config: only the margins
|
|
* are overridden.
|
|
*
|
|
* A page margin rather than body padding because reports run to several
|
|
* pages, and padding only insets the first one.
|
|
*/
|
|
public static function forReports(): self
|
|
{
|
|
return self::fromConfig()->withUniformMargin(self::length('pdf.page.report_margin', '1.2cm'));
|
|
}
|
|
|
|
/**
|
|
* A copy of this page with the same paper and orientation, and the given
|
|
* margin on all four sides.
|
|
*/
|
|
public function withUniformMargin(string $margin): self
|
|
{
|
|
$margin = self::assertLength($margin, "Invalid PDF page margin: \"{$margin}\".");
|
|
|
|
return new self(
|
|
width: $this->width,
|
|
height: $this->height,
|
|
orientation: $this->orientation,
|
|
marginTop: $margin,
|
|
marginRight: $margin,
|
|
marginBottom: $margin,
|
|
marginLeft: $margin,
|
|
);
|
|
}
|
|
|
|
public function isLandscape(): bool
|
|
{
|
|
return $this->orientation === 'landscape';
|
|
}
|
|
|
|
/**
|
|
* Portrait dimensions for Gotenberg's paperSize(). Orientation is applied
|
|
* separately via landscape(), which does the swap itself.
|
|
*
|
|
* @return array{0: string, 1: string}
|
|
*/
|
|
public function gotenbergPaper(): array
|
|
{
|
|
return [$this->width, $this->height];
|
|
}
|
|
|
|
/**
|
|
* Gotenberg's margins() takes top, bottom, left, right — note the order,
|
|
* which is not the CSS one.
|
|
*
|
|
* @return array{0: string, 1: string, 2: string, 3: string}
|
|
*/
|
|
public function gotenbergMargins(): array
|
|
{
|
|
return [$this->marginTop, $this->marginBottom, $this->marginLeft, $this->marginRight];
|
|
}
|
|
|
|
/**
|
|
* Points array for dompdf's setPaper(). Always portrait: Dompdf::getPaperSize()
|
|
* swaps the axes itself when the orientation argument says landscape, so
|
|
* pre-swapping here would cancel out.
|
|
*
|
|
* @return array{0: float, 1: float, 2: float, 3: float}
|
|
*/
|
|
public function dompdfPaper(): array
|
|
{
|
|
return [0.0, 0.0, self::toPoints($this->width), self::toPoints($this->height)];
|
|
}
|
|
|
|
/**
|
|
* dompdf has no margin API at all — margins come from the `@page` box, so
|
|
* the only lever is CSS. See DompdfDriver, which injects this.
|
|
*/
|
|
public function marginCss(): string
|
|
{
|
|
return "{$this->marginTop} {$this->marginRight} {$this->marginBottom} {$this->marginLeft}";
|
|
}
|
|
|
|
public static function toPoints(string $length): float
|
|
{
|
|
$length = trim($length);
|
|
|
|
if ($length === '0') {
|
|
return 0.0;
|
|
}
|
|
|
|
if (! preg_match('/^(\d+(?:\.\d+)?)(pt|px|pc|mm|cm|in)$/', $length, $m)) {
|
|
throw new \InvalidArgumentException("Invalid PDF page length: {$length}");
|
|
}
|
|
|
|
return (float) $m[1] * self::POINTS_PER_UNIT[$m[2]];
|
|
}
|
|
|
|
/**
|
|
* Unset or blank falls back to the default; anything set but malformed
|
|
* throws.
|
|
*
|
|
* Values are validated on save, but config can also come from the
|
|
* environment, and the drivers would fail differently otherwise: dompdf
|
|
* throws while converting to points, whereas Gotenberg would forward the
|
|
* garbage and render at some other size. Failing here keeps them consistent
|
|
* and names the offending key.
|
|
*/
|
|
private static function length(string $key, string $fallback): string
|
|
{
|
|
$value = config($key);
|
|
|
|
if (! is_string($value) || trim($value) === '') {
|
|
return $fallback;
|
|
}
|
|
|
|
$value = trim($value);
|
|
|
|
return self::assertLength($value, "Invalid PDF page length for {$key}: \"{$value}\".");
|
|
}
|
|
|
|
/**
|
|
* A bare 0 needs no unit; anything else is a number and one of the units
|
|
* both drivers understand. Shared by length() and withUniformMargin() so
|
|
* there is one definition of what a valid length is, with the caller
|
|
* supplying the part of the message that says where the bad value came from.
|
|
*/
|
|
private static function assertLength(string $value, string $context): string
|
|
{
|
|
$value = trim($value);
|
|
|
|
if (! preg_match('/^(0|\d+(\.\d+)?(pt|px|pc|mm|cm|in))$/', $value)) {
|
|
throw new \InvalidArgumentException(
|
|
$context.' Expected 0, or a number and a unit, e.g. "210mm".'
|
|
);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|