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('