Files
InvoiceShelf/app/Support/Pdf/PdfTemplateUtils.php
Darko Gjorgjijoski 00c9c4268e fix(pdf): give the reports their margin back, and one shared chrome (#738)
* 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.
2026-08-02 14:34:02 +02:00

310 lines
11 KiB
PHP

<?php
namespace App\Support\Pdf;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Str;
class PdfTemplateUtils
{
/**
* Find the formatted template
*
* @param string $imageFormat
* @return array|null
*/
public static function findFormattedTemplate($templateType, $templateName, $imageFormat = 'base64')
{
foreach (array_reverse(self::getFormattedTemplates($templateType, $imageFormat)) as $formattedTemplate) {
if ($formattedTemplate['name'] === $templateName) {
return $formattedTemplate;
}
}
return null;
}
/**
* Return the available formatted template paths
*
* @param string $imageFormat
* @return array|array[]
*/
public static function getFormattedTemplates($templateType, $imageFormat = 'base64')
{
$files_native = array_map(function ($file) {
return [
'path' => $file,
'custom' => false,
];
}, Storage::disk('views')->files(sprintf('/app/pdf/%s', $templateType)));
$files_custom = array_map(function ($file) {
return [
'path' => $file,
'custom' => true,
];
}, Storage::disk('pdf_templates')->files(sprintf('/%s', $templateType)));
$files = array_merge($files_native, $files_custom);
$files = array_filter($files, function ($file) {
if (! Str::endsWith($file['path'], '.blade.php')) {
return false;
}
// `{template}_header` / `{template}_footer` are companions rendered
// alongside their template by the Gotenberg driver, not templates in
// their own right. Without this they show up in the picker as
// selectable entries with no preview image.
return ! Str::endsWith(
Str::before(basename($file['path']), '.blade.php'),
['_header', '_footer']
);
});
$formatted = [];
foreach ($files as $file) {
$templateName = Str::before(basename($file['path']), '.blade.php');
if ($file['custom']) {
$imagePath = self::getCustomTemplateFilePath($templateType, sprintf('%s.png', $templateName));
// A custom template needs a same-named .png. Without one the
// picker used to render <img src=""> — a blank tile with no hint
// that anything was missing. Fall back to the preview of the
// template make:template clones from.
if (! File::exists($imagePath)) {
$imagePath = resource_path("static/img/PDF/{$templateType}1.png");
}
} else {
$imagePath = resource_path('static/img/PDF/'.$templateName.'.png');
}
if (empty($imageFormat)) {
$imageValue = '';
} elseif ($imageFormat == 'path') {
$imageValue = $imagePath;
} else {
$imageValue = File::exists($imagePath) ? ImageUtils::toBase64Src($imagePath) : '';
}
// Keyed by name so a custom template that shares a built-in's name
// appears once rather than twice. Custom entries come last and so
// win, which matches what findFormattedTemplate() already resolved
// to — the picker just used to show both tiles with no way to tell
// which one you were clicking.
$formatted[$templateName] = [
'name' => $templateName,
'path' => $imageValue,
'custom' => $file['custom'],
];
}
return array_values($formatted);
}
/**
* The view to render for a document, preferring a custom override.
*
* Invoices and estimates let you pick between several designs, so their
* template is chosen per document and $fallback is the design to use when
* that choice cannot be honoured. Payment receipts and reports have no
* chooser and no design to pick, so overriding one means dropping a
* same-named file into storage/app/templates/pdf/{type}/ and having it win.
*
* The fallback matters because the stored name is not trustworthy. It is
* validated when a document is saved through the UI, but seeders, imports,
* recurring-invoice copies and rows predating that validation all bypass it
* — and an unresolvable name used to reach `$template['custom']` on null and
* take the whole PDF route down with a 500.
*/
public static function resolveView(string $templateType, ?string $templateName, ?string $fallback = null): string
{
foreach (array_filter([$templateName, $fallback]) as $candidate) {
// View::exists rather than a disk check: the namespace is what
// actually renders, so asking it directly means the two cannot
// disagree about where custom templates live.
foreach ([
sprintf('pdf_templates::%s.%s', $templateType, $candidate),
sprintf('app.pdf.%s.%s', $templateType, $candidate),
] as $view) {
if (View::exists($view)) {
if ($candidate !== $templateName) {
Log::warning('PDF template not found, falling back.', [
'type' => $templateType,
'requested' => $templateName,
'used' => $candidate,
]);
}
return $view;
}
}
}
// Nothing resolved. Name the built-in path so the failure points at
// something real rather than at whatever was stored.
return sprintf('app.pdf.%s.%s', $templateType, $fallback ?? $templateName);
}
/**
* Returns custom template path
*
* @param string $fileName
*/
public static function getCustomTemplateFilePath($templateType, $fileName = ''): string
{
$path = ! empty($fileName) ? sprintf('/%s/%s', $templateType, $fileName) : sprintf('/%s/', $templateType);
return Storage::disk('pdf_templates')->path($path);
}
/**
* Check if custom template exists.
*
* @param $templateName
* @return string
*/
public static function customTemplateFileExists($templateType, $fileName)
{
return Storage::disk('pdf_templates')->exists(sprintf('/%s/%s', $templateType, $fileName));
}
/**
* Save template markup file
*
* @return bool|string
*/
public static function toCustomTemplateMarkupFile($contents, $templateType, $templateName)
{
return self::toCustomTemplateFile($contents, $templateType, $templateName.'.blade.php');
}
/**
* Save template image file
*
*
* @return bool|string
*/
public static function toCustomTemplateImageFile($contents, $templateType, $templateName, $imageType = 'png')
{
return self::toCustomTemplateFile($contents, $templateType, $templateName.'.'.$imageType);
}
/**
* Save file contents into a template file of specific template type.
*
*
* @return bool|string
*/
public static function toCustomTemplateFile($contents, $templateType, $fileName)
{
return Storage::disk('pdf_templates')->put(
sprintf('/%s/%s', $templateType, $fileName),
$contents
);
}
/**
* Where a custom template's own copies of its type's partials live, keyed by
* the built-in view name each one replaces.
*
* A per-template copy is what keeps two custom templates of the same type
* independent: they used to share one partials/table.blade.php, written once
* and then reused, so editing the table for one silently changed it for all
* of them.
*
* @return array<string, string>
*/
public static function partialViewMap(string $templateType, string $templateName): array
{
$map = [];
foreach (self::stockPartials($templateType) as $partial) {
$view = str_replace('/', '.', $partial);
$map[sprintf('app.pdf.%s.partials.%s', $templateType, $view)] = sprintf(
'pdf_templates::%s.partials.%s.%s',
$templateType,
$templateName,
$view,
);
}
return $map;
}
/**
* Repoint the view names in some Blade markup.
*
* Keyed by view name rather than by directive on purpose: a partial is named
* by @include, by @extends and by several others, and a clone has to follow
* every one of them.
*
* @param array<string, string> $map
*/
public static function rewriteViewReferences(string $markup, array $map): string
{
return Str::replace(array_keys($map), array_values($map), $markup);
}
/**
* Give a custom template its own copy of every partial its type ships.
*
* References between partials are rewritten in the copies too, so a copied
* layout includes the copied stylesheet rather than the built-in one.
* Nesting of any depth is covered, because every copy is rewritten with the
* same map. Views outside the type's own partials directory, notably the
* cross-type app.pdf.partials.*, are deliberately left pointing at the
* built-ins: they are shared chrome, not part of the design being cloned.
*/
public static function copyTemplatePartials(string $templateType, string $templateName): void
{
$map = self::partialViewMap($templateType, $templateName);
foreach (self::stockPartials($templateType) as $partial) {
$contents = Storage::disk('views')->get(
sprintf('/app/pdf/%s/partials/%s.blade.php', $templateType, $partial)
);
self::toCustomTemplateFile(
self::rewriteViewReferences($contents, $map),
$templateType,
sprintf('partials/%s/%s.blade.php', $templateName, $partial),
);
}
}
/**
* The partials a template type ships, as paths relative to its partials
* directory and without the .blade.php suffix.
*
* @return array<int, string>
*/
private static function stockPartials(string $templateType): array
{
$directory = sprintf('app/pdf/%s/partials', $templateType);
$partials = [];
foreach (Storage::disk('views')->allFiles($directory) as $file) {
if (! Str::endsWith($file, '.blade.php')) {
continue;
}
$partials[] = Str::before(Str::after($file, $directory.'/'), '.blade.php');
}
// Longest name first, so a partial whose name is a prefix of another
// one cannot rewrite the leading segment of the longer name.
usort($partials, fn (string $a, string $b) => strlen($b) <=> strlen($a));
return $partials;
}
}