1.0, 'px' => 0.75, 'pc' => 12.0, 'mm' => 72 / 25.4, 'cm' => 720 / 25.4, 'in' => 72.0, ]; private function __construct( public readonly string $width, public readonly string $height, public readonly string $orientation, public readonly string $marginTop, public readonly string $marginRight, public readonly string $marginBottom, public readonly string $marginLeft, ) {} public static function fromConfig(): self { return new self( width: self::length('pdf.page.paper_width', '210mm'), height: self::length('pdf.page.paper_height', '297mm'), orientation: config('pdf.page.orientation') === 'landscape' ? 'landscape' : 'portrait', marginTop: self::length('pdf.page.margin_top', '0'), marginRight: self::length('pdf.page.margin_right', '0'), marginBottom: self::length('pdf.page.margin_bottom', '0'), marginLeft: self::length('pdf.page.margin_left', '0'), ); } /** * The configured page, but with the report margin on all four sides. * * Reports are the one family of templates that carries no inset of its own: * they only set `.sub-container { padding: 0px 20px }` and relied on dompdf's * built-in 1.2cm page margin, which stopped applying once DompdfDriver began * injecting an @page rule from config. The document margins default to zero * and must stay that way (invoice2 and estimate2 bleed a header band to the * paper edge), so the reports get their own margin instead of inheriting * those. Paper size and orientation still come from config: only the margins * are overridden. * * A page margin rather than body padding because reports run to several * pages, and padding only insets the first one. */ public static function forReports(): self { return self::fromConfig()->withUniformMargin(self::length('pdf.page.report_margin', '1.2cm')); } /** * A copy of this page with the same paper and orientation, and the given * margin on all four sides. */ public function withUniformMargin(string $margin): self { $margin = self::assertLength($margin, "Invalid PDF page margin: \"{$margin}\"."); return new self( width: $this->width, height: $this->height, orientation: $this->orientation, marginTop: $margin, marginRight: $margin, marginBottom: $margin, marginLeft: $margin, ); } public function isLandscape(): bool { return $this->orientation === 'landscape'; } /** * Portrait dimensions for Gotenberg's paperSize(). Orientation is applied * separately via landscape(), which does the swap itself. * * @return array{0: string, 1: string} */ public function gotenbergPaper(): array { return [$this->width, $this->height]; } /** * Gotenberg's margins() takes top, bottom, left, right — note the order, * which is not the CSS one. * * @return array{0: string, 1: string, 2: string, 3: string} */ public function gotenbergMargins(): array { return [$this->marginTop, $this->marginBottom, $this->marginLeft, $this->marginRight]; } /** * Points array for dompdf's setPaper(). Always portrait: Dompdf::getPaperSize() * swaps the axes itself when the orientation argument says landscape, so * pre-swapping here would cancel out. * * @return array{0: float, 1: float, 2: float, 3: float} */ public function dompdfPaper(): array { return [0.0, 0.0, self::toPoints($this->width), self::toPoints($this->height)]; } /** * dompdf has no margin API at all — margins come from the `@page` box, so * the only lever is CSS. See DompdfDriver, which injects this. */ public function marginCss(): string { return "{$this->marginTop} {$this->marginRight} {$this->marginBottom} {$this->marginLeft}"; } public static function toPoints(string $length): float { $length = trim($length); if ($length === '0') { return 0.0; } if (! preg_match('/^(\d+(?:\.\d+)?)(pt|px|pc|mm|cm|in)$/', $length, $m)) { throw new \InvalidArgumentException("Invalid PDF page length: {$length}"); } return (float) $m[1] * self::POINTS_PER_UNIT[$m[2]]; } /** * Unset or blank falls back to the default; anything set but malformed * throws. * * Values are validated on save, but config can also come from the * environment, and the drivers would fail differently otherwise: dompdf * throws while converting to points, whereas Gotenberg would forward the * garbage and render at some other size. Failing here keeps them consistent * and names the offending key. */ private static function length(string $key, string $fallback): string { $value = config($key); if (! is_string($value) || trim($value) === '') { return $fallback; } $value = trim($value); return self::assertLength($value, "Invalid PDF page length for {$key}: \"{$value}\"."); } /** * A bare 0 needs no unit; anything else is a number and one of the units * both drivers understand. Shared by length() and withUniformMargin() so * there is one definition of what a valid length is, with the caller * supplying the part of the message that says where the bad value came from. */ private static function assertLength(string $value, string $context): string { $value = trim($value); if (! preg_match('/^(0|\d+(\.\d+)?(pt|px|pc|mm|cm|in))$/', $value)) { throw new \InvalidArgumentException( $context.' Expected 0, or a number and a unit, e.g. "210mm".' ); } return $value; } }