Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.89% covered (warning)
88.89%
56 / 63
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
MpdfRendererDriver
88.71% covered (warning)
88.71%
55 / 62
50.00% covered (danger)
50.00%
3 / 6
24.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderPdf
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
4.91
 buildMpdfConfig
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 configureProtection
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 configureSignature
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 configureDecorations
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Pdf\Infrastructure\Driver;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Pdf\Domain\Exception\PdfRenderingException;
12use Mpdf\Mpdf;
13use Mpdf\Output\Destination;
14use Throwable;
15
16/**
17 * mPDF Library Driver Implementation.
18 *
19 * Provides high-fidelity PDF rendering with full support for CSS Paged Media,
20 * running headers/footers, UTF-8 character sets, watermark overlays, password encryption,
21 * and cryptographic X.509 PAdES digital signatures.
22 *
23 * @package App\Modules\Pdf\Infrastructure\Driver
24 */
25final class MpdfRendererDriver implements PdfRendererDriverInterface
26{
27    /**
28     * MpdfRendererDriver constructor.
29     *
30     * @param HtmlPrintRendererDriver|null $fallbackDriver Fallback HTML driver if mPDF not loaded.
31     */
32    public function __construct(
33        private readonly ?HtmlPrintRendererDriver $fallbackDriver = null
34    ) {
35    }
36
37    /**
38     * Renders HTML content into PDF binary document.
39     *
40     * @param string               $html    Composed HTML markup.
41     * @param array<string, mixed> $options Rendering configuration options.
42     * @return string Binary PDF document string.
43     */
44    public function renderPdf(string $html, array $options = []): string
45    {
46        if (!class_exists(Mpdf::class)) {
47            if ($this->fallbackDriver !== null) {
48                return $this->fallbackDriver->renderPdf($html, $options);
49            }
50            throw PdfRenderingException::mpdfNotInstalled();
51        }
52
53        try {
54            $mpdfConfig = $this->buildMpdfConfig($options);
55            $mpdf = new Mpdf($mpdfConfig);
56
57            $this->configureProtection($mpdf, $options);
58            $this->configureSignature($mpdf, $options);
59            $this->configureDecorations($mpdf, $options);
60
61            $mpdf->WriteHTML($html);
62
63            return (string)$mpdf->Output('', Destination::STRING_RETURN);
64        } catch (Throwable $e) {
65            throw PdfRenderingException::renderingFailed($e->getMessage(), $e);
66        }
67    }
68
69    /**
70     * Builds mPDF initialization configuration array.
71     *
72     * @param array<string, mixed> $options
73     * @return array<string, mixed>
74     */
75    private function buildMpdfConfig(array $options): array
76    {
77        $format = (string)($options['format'] ?? 'A4');
78        $orientation = (string)($options['orientation'] ?? 'portrait');
79        $marginTop = (int)($options['margin_top'] ?? 15);
80        $marginBottom = (int)($options['margin_bottom'] ?? 15);
81        $marginLeft = (int)($options['margin_left'] ?? 15);
82        $marginRight = (int)($options['margin_right'] ?? 15);
83        $uid = function_exists('posix_getuid') ? (string)posix_getuid() : 'shared';
84        $tempDir = sys_get_temp_dir() . '/mpdf_' . $uid;
85
86        if (!is_dir($tempDir)) {
87            @mkdir($tempDir, 0700, true);
88        }
89
90        return [
91            'mode'          => 'utf-8',
92            'format'        => $format,
93            'orientation'   => str_starts_with(strtolower($orientation), 'l') ? 'L' : 'P',
94            'margin_top'    => $marginTop,
95            'margin_bottom' => $marginBottom,
96            'margin_left'   => $marginLeft,
97            'margin_right'  => $marginRight,
98            'tempDir'       => $tempDir,
99        ];
100    }
101
102    /**
103     * Applies password protection and permissions.
104     *
105     * @param array<string, mixed> $options
106     */
107    private function configureProtection(Mpdf $mpdf, array $options): void
108    {
109        if (empty($options['password'])) {
110            return;
111        }
112
113        $userPassword = (string)$options['password'];
114        $ownerPassword = (string)($options['owner_password'] ?? bin2hex(random_bytes(8)));
115        $permissions = isset($options['permissions']) && is_array($options['permissions'])
116            ? $options['permissions']
117            : ['print', 'copy'];
118
119        $mpdf->SetProtection($permissions, $userPassword, $ownerPassword);
120    }
121
122    /**
123     * Applies digital X.509 PAdES signature.
124     *
125     * @param array<string, mixed> $options
126     */
127    private function configureSignature(Mpdf $mpdf, array $options): void
128    {
129        if (empty($options['certificate_path']) || !file_exists((string)$options['certificate_path'])) {
130            return;
131        }
132
133        $certPath = (string)$options['certificate_path'];
134        $keyPath = (string)($options['private_key_path'] ?? $certPath);
135        $certPass = (string)($options['certificate_password'] ?? '');
136        $signatureInfo = [
137            'Name'        => (string)($options['signature_name'] ?? 'Ammonly Enterprise Suite'),
138            'Location'    => (string)($options['signature_location'] ?? 'Warsaw, Poland'),
139            'Reason'      => (string)($options['signature_reason'] ?? 'Official Business Document Verification'),
140            'ContactInfo' => (string)($options['signature_contact'] ?? 'support@ammonly.com'),
141        ];
142
143        if (method_exists($mpdf, 'SetSignature')) {
144            /** @phpstan-ignore method.notFound */
145            $mpdf->SetSignature($certPath, $keyPath, $certPass, '', 1, $signatureInfo);
146        }
147    }
148
149    /**
150     * Applies watermark, header and footer.
151     *
152     * @param array<string, mixed> $options
153     */
154    private function configureDecorations(Mpdf $mpdf, array $options): void
155    {
156        if (isset($options['watermark_text']) && (string)$options['watermark_text'] !== '') {
157            $mpdf->SetWatermarkText((string)$options['watermark_text']);
158            $mpdf->showWatermarkText = true;
159        }
160        if (isset($options['header_html']) && (string)$options['header_html'] !== '') {
161            $mpdf->SetHTMLHeader((string)$options['header_html']);
162        }
163        if (isset($options['footer_html']) && (string)$options['footer_html'] !== '') {
164            $mpdf->SetHTMLFooter((string)$options['footer_html']);
165        }
166    }
167}