From a54a5ee0078a62f7875bddb6475d88ca2c892ba5 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:48:51 +0200 Subject: [PATCH] feat(pdf): one page setup, honoured by both drivers (#728) Paper size was a Gotenberg-only setting stored as a single "210mm 297mm" string. dompdf had no page settings at all: size was pinned to config/dompdf.php's fixed 'a4', its top-level `orientation` key was read by nothing (the installed barryvdh v3 builds options only from `defines`), and margins were whatever dompdf's own stylesheet said. So the two drivers disagreed about margins by default -- dompdf 1.2cm, Gotenberg hardcoded to zero -- and selecting dompdf silently discarded the paper size. Replaces gotenberg_papersize with pdf_paper_width / pdf_paper_height / pdf_orientation / pdf_margin_{top,right,bottom,left}, saved and applied for either driver. Width and height are separate CSS lengths because that is the only lossless shared notation: Gotenberg has no named sizes, and dompdf's named table cannot express everything Gotenberg accepts. Named presets (A3/A4/A5/ Letter/Legal) are a convenience in the UI that resolve to a pair of lengths. PdfPageSetup resolves it once and translates: a points array plus an orientation argument for dompdf, CSS lengths plus landscape() for Gotenberg. Both are handed the portrait pair, since each swaps the axes itself. Gotenberg's margins() takes top, bottom, left, right, which is not the CSS order. dompdf exposes no margin API, so DompdfDriver injects an @page rule -- at the top of , so a template declaring its own still wins. Doing it in the driver rather than a Blade partial means custom templates get it without including anything. Margins default to 1.2cm, dompdf's existing default, so Gotenberg starts matching it rather than rendering edge-to-edge. Verified against a live gotenberg:8: A4 portrait, A4 landscape and Letter at zero margins all come out with the same page box and the same ink offsets on both drivers. A malformed length now throws rather than being ignored. Blank still falls back, but a value that is set and wrong is an operator mistake, and the drivers would otherwise fail differently: dompdf throws converting to points, Gotenberg would forward the string and render at some other size. Also here: - Migration splits an existing gotenberg_papersize into the new pair. It earns its place because that key ships in 2.x, not just a 3.x alpha, so a stable install that chose Letter would otherwise come back up on A4. Drops gotenberg_margins, which 2.x also stores and neither driver ever read. - Removes EnvironmentManager::savePDFVariables/getPDFConfiguration, which had no caller anywhere, and the unused EnvironmentManager injection in the controller. - config/dompdf.php: drops the dead `orientation` key and defaults enable_remote to false, matching .env.example, which sets it explicitly and explains why. Installs predating that line were falling back to true. - Retires the settings.pdf.footer_text and pdf_layout strings, which no component referenced. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF --- .env.example | 13 +- .../Settings/PDFConfigurationController.php | 72 ++++---- app/Http/Requests/PDFConfigurationRequest.php | 87 +++++---- app/Providers/AppConfigProvider.php | 52 +++--- app/Rules/CssLength.php | 29 +++ app/Support/Pdf/DompdfDriver.php | 32 +++- app/Support/Pdf/GotenbergPdfDriver.php | 38 ++-- app/Support/Pdf/PdfPageSetup.php | 140 ++++++++++++++ app/Support/Setup/EnvironmentManager.php | 55 ------ config/dompdf.php | 18 +- config/pdf.php | 27 ++- ...tenberg_papersize_to_shared_page_setup.php | 56 ++++++ lang/en.json | 22 ++- resources/scripts/api/services/pdf.service.ts | 19 +- .../components/settings/AdminPdfDomDriver.vue | 35 +++- .../settings/AdminPdfGotenbergDriver.vue | 62 +++---- .../components/settings/AdminPdfPageSetup.vue | 171 ++++++++++++++++++ .../admin/components/settings/pdfPageSetup.ts | 68 +++++++ tests/Feature/Admin/AdminSettingsTest.php | 81 ++++++++- .../Admin/PdfPageSetupMigrationTest.php | 52 ++++++ tests/Unit/GotenbergPdfDriverTest.php | 11 +- tests/Unit/PdfDriverPageParityTest.php | 109 +++++++++++ tests/Unit/PdfPageSetupTest.php | 106 +++++++++++ 23 files changed, 1104 insertions(+), 251 deletions(-) create mode 100644 app/Rules/CssLength.php create mode 100644 app/Support/Pdf/PdfPageSetup.php create mode 100644 database/migrations/2026_07_29_120000_move_gotenberg_papersize_to_shared_page_setup.php create mode 100644 resources/scripts/features/admin/components/settings/AdminPdfPageSetup.vue create mode 100644 resources/scripts/features/admin/components/settings/pdfPageSetup.ts create mode 100644 tests/Feature/Admin/PdfPageSetupMigrationTest.php create mode 100644 tests/Unit/PdfDriverPageParityTest.php create mode 100644 tests/Unit/PdfPageSetupTest.php diff --git a/.env.example b/.env.example index 8d70d2d1..f35eabc2 100644 --- a/.env.example +++ b/.env.example @@ -22,10 +22,21 @@ TRUSTED_PROXIES="*" # Set true only if you fully trust all PDF HTML and need remote images/CSS. DOMPDF_ENABLE_REMOTE=false +# Page setup, applied by whichever driver renders. Sizes and margins are a +# number plus a unit (pt, px, pc, mm, cm, in). The 1.2cm margin default is +# dompdf's own, so both drivers lay out identically out of the box. Normally set +# under Settings -> PDF Generation; these are the defaults before anything is saved. +# PDF_PAPER_WIDTH=210mm +# PDF_PAPER_HEIGHT=297mm +# PDF_ORIENTATION=portrait +# PDF_MARGIN_TOP=1.2cm +# PDF_MARGIN_RIGHT=1.2cm +# PDF_MARGIN_BOTTOM=1.2cm +# PDF_MARGIN_LEFT=1.2cm + # Gotenberg (optional alternative PDF driver; the default is dompdf). # PDF_DRIVER=gotenberg # GOTENBERG_HOST=http://pdf:3000 -# GOTENBERG_PAPERSIZE="210mm 297mm" # # Gotenberg is normally a sidecar on a private network, which the SSRF guard # rejects. Name that one host to exempt it — and only it. Every other private diff --git a/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php b/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php index 0ebb68cd..7ca2783a 100644 --- a/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php +++ b/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php @@ -5,21 +5,24 @@ namespace App\Http\Controllers\Admin\Settings; use App\Http\Controllers\Controller; use App\Http\Requests\PDFConfigurationRequest; use App\Models\Setting; -use App\Support\Setup\EnvironmentManager; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; class PDFConfigurationController extends Controller { - protected EnvironmentManager $environmentManager; - /** - * Constructor + * Driver-neutral page geometry. Each maps onto `pdf.page.*` in config, and is + * read and written for every driver rather than being nested under one. */ - public function __construct(EnvironmentManager $environmentManager) - { - $this->environmentManager = $environmentManager; - } + private const PAGE_SETTINGS = [ + 'pdf_paper_width', + 'pdf_paper_height', + 'pdf_orientation', + 'pdf_margin_top', + 'pdf_margin_right', + 'pdf_margin_bottom', + 'pdf_margin_left', + ]; /** * Returns the available drivers @@ -47,21 +50,22 @@ class PDFConfigurationController extends Controller { $this->authorize('manage pdf config'); - // Get PDF settings from database - $pdfSettings = Setting::getSettings([ - 'pdf_driver', - 'gotenberg_host', - 'gotenberg_papersize', - 'gotenberg_margins', - ]); + $pdfSettings = Setting::getSettings(array_merge( + ['pdf_driver', 'gotenberg_host'], + self::PAGE_SETTINGS, + )); $config = [ 'pdf_driver' => $pdfSettings['pdf_driver'] ?? config('pdf.driver'), 'gotenberg_host' => $pdfSettings['gotenberg_host'] ?? config('pdf.connections.gotenberg.host'), - 'gotenberg_margins' => $pdfSettings['gotenberg_margins'] ?? config('pdf.connections.gotenberg.margins'), - 'gotenberg_papersize' => $pdfSettings['gotenberg_papersize'] ?? config('pdf.connections.gotenberg.papersize'), ]; + // Page geometry applies to whichever driver is selected, so it is always + // returned rather than nested under a driver branch. + foreach (self::PAGE_SETTINGS as $setting) { + $config[$setting] = $pdfSettings[$setting] ?? config(self::configKeyFor($setting)); + } + return response()->json($config); } @@ -92,26 +96,28 @@ class PDFConfigurationController extends Controller { $driver = $request->get('pdf_driver'); - // Base settings that are always saved - $settings = [ - 'pdf_driver' => $driver, - ]; + $settings = ['pdf_driver' => $driver]; - // Driver-specific settings - switch ($driver) { - case 'gotenberg': - $settings = array_merge($settings, [ - 'gotenberg_host' => $request->get('gotenberg_host'), - 'gotenberg_papersize' => $request->get('gotenberg_papersize'), - 'gotenberg_margins' => $request->get('gotenberg_margins'), - ]); - break; + // Page geometry is saved for every driver: switching between them should + // not lose the paper size, which is what happened while it was a + // Gotenberg-only setting. + foreach (self::PAGE_SETTINGS as $setting) { + $settings[$setting] = $request->get($setting); + } - case 'dompdf': - // dompdf doesn't have additional configuration in the current setup - break; + if ($driver === 'gotenberg') { + $settings['gotenberg_host'] = $request->get('gotenberg_host'); } return $settings; } + + /** + * Maps a settings key onto its config counterpart, e.g. + * `pdf_margin_top` -> `pdf.page.margin_top`. + */ + private static function configKeyFor(string $setting): string + { + return 'pdf.page.'.substr($setting, strlen('pdf_')); + } } diff --git a/app/Http/Requests/PDFConfigurationRequest.php b/app/Http/Requests/PDFConfigurationRequest.php index f6718b28..4e4408bb 100644 --- a/app/Http/Requests/PDFConfigurationRequest.php +++ b/app/Http/Requests/PDFConfigurationRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\CssLength; use App\Rules\PublicHttpUrl; use App\Support\Pdf\GotenbergHostPolicy; use Illuminate\Foundation\Http\FormRequest; @@ -22,55 +23,49 @@ class PDFConfigurationRequest extends FormRequest */ public function rules(): array { - switch ($this->get('pdf_driver')) { - case 'dompdf': - return [ - 'pdf_driver' => [ - 'required', - 'string', - ], - ]; + return array_merge($this->pageRules(), $this->driverRules()); + } - case 'gotenberg': - // The operator-declared Gotenberg host skips the private-network - // check; anything else is still held to it. See GotenbergHostPolicy. - $isDeclaredHost = GotenbergHostPolicy::isExemptFromPrivateNetworkGuard( - $this->input('gotenberg_host') - ); + /** + * Page geometry is driver-neutral, so it is validated the same way whichever + * driver is selected. Paper size used to live under `gotenberg_papersize` as + * a single "210mm 297mm" string, which dompdf had no equivalent of and could + * not consume. + */ + private function pageRules(): array + { + $length = ['nullable', 'string', new CssLength]; - return [ - 'pdf_driver' => [ - 'required', - 'string', - ], - 'gotenberg_host' => [ - 'required', - 'url', - Rule::when(! $isDeclaredHost, [new PublicHttpUrl]), - ], - 'gotenberg_papersize' => [ - 'required', - 'string', - function ($attribute, $value, $fail) { - $reg = "/^\d+(pt|px|pc|mm|cm|in) \d+(pt|px|pc|mm|cm|in)$/"; - if (! preg_match($reg, $value)) { - $fail('Invalid papersize, must be in format "210mm 297mm". Accepts: pt,px,pc,mm,cm,in'); - } - }, - ], - 'gotenberg_margins' => [ - 'nullable', - 'string', - ], - ]; + return [ + 'pdf_driver' => ['required', 'string'], + 'pdf_paper_width' => ['required', 'string', new CssLength], + 'pdf_paper_height' => ['required', 'string', new CssLength], + 'pdf_orientation' => ['required', Rule::in(['portrait', 'landscape'])], + 'pdf_margin_top' => $length, + 'pdf_margin_right' => $length, + 'pdf_margin_bottom' => $length, + 'pdf_margin_left' => $length, + ]; + } - default: - return [ - 'pdf_driver' => [ - 'required', - 'string', - ], - ]; + private function driverRules(): array + { + if ($this->get('pdf_driver') !== 'gotenberg') { + return []; } + + // The operator-declared Gotenberg host skips the private-network + // check; anything else is still held to it. See GotenbergHostPolicy. + $isDeclaredHost = GotenbergHostPolicy::isExemptFromPrivateNetworkGuard( + $this->input('gotenberg_host') + ); + + return [ + 'gotenberg_host' => [ + 'required', + 'url', + Rule::when(! $isDeclaredHost, [new PublicHttpUrl]), + ], + ]; } } diff --git a/app/Providers/AppConfigProvider.php b/app/Providers/AppConfigProvider.php index 071f9600..dcba6331 100644 --- a/app/Providers/AppConfigProvider.php +++ b/app/Providers/AppConfigProvider.php @@ -46,37 +46,35 @@ class AppConfigProvider extends ServiceProvider protected function configurePDFFromDatabase(): void { try { - // Get PDF settings from database - $pdfSettings = Setting::getSettings([ - 'pdf_driver', - 'gotenberg_host', - 'gotenberg_papersize', - 'gotenberg_margins', - ]); + $pageSettings = [ + 'pdf_paper_width' => 'pdf.page.paper_width', + 'pdf_paper_height' => 'pdf.page.paper_height', + 'pdf_orientation' => 'pdf.page.orientation', + 'pdf_margin_top' => 'pdf.page.margin_top', + 'pdf_margin_right' => 'pdf.page.margin_right', + 'pdf_margin_bottom' => 'pdf.page.margin_bottom', + 'pdf_margin_left' => 'pdf.page.margin_left', + ]; + + $pdfSettings = Setting::getSettings(array_merge( + ['pdf_driver', 'gotenberg_host'], + array_keys($pageSettings), + )); if (! empty($pdfSettings['pdf_driver'])) { - $driver = $pdfSettings['pdf_driver']; + Config::set('pdf.driver', $pdfSettings['pdf_driver']); - // Set PDF driver - Config::set('pdf.driver', $driver); + if ($pdfSettings['pdf_driver'] === 'gotenberg' && ! empty($pdfSettings['gotenberg_host'])) { + Config::set('pdf.connections.gotenberg.host', $pdfSettings['gotenberg_host']); + } + } - // Configure based on driver - switch ($driver) { - case 'gotenberg': - if (! empty($pdfSettings['gotenberg_host'])) { - Config::set('pdf.connections.gotenberg.host', $pdfSettings['gotenberg_host']); - } - if (! empty($pdfSettings['gotenberg_papersize'])) { - Config::set('pdf.connections.gotenberg.papersize', $pdfSettings['gotenberg_papersize']); - } - if (! empty($pdfSettings['gotenberg_margins'])) { - Config::set('pdf.connections.gotenberg.margins', $pdfSettings['gotenberg_margins']); - } - break; - - case 'dompdf': - // dompdf doesn't have additional configuration in the current setup - break; + // Page geometry is applied regardless of driver. Note the isset guard + // rather than !empty: a saved margin of "0mm" is a deliberate choice, + // and !empty() would discard it and silently fall back to the default. + foreach ($pageSettings as $setting => $configKey) { + if (isset($pdfSettings[$setting]) && trim((string) $pdfSettings[$setting]) !== '') { + Config::set($configKey, $pdfSettings[$setting]); } } } catch (\Exception $e) { diff --git a/app/Rules/CssLength.php b/app/Rules/CssLength.php new file mode 100644 index 00000000..412c5475 --- /dev/null +++ b/app/Rules/CssLength.php @@ -0,0 +1,29 @@ +wrapper()->loadView($template)); + $page = PdfPageSetup::fromConfig(); + + $pdf = $this->wrapper(); + $pdf->setPaper($page->dompdfPaper(), $page->orientation); + $pdf->loadHTML($this->withPageMargins(view($template)->render(), $page)); + + return new DompdfResponse($pdf); + } + + /** + * dompdf exposes no margin API — the page box comes from an `@page` rule, so + * CSS is the only lever (see PdfPageSetup::marginCss). + * + * Injected at the top of rather than the bottom so a template that + * declares its own `@page` still wins, later rules of equal specificity + * taking precedence. Prepending is the fallback for markup with no , + * which dompdf tolerates. + */ + private function withPageMargins(string $html, PdfPageSetup $page): string + { + $style = ''; + + $injected = preg_replace('/(]*>)/i', '$1'.$style, $html, 1, $count); + + return $count ? $injected : $style.$html; } protected function wrapper(): PDF diff --git a/app/Support/Pdf/GotenbergPdfDriver.php b/app/Support/Pdf/GotenbergPdfDriver.php index 2461436c..3efe91f8 100644 --- a/app/Support/Pdf/GotenbergPdfDriver.php +++ b/app/Support/Pdf/GotenbergPdfDriver.php @@ -24,10 +24,9 @@ class GotenbergPdfDriver implements PdfDriver */ public function buildRequest(string $template): RequestInterface { - $papersize = explode(' ', config('pdf.connections.gotenberg.papersize')); - if (count($papersize) != 2) { - throw new \InvalidArgumentException('Invalid Gotenberg Papersize specified'); - } + $page = PdfPageSetup::fromConfig(); + [$width, $height] = $page->gotenbergPaper(); + [$marginTop, $marginBottom, $marginLeft, $marginRight] = $page->gotenbergMargins(); $host = config('pdf.connections.gotenberg.host'); @@ -45,7 +44,7 @@ class GotenbergPdfDriver implements PdfDriver } } - return Gotenberg::chromium($host) + $chromium = Gotenberg::chromium($host) ->pdf() // Only affects the root (body/html) background: Chromium paints // element backgrounds either way, verified against gotenberg:8, so @@ -57,16 +56,23 @@ class GotenbergPdfDriver implements PdfDriver // Align them so a template with media queries behaves the same either // way rather than depending on which driver is selected. ->emulateScreenMediaType() - ->margins(0, 0, 0, 0) - ->paperSize($papersize[0], $papersize[1]) - ->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(), - ) - ); + ->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(); + } + + 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(), + ) + ); } } diff --git a/app/Support/Pdf/PdfPageSetup.php b/app/Support/Pdf/PdfPageSetup.php new file mode 100644 index 00000000..f706b9ac --- /dev/null +++ b/app/Support/Pdf/PdfPageSetup.php @@ -0,0 +1,140 @@ + 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', '1.2cm'), + marginRight: self::length('pdf.page.margin_right', '1.2cm'), + marginBottom: self::length('pdf.page.margin_bottom', '1.2cm'), + marginLeft: self::length('pdf.page.margin_left', '1.2cm'), + ); + } + + 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 + { + if (! preg_match('/^(\d+(?:\.\d+)?)(pt|px|pc|mm|cm|in)$/', trim($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); + + if (! preg_match('/^\d+(\.\d+)?(pt|px|pc|mm|cm|in)$/', $value)) { + throw new \InvalidArgumentException( + "Invalid PDF page length for {$key}: \"{$value}\". Expected a number and a unit, e.g. \"210mm\"." + ); + } + + return $value; + } +} diff --git a/app/Support/Setup/EnvironmentManager.php b/app/Support/Setup/EnvironmentManager.php index 1b6170e8..67b494d5 100755 --- a/app/Support/Setup/EnvironmentManager.php +++ b/app/Support/Setup/EnvironmentManager.php @@ -4,7 +4,6 @@ namespace App\Support\Setup; use App\Http\Requests\DatabaseEnvironmentRequest; use App\Http\Requests\DomainEnvironmentRequest; -use App\Http\Requests\PDFConfigurationRequest; use Exception; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; @@ -263,60 +262,6 @@ class EnvironmentManager || preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1; } - /** - * Save the pdf generation content to the .env file. - * - * @return array - */ - public function savePDFVariables(PDFConfigurationRequest $request) - { - $pdfEnv = $this->getPDFConfiguration($request); - - try { - - $this->updateEnv($pdfEnv); - } catch (Exception $e) { - return [ - 'error' => 'pdf_variables_save_error', - ]; - } - - return [ - 'success' => 'pdf_variables_save_successfully', - ]; - } - - /** - * Returns the pdf configuration - * - * @param PDFConfigurationRequest $request - * @return array - */ - private function getPDFConfiguration($request) - { - $pdfEnv = []; - - $driver = $request->get('pdf_driver'); - - switch ($driver) { - case 'dompdf': - $pdfEnv = [ - 'PDF_DRIVER' => $request->get('pdf_driver'), - ]; - break; - case 'gotenberg': - $pdfEnv = [ - 'PDF_DRIVER' => $request->get('pdf_driver'), - 'GOTENBERG_HOST' => $request->get('gotenberg_host'), - 'GOTENBERG_MARGINS' => $request->get('gotenberg_margins'), - 'GOTENBERG_PAPERSIZE' => $request->get('gotenberg_papersize'), - ]; - break; - } - - return $pdfEnv; - } - /** * Save sanctum stateful domain to the .env file. * diff --git a/config/dompdf.php b/config/dompdf.php index 7676c9b8..9e7bcf43 100644 --- a/config/dompdf.php +++ b/config/dompdf.php @@ -12,7 +12,15 @@ return [ | */ 'show_warnings' => false, // Throw an Exception on warnings from dompdf - 'orientation' => 'portrait', + + /* + * Note: this file predates the installed barryvdh/laravel-dompdf (v3.x), + * whose service provider builds options exclusively from `defines` below. + * A top-level `orientation` key used to sit here and was read by nothing. + * Page size, orientation and margins now come from `pdf.page` and are + * applied per render by DompdfDriver, so they are the same on both drivers. + */ + 'defines' => [ /** * The location of the DOMPDF font directory @@ -227,7 +235,13 @@ return [ * * @var bool */ - 'enable_remote' => env('DOMPDF_ENABLE_REMOTE', true), + /* + * Defaults to false to match .env.example, which sets it explicitly and + * explains why. Fresh installs copy that file so they were already safe, + * but an install predating the line has no such entry and was falling + * back to true here -- the opposite of the documented intent. + */ + 'enable_remote' => env('DOMPDF_ENABLE_REMOTE', false), /** * A ratio applied to the fonts height to be more like browsers' line height diff --git a/config/pdf.php b/config/pdf.php index cbf65a7a..c290c0cb 100644 --- a/config/pdf.php +++ b/config/pdf.php @@ -13,6 +13,32 @@ return [ 'driver' => env('PDF_DRIVER', 'dompdf'), + /* + |-------------------------------------------------------------------------- + | Page Setup + |-------------------------------------------------------------------------- + | Geometry applied to every document, whichever driver renders it. Sizes and + | margins are CSS lengths (pt, px, pc, mm, cm, in) because that is the only + | notation both drivers accept without loss — Gotenberg has no named sizes, + | and dompdf's points array can express anything a name can. + | + | The 1.2cm margin default is dompdf's own, from its user-agent stylesheet. + | Gotenberg used to be hardcoded to zero, so the same template came out + | edge-to-edge on one driver and inset on the other; matching dompdf keeps + | existing documents looking as they always have. + | + */ + + 'page' => [ + 'paper_width' => env('PDF_PAPER_WIDTH', '210mm'), + 'paper_height' => env('PDF_PAPER_HEIGHT', '297mm'), + 'orientation' => env('PDF_ORIENTATION', 'portrait'), + 'margin_top' => env('PDF_MARGIN_TOP', '1.2cm'), + 'margin_right' => env('PDF_MARGIN_RIGHT', '1.2cm'), + 'margin_bottom' => env('PDF_MARGIN_BOTTOM', '1.2cm'), + 'margin_left' => env('PDF_MARGIN_LEFT', '1.2cm'), + ], + /* |-------------------------------------------------------------------------- | PDF Connections @@ -29,7 +55,6 @@ return [ 'gotenberg' => [ 'host' => env('GOTENBERG_HOST', 'http://pdf:3000'), - 'papersize' => env('GOTENBERG_PAPERSIZE', '210mm 297mm'), /* * Gotenberg usually runs as a sidecar on a private network, which the diff --git a/database/migrations/2026_07_29_120000_move_gotenberg_papersize_to_shared_page_setup.php b/database/migrations/2026_07_29_120000_move_gotenberg_papersize_to_shared_page_setup.php new file mode 100644 index 00000000..78b71f65 --- /dev/null +++ b/database/migrations/2026_07_29_120000_move_gotenberg_papersize_to_shared_page_setup.php @@ -0,0 +1,56 @@ + $m[1], + 'pdf_paper_height' => $m[2], + ]); + } + + Setting::whereIn('option', ['gotenberg_papersize', 'gotenberg_margins'])->delete(); + } + + public function down(): void + { + $width = Setting::getSetting('pdf_paper_width'); + $height = Setting::getSetting('pdf_paper_height'); + + if ($width && $height) { + Setting::setSetting('gotenberg_papersize', "{$width} {$height}"); + } + + Setting::whereIn('option', [ + 'pdf_paper_width', + 'pdf_paper_height', + 'pdf_orientation', + 'pdf_margin_top', + 'pdf_margin_right', + 'pdf_margin_bottom', + 'pdf_margin_left', + ])->delete(); + } +}; diff --git a/lang/en.json b/lang/en.json index bc858e39..fb1a1526 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1048,16 +1048,25 @@ }, "pdf": { "title": "PDF Setting", - "footer_text": "Footer Text", - "pdf_layout": "PDF Layout", "pdf_configuration": "PDF Generation Settings", "section_description": "Change the way PDFs are generated", "driver": "PDF Driver to use", - "papersize": "Papersize", - "papersize_hint": "Papersize in width and height (ex. \"210mm 297mm\")", "gotenberg_host": "Gotenberg service host", "pdf_variables_save_successfully": "PDF configuration saved successfully", - "pdf_variables_save_error": "PDF configuration could not be saved" + "pdf_variables_save_error": "PDF configuration could not be saved", + "page_setup": "Page Setup", + "page_setup_hint": "Applies to every document, whichever driver renders it.", + "paper_size": "Paper Size", + "paper_width": "Paper Width", + "paper_height": "Paper Height", + "orientation": "Orientation", + "portrait": "Portrait", + "landscape": "Landscape", + "margin_top": "Top Margin", + "margin_right": "Right Margin", + "margin_bottom": "Bottom Margin", + "margin_left": "Left Margin", + "length_hint": "A number and a unit, e.g. \"210mm\". Accepts: pt, px, pc, mm, cm, in." }, "company_info": { "company_info": "Company info", @@ -1777,7 +1786,8 @@ "at_least_one_ability": "Please select atleast one Permission.", "valid_driver_key": "Please enter a valid {driver} key.", "valid_exchange_rate": "Please enter a valid exchange rate.", - "company_name_not_same": "Company name must match with given name." + "company_name_not_same": "Company name must match with given name.", + "invalid_length": "Enter a number and a unit, e.g. \"210mm\". Accepts: pt, px, pc, mm, cm, in." }, "errors": { "starter_plan": "This feature is available on Starter plan and onwards!", diff --git a/resources/scripts/api/services/pdf.service.ts b/resources/scripts/api/services/pdf.service.ts index c5ac7cc5..31e85173 100644 --- a/resources/scripts/api/services/pdf.service.ts +++ b/resources/scripts/api/services/pdf.service.ts @@ -3,14 +3,27 @@ import { API } from '../endpoints' export type PdfDriver = string -export interface DomPdfConfig { +/** + * Page geometry, applied whichever driver renders. Sizes and margins are CSS + * lengths (e.g. "210mm"), the only notation both drivers accept without loss. + */ +export interface PdfPageSetup { + pdf_paper_width: string + pdf_paper_height: string + pdf_orientation: 'portrait' | 'landscape' + pdf_margin_top: string + pdf_margin_right: string + pdf_margin_bottom: string + pdf_margin_left: string +} + +export interface DomPdfConfig extends PdfPageSetup { pdf_driver: string } -export interface GotenbergConfig { +export interface GotenbergConfig extends PdfPageSetup { pdf_driver: string gotenberg_host: string - gotenberg_papersize: string } export type PdfConfig = DomPdfConfig | GotenbergConfig diff --git a/resources/scripts/features/admin/components/settings/AdminPdfDomDriver.vue b/resources/scripts/features/admin/components/settings/AdminPdfDomDriver.vue index 8a22acf5..03f6c54c 100644 --- a/resources/scripts/features/admin/components/settings/AdminPdfDomDriver.vue +++ b/resources/scripts/features/admin/components/settings/AdminPdfDomDriver.vue @@ -3,11 +3,14 @@ import { computed, onMounted, reactive } from 'vue' import { useI18n } from 'vue-i18n' import { required, helpers } from '@vuelidate/validators' import useVuelidate from '@vuelidate/core' -import type { PdfDriver } from '@/scripts/api/services/pdf.service' - -interface DomPdfForm { - pdf_driver: string -} +import type { DomPdfConfig, PdfDriver } from '@/scripts/api/services/pdf.service' +import AdminPdfPageSetup from '@/scripts/features/admin/components/settings/AdminPdfPageSetup.vue' +import { + cssLength, + pageSetupDefaults, + pageSetupErrors, + pageSetupFrom, +} from '@/scripts/features/admin/components/settings/pdfPageSetup' const props = withDefaults( defineProps<{ @@ -25,28 +28,39 @@ const props = withDefaults( ) const emit = defineEmits<{ - 'submit-data': [config: DomPdfForm] + 'submit-data': [config: DomPdfConfig] 'on-change-driver': [driver: string] }>() const { t } = useI18n() -const form = reactive({ +const form = reactive({ pdf_driver: 'dompdf', + ...pageSetupDefaults(), }) const rules = computed(() => ({ pdf_driver: { required: helpers.withMessage(t('validation.required'), required), }, + pdf_paper_width: cssLength(t), + pdf_paper_height: cssLength(t), + pdf_margin_top: cssLength(t), + pdf_margin_right: cssLength(t), + pdf_margin_bottom: cssLength(t), + pdf_margin_left: cssLength(t), })) const v$ = useVuelidate(rules, form) +const pageErrors = computed(() => pageSetupErrors(v$.value)) + onMounted(() => { if (typeof props.configData.pdf_driver === 'string') { form.pdf_driver = props.configData.pdf_driver } + + Object.assign(form, pageSetupFrom(props.configData)) }) function onChangeDriver(): void { @@ -83,6 +97,13 @@ function saveConfig(): void { + +
() const { t } = useI18n() -const form = reactive({ +const form = reactive({ pdf_driver: 'gotenberg', gotenberg_host: '', - gotenberg_papersize: '210mm 297mm', + ...pageSetupDefaults(), }) function isValidServiceUrl(value: string): boolean { @@ -67,13 +68,18 @@ const rules = computed(() => ({ isValidServiceUrl ), }, - gotenberg_papersize: { - required: helpers.withMessage(t('validation.required'), required), - }, + pdf_paper_width: cssLength(t), + pdf_paper_height: cssLength(t), + pdf_margin_top: cssLength(t), + pdf_margin_right: cssLength(t), + pdf_margin_bottom: cssLength(t), + pdf_margin_left: cssLength(t), })) const v$ = useVuelidate(rules, form) +const pageErrors = computed(() => pageSetupErrors(v$.value)) + onMounted(() => { if (typeof props.configData.pdf_driver === 'string') { form.pdf_driver = props.configData.pdf_driver @@ -83,9 +89,7 @@ onMounted(() => { form.gotenberg_host = props.configData.gotenberg_host } - if (typeof props.configData.gotenberg_papersize === 'string') { - form.gotenberg_papersize = props.configData.gotenberg_papersize - } + Object.assign(form, pageSetupFrom(props.configData)) }) function onChangeDriver(): void { @@ -137,27 +141,15 @@ function saveConfig(): void { @input="v$.gotenberg_host.$touch()" /> - - - - + +
+import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import type { PdfPageSetup } from '@/scripts/api/services/pdf.service' + +const { t } = useI18n() + +/** + * Page geometry, shared by both drivers. + * + * Paper size used to be Gotenberg-only and stored as a single "210mm 297mm" + * string, so dompdf had no paper size at all and switching drivers lost it. + * These fields are rendered identically for either driver and applied to both. + */ +const form = defineModel({ required: true }) + +defineProps<{ + isFetchingInitialData?: boolean + errors?: Record +}>() + +// Convenience only. Storage is always a pair of CSS lengths, because Gotenberg +// has no concept of a named size and dompdf's named table cannot express +// everything Gotenberg accepts. +const PRESETS = [ + { label: 'A3', width: '297mm', height: '420mm' }, + { label: 'A4', width: '210mm', height: '297mm' }, + { label: 'A5', width: '148mm', height: '210mm' }, + { label: 'Letter', width: '8.5in', height: '11in' }, + { label: 'Legal', width: '8.5in', height: '14in' }, +] + +const CUSTOM = 'Custom' + +const presetOptions = [...PRESETS.map((p) => p.label), CUSTOM] + +const selectedPreset = computed({ + get() { + const match = PRESETS.find( + (p) => p.width === form.value.pdf_paper_width && p.height === form.value.pdf_paper_height + ) + + return match?.label ?? CUSTOM + }, + set(label: string) { + const preset = PRESETS.find((p) => p.label === label) + + if (preset) { + form.value.pdf_paper_width = preset.width + form.value.pdf_paper_height = preset.height + } + }, +}) + +const isCustom = computed(() => selectedPreset.value === CUSTOM) + +const orientations = computed(() => [ + { label: t('settings.pdf.portrait'), value: 'portrait' }, + { label: t('settings.pdf.landscape'), value: 'landscape' }, +]) + + + diff --git a/resources/scripts/features/admin/components/settings/pdfPageSetup.ts b/resources/scripts/features/admin/components/settings/pdfPageSetup.ts new file mode 100644 index 00000000..f6cbd623 --- /dev/null +++ b/resources/scripts/features/admin/components/settings/pdfPageSetup.ts @@ -0,0 +1,68 @@ +import { helpers } from '@vuelidate/validators' +import type { PdfPageSetup } from '@/scripts/api/services/pdf.service' + +/** + * Shared bits of the page-setup form, so the dompdf and Gotenberg components + * validate and seed the same fields the same way rather than drifting apart. + */ + +export const PAGE_SETUP_KEYS = [ + 'pdf_paper_width', + 'pdf_paper_height', + 'pdf_orientation', + 'pdf_margin_top', + 'pdf_margin_right', + 'pdf_margin_bottom', + 'pdf_margin_left', +] as const + +/** Mirrors App\Rules\CssLength, so a bad value is caught before the round trip. */ +const CSS_LENGTH = /^\d+(\.\d+)?(pt|px|pc|mm|cm|in)$/ + +export function cssLength(t: (key: string) => string) { + return { + cssLength: helpers.withMessage(t('validation.invalid_length'), (value: string) => + !helpers.req(value) ? true : CSS_LENGTH.test(String(value).trim()) + ), + } +} + +/** Matches the defaults in config/pdf.php, including dompdf's own 1.2cm margin. */ +export function pageSetupDefaults(): PdfPageSetup { + return { + pdf_paper_width: '210mm', + pdf_paper_height: '297mm', + pdf_orientation: 'portrait', + pdf_margin_top: '1.2cm', + pdf_margin_right: '1.2cm', + pdf_margin_bottom: '1.2cm', + pdf_margin_left: '1.2cm', + } +} + +/** Pulls the page-setup keys out of the API payload, skipping anything absent. */ +export function pageSetupFrom(configData: Record): Partial { + const setup: Record = {} + + for (const key of PAGE_SETUP_KEYS) { + if (typeof configData[key] === 'string' && configData[key]) { + setup[key] = configData[key] as string + } + } + + return setup as Partial +} + +/** Flattens Vuelidate state into the shape AdminPdfPageSetup renders. */ +export function pageSetupErrors( + v$: Record +): Record { + const errors: Record = {} + + for (const key of PAGE_SETUP_KEYS) { + const field = v$[key] + errors[key] = field?.$error ? String(field.$errors?.[0]?.$message ?? '') : false + } + + return errors +} diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php index b1f298cc..5e612073 100644 --- a/tests/Feature/Admin/AdminSettingsTest.php +++ b/tests/Feature/Admin/AdminSettingsTest.php @@ -162,24 +162,85 @@ test('get pdf configuration', function () { ->assertJsonStructure([ 'pdf_driver', 'gotenberg_host', - 'gotenberg_margins', - 'gotenberg_papersize', + 'pdf_paper_width', + 'pdf_paper_height', + 'pdf_orientation', + 'pdf_margin_top', + 'pdf_margin_right', + 'pdf_margin_bottom', + 'pdf_margin_left', ]); }); -test('save pdf configuration', function () { +/** + * Page geometry is saved for whichever driver is selected. It used to hang off + * gotenberg_papersize, so picking dompdf meant having no paper size at all and + * switching drivers threw the setting away. + */ +test('save pdf configuration stores the page setup for dompdf too', function () { postJson('/api/v1/pdf/config', [ 'pdf_driver' => 'dompdf', + 'pdf_paper_width' => '8.5in', + 'pdf_paper_height' => '14in', + 'pdf_orientation' => 'landscape', + 'pdf_margin_top' => '5mm', + 'pdf_margin_right' => '6mm', + 'pdf_margin_bottom' => '7mm', + 'pdf_margin_left' => '8mm', ]) ->assertOk() - ->assertJson([ - 'success' => 'pdf_variables_save_successfully', - ]); + ->assertJson(['success' => 'pdf_variables_save_successfully']); - $this->assertDatabaseHas('settings', [ - 'option' => 'pdf_driver', - 'value' => 'dompdf', - ]); + foreach ([ + 'pdf_driver' => 'dompdf', + 'pdf_paper_width' => '8.5in', + 'pdf_paper_height' => '14in', + 'pdf_orientation' => 'landscape', + 'pdf_margin_top' => '5mm', + 'pdf_margin_right' => '6mm', + 'pdf_margin_bottom' => '7mm', + 'pdf_margin_left' => '8mm', + ] as $option => $value) { + $this->assertDatabaseHas('settings', compact('option', 'value')); + } +}); + +test('pdf configuration rejects a length with no unit', function () { + postJson('/api/v1/pdf/config', [ + 'pdf_driver' => 'dompdf', + 'pdf_paper_width' => '210', + 'pdf_paper_height' => '297mm', + 'pdf_orientation' => 'portrait', + ])->assertStatus(422)->assertJsonValidationErrors('pdf_paper_width'); +}); + +test('pdf configuration rejects an unknown orientation', function () { + postJson('/api/v1/pdf/config', [ + 'pdf_driver' => 'dompdf', + 'pdf_paper_width' => '210mm', + 'pdf_paper_height' => '297mm', + 'pdf_orientation' => 'sideways', + ])->assertStatus(422)->assertJsonValidationErrors('pdf_orientation'); +}); + +/** + * A zero margin is a deliberate choice. AppConfigProvider guards its settings + * with !empty(), and '0mm' is fine there, but a bare '0' would not be -- this + * pins the behaviour either way. + */ +test('a zero margin survives the round trip', function () { + postJson('/api/v1/pdf/config', [ + 'pdf_driver' => 'dompdf', + 'pdf_paper_width' => '210mm', + 'pdf_paper_height' => '297mm', + 'pdf_orientation' => 'portrait', + 'pdf_margin_top' => '0mm', + 'pdf_margin_right' => '0mm', + 'pdf_margin_bottom' => '0mm', + 'pdf_margin_left' => '0mm', + ])->assertOk(); + + getJson('/api/v1/pdf/config')->assertOk()->assertJson(['pdf_margin_top' => '0mm']); }); test('get app version', function () { diff --git a/tests/Feature/Admin/PdfPageSetupMigrationTest.php b/tests/Feature/Admin/PdfPageSetupMigrationTest.php new file mode 100644 index 00000000..24c127d1 --- /dev/null +++ b/tests/Feature/Admin/PdfPageSetupMigrationTest.php @@ -0,0 +1,52 @@ +up(); +} + +test('an existing papersize is split into width and height', function () { + Setting::setSetting('gotenberg_papersize', '8.5in 14in'); + + runPageSetupMigration(); + + expect(Setting::getSetting('pdf_paper_width'))->toBe('8.5in') + ->and(Setting::getSetting('pdf_paper_height'))->toBe('14in'); +}); + +test('the retired keys are removed', function () { + Setting::setSettings([ + 'gotenberg_papersize' => '210mm 297mm', + 'gotenberg_margins' => '10mm', + ]); + + runPageSetupMigration(); + + expect(Setting::getSetting('gotenberg_papersize'))->toBeNull() + ->and(Setting::getSetting('gotenberg_margins'))->toBeNull(); +}); + +/** + * No stored value, or one that never matched the old format, leaves the new keys + * unset so config/pdf.php's A4 default applies. + */ +test('an absent or unparseable papersize leaves the defaults in place', function (?string $stored) { + if ($stored !== null) { + Setting::setSetting('gotenberg_papersize', $stored); + } + + runPageSetupMigration(); + + expect(Setting::getSetting('pdf_paper_width'))->toBeNull(); +})->with([ + 'absent' => null, + 'single token' => 'a4', + 'empty' => '', +]); diff --git a/tests/Unit/GotenbergPdfDriverTest.php b/tests/Unit/GotenbergPdfDriverTest.php index 2278d600..d9a95213 100644 --- a/tests/Unit/GotenbergPdfDriverTest.php +++ b/tests/Unit/GotenbergPdfDriverTest.php @@ -9,7 +9,8 @@ use App\Support\Pdf\GotenbergPdfDriver; beforeEach(function () { config([ 'pdf.connections.gotenberg.host' => 'http://gotenberg.example.com:3000', - 'pdf.connections.gotenberg.papersize' => '210mm 297mm', + 'pdf.page.paper_width' => '210mm', + 'pdf.page.paper_height' => '297mm', ]); }); @@ -39,7 +40,7 @@ test('the chromium request emulates the same media type dompdf uses', function ( }); test('the configured paper size reaches the request', function () { - config(['pdf.connections.gotenberg.papersize' => '8.5in 11in']); + config(['pdf.page.paper_width' => '8.5in', 'pdf.page.paper_height' => '11in']); expect(gotenbergRequestBody()) ->toContain('paperWidth') @@ -51,11 +52,11 @@ test('the rendered document is sent as the index file', function () { expect(gotenbergRequestBody())->toContain('index.html'); }); -test('it throws when the papersize config has an unexpected format', function () { - config(['pdf.connections.gotenberg.papersize' => 'invalid']); +test('it throws when a page length has an unexpected format', function () { + config(['pdf.page.paper_width' => 'invalid']); expect(fn () => gotenbergRequestBody()) - ->toThrow(InvalidArgumentException::class, 'Invalid Gotenberg Papersize specified'); + ->toThrow(InvalidArgumentException::class, 'Invalid PDF page length'); }); test('it throws when the configured host targets a private network address', function () { diff --git a/tests/Unit/PdfDriverPageParityTest.php b/tests/Unit/PdfDriverPageParityTest.php new file mode 100644 index 00000000..e0d6d59a --- /dev/null +++ b/tests/Unit/PdfDriverPageParityTest.php @@ -0,0 +1,109 @@ + 'http://gotenberg.example.com:3000', + 'pdf.page.paper_width' => '210mm', + 'pdf.page.paper_height' => '297mm', + 'pdf.page.orientation' => 'portrait', + 'pdf.page.margin_top' => '1.2cm', + 'pdf.page.margin_right' => '1.2cm', + 'pdf.page.margin_bottom' => '1.2cm', + 'pdf.page.margin_left' => '1.2cm', + ]); +}); + +test('the gotenberg request carries the configured page geometry', function () { + $body = (string) (new GotenbergPdfDriver)->buildRequest('app.pdf.partials.fonts')->getBody(); + + expect($body)->toContain('210mm') + ->and($body)->toContain('297mm') + ->and($body)->toContain('marginTop') + ->and($body)->toContain('1.2cm'); +}); + +test('landscape is requested of gotenberg rather than pre-swapping the paper', function () { + config(['pdf.page.orientation' => 'landscape']); + + $body = (string) (new GotenbergPdfDriver)->buildRequest('app.pdf.partials.fonts')->getBody(); + + // Still the portrait pair: landscape() does the swap on Gotenberg's side, + // exactly as setPaper()'s orientation argument does on dompdf's. + expect($body)->toContain('landscape') + ->and($body)->toContain('210mm') + ->and($body)->toContain('297mm'); +}); + +test('dompdf renders at the configured paper size', function () { + $pdf = (new DompdfDriver)->loadView('app.pdf.partials.fonts'); + + // A4 in points, per dompdf's own table. + expect($pdf->output())->toStartWith('%PDF-'); + + $page = PdfPageSetup::fromConfig(); + expect($page->dompdfPaper())->toEqualWithDelta([0.0, 0.0, 595.28, 841.89], 0.01); +}); + +/** + * dompdf has no margin API, so the only way margins reach it is an injected + * page rule. If that injection stops happening, output silently reverts to + * dompdf's built-in 1.2cm and nothing else notices -- so assert on real + * rendered bytes, not just on the string helper. + */ +test('changing the margins changes what dompdf actually renders', function () { + config(['pdf.page.margin_top' => '0mm', 'pdf.page.margin_left' => '0mm']); + $tight = (new DompdfDriver)->loadView('app.pdf.partials.fonts')->output(); + + config(['pdf.page.margin_top' => '40mm', 'pdf.page.margin_left' => '40mm']); + $roomy = (new DompdfDriver)->loadView('app.pdf.partials.fonts')->output(); + + expect($tight)->toStartWith('%PDF-') + ->and($roomy)->toStartWith('%PDF-') + ->and($tight)->not->toBe($roomy); +}); + +test('the page size reaches dompdf rather than config/dompdf.php\'s fixed a4', function () { + config(['pdf.page.paper_width' => '8.5in', 'pdf.page.paper_height' => '14in']); + $legal = (new DompdfDriver)->loadView('app.pdf.partials.fonts')->output(); + + config(['pdf.page.paper_width' => '210mm', 'pdf.page.paper_height' => '297mm']); + $a4 = (new DompdfDriver)->loadView('app.pdf.partials.fonts')->output(); + + expect($legal)->not->toBe($a4); +}); + +/** + * Injected at the top of the head element so a template declaring its own page + * rule still wins -- later rules of equal specificity take precedence in CSS. + */ +test('the injected rule sits at the start of head so a template can override it', function () { + $html = ''; + + $method = new ReflectionMethod(DompdfDriver::class, 'withPageMargins'); + $result = $method->invoke(new DompdfDriver, $html, PdfPageSetup::fromConfig()); + + expect(strpos($result, '1.2cm'))->toBeLessThan(strpos($result, 'margin: 0')); +}); + +test('markup with no head still receives the rule', function () { + $method = new ReflectionMethod(DompdfDriver::class, 'withPageMargins'); + $result = $method->invoke(new DompdfDriver, '

bare

', PdfPageSetup::fromConfig()); + + expect($result)->toStartWith('