Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.76% covered (success)
95.76%
113 / 118
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfGeneratorService
95.73% covered (success)
95.73%
112 / 117
40.00% covered (danger)
40.00%
2 / 5
20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 generateForRecord
97.78% covered (success)
97.78%
44 / 45
0.00% covered (danger)
0.00%
0 / 1
6
 generateBulk
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
5.01
 renderHtmlPreview
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 saveVerificationRecord
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
4.02
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Database\FallbackPdoResolver;
12use App\Core\Template\Application\Service\TemplateCompilerService;
13use App\Core\Template\Application\Service\TemplateVariableResolverInterface;
14use App\Modules\Pdf\Domain\Repository\PdfTemplateRepositoryInterface;
15use App\Modules\Pdf\Infrastructure\Driver\HtmlPrintRendererDriver;
16use App\Modules\Pdf\Infrastructure\Driver\MpdfRendererDriver;
17use App\Modules\Pdf\Infrastructure\Driver\PdfRendererDriverInterface;
18use InvalidArgumentException;
19use PDO;
20
21/**
22 * PDF Document Generator Orchestrator.
23 *
24 * Resolves record context, executes Twig template compilation, registers digital
25 * verification records, and delegates PDF rendering to the configured driver.
26 *
27 * @package App\Modules\Pdf\Application\Service
28 */
29final class PdfGeneratorService implements PdfGeneratorServiceInterface
30{
31    private PdfRendererDriverInterface $driver;
32    private ?PDO $pdo;
33
34    /**
35     * PdfGeneratorService constructor.
36     *
37     * @param PdfTemplateRepositoryInterface        $repository Template repository.
38     * @param TemplateVariableResolverInterface       $resolver   Context variable resolver.
39     * @param TemplateCompilerService               $compiler   Block AST and Twig compiler.
40     * @param PdfRendererDriverInterface|null       $driver     Optional PDF renderer driver.
41     * @param PDO|null                              $pdo        Optional PDO database connection.
42     */
43    public function __construct(
44        private readonly PdfTemplateRepositoryInterface $repository,
45        private readonly TemplateVariableResolverInterface $resolver,
46        private readonly TemplateCompilerService $compiler,
47        ?PdfRendererDriverInterface $driver = null,
48        ?PDO $pdo = null
49    ) {
50        $this->driver = $driver ?? new MpdfRendererDriver(new HtmlPrintRendererDriver());
51        $this->pdo = $pdo ?? FallbackPdoResolver::resolveDefaultConnection();
52    }
53
54    /**
55     * Generates a PDF document for a single record.
56     *
57     * @param int                       $templateId Target template ID.
58     * @param string                    $moduleName Business module name.
59     * @param int                       $recordId   Primary record identifier.
60     * @param array<string, mixed>|null $user       Current user identity.
61     * @param array<string, mixed>      $options    Rendering options.
62     * @return array<string, string> Dictionary with filename, binary content, and verification data.
63     */
64    public function generateForRecord(
65        int $templateId,
66        string $moduleName,
67        int $recordId,
68        ?array $user = null,
69        array $options = []
70    ): array {
71        $template = $this->repository->findById($templateId);
72        if ($template === null) {
73            throw new InvalidArgumentException("PDF Template ID {$templateId} not found.");
74        }
75
76        $verificationHash = (string)($options['verification_hash'] ?? bin2hex(random_bytes(16)));
77        $overrides = ['verification_hash' => $verificationHash];
78        $context = $template->templateScope === 'list'
79            ? $this->resolver->resolveListContext($moduleName, [$recordId], $user)
80            : $this->resolver->resolveContext($moduleName, $recordId, $overrides, $user);
81
82        $bodyHtml = $this->compiler->render($template->bodyHtml, $context);
83        $headerHtml = $template->headerHtml !== null ? $this->compiler->render($template->headerHtml, $context) : '';
84        $footerHtml = $template->footerHtml !== null ? $this->compiler->render($template->footerHtml, $context) : '';
85
86        $filename = $this->compiler->render($template->filenamePattern, $context);
87        if (!str_ends_with(strtolower($filename), '.pdf')) {
88            $filename .= '.pdf';
89        }
90
91        $renderOptions = array_merge([
92            'format'         => $template->pageFormat,
93            'orientation'    => $template->pageOrientation,
94            'margin_top'     => $template->marginTop,
95            'margin_bottom'  => $template->marginBottom,
96            'margin_left'    => $template->marginLeft,
97            'margin_right'   => $template->marginRight,
98            'watermark_text' => $template->watermarkText,
99            'header_html'    => $headerHtml,
100            'footer_html'    => $footerHtml,
101            'css_styles'     => $template->cssStyles ?? '',
102            'title'          => $template->name,
103        ], $options);
104
105        $binary = $this->driver->renderPdf($bodyHtml, $renderOptions);
106        $checksum = hash('sha256', $binary);
107
108        $this->saveVerificationRecord(
109            $verificationHash,
110            $templateId,
111            $moduleName,
112            $recordId,
113            $filename,
114            $checksum,
115            (int)($user['id'] ?? 1)
116        );
117
118        return [
119            'filename'          => $filename,
120            'content'           => $binary,
121            'mime_type'         => 'application/pdf',
122            'verification_hash' => $verificationHash,
123            'checksum_sha256'   => $checksum,
124        ];
125    }
126
127    /**
128     * Generates a consolidated multi-record PDF document for bulk actions.
129     *
130     * @param int                       $templateId Target template ID.
131     * @param string                    $moduleName Business module name.
132     * @param array<int>                $recordIds  List of selected record IDs.
133     * @param array<string, mixed>|null $user       Current user identity.
134     * @param array<string, mixed>      $options    Rendering options.
135     * @return array<string, string> Dictionary with filename, binary content, and mime_type.
136     */
137    public function generateBulk(
138        int $templateId,
139        string $moduleName,
140        array $recordIds,
141        ?array $user = null,
142        array $options = []
143    ): array {
144        $template = $this->repository->findById($templateId);
145        if ($template === null) {
146            throw new InvalidArgumentException("PDF Template ID {$templateId} not found.");
147        }
148
149        if ($template->templateScope === 'list' || $template->category === 'list') {
150            $context = $this->resolver->resolveListContext($moduleName, $recordIds, $user);
151            $fullBody = $this->compiler->render($template->bodyHtml, $context);
152        } else {
153            $aggregatedBodies = [];
154            foreach ($recordIds as $recId) {
155                $context = $this->resolver->resolveContext($moduleName, (int)$recId, [], $user);
156                $aggregatedBodies[] = $this->compiler->render($template->bodyHtml, $context);
157            }
158
159            $pageBreak = '<div class="pdf-page-break" style="page-break-after: always; break-after: page;"></div>';
160            $fullBody = implode($pageBreak, $aggregatedBodies);
161        }
162
163        $renderOptions = array_merge([
164            'format'         => $template->pageFormat,
165            'orientation'    => $template->pageOrientation,
166            'margin_top'     => $template->marginTop,
167            'margin_bottom'  => $template->marginBottom,
168            'margin_left'    => $template->marginLeft,
169            'margin_right'   => $template->marginRight,
170            'watermark_text' => $template->watermarkText,
171            'css_styles'     => $template->cssStyles ?? '',
172            'title'          => 'Zbiorczy wydruk - ' . $template->name,
173        ], $options);
174
175        $binary = $this->driver->renderPdf($fullBody, $renderOptions);
176
177        return [
178            'filename'  => 'bulk_' . $moduleName . '_' . date('Ymd_His') . '.pdf',
179            'content'   => $binary,
180            'mime_type' => 'application/pdf',
181        ];
182    }
183
184    /**
185     * Renders self-contained HTML preview for in-browser modal dialog.
186     *
187     * @param int                       $templateId Target template ID.
188     * @param string                    $moduleName Business module name.
189     * @param int                       $recordId   Primary record identifier.
190     * @param array<string, mixed>|null $user       Current user identity.
191     * @return string Complete preview HTML string.
192     */
193    public function renderHtmlPreview(
194        int $templateId,
195        string $moduleName,
196        int $recordId,
197        ?array $user = null
198    ): string {
199        $template = $this->repository->findById($templateId);
200        if ($template === null) {
201            return '<div class="alert alert-danger">PDF template not found.</div>';
202        }
203
204        $context = $this->resolver->resolveContext($moduleName, $recordId, [], $user);
205        $bodyHtml = $this->compiler->render($template->bodyHtml, $context);
206        $headerHtml = $template->headerHtml !== null ? $this->compiler->render($template->headerHtml, $context) : '';
207        $footerHtml = $template->footerHtml !== null ? $this->compiler->render($template->footerHtml, $context) : '';
208
209        $htmlDriver = new HtmlPrintRendererDriver();
210        $options = [
211            'format'         => $template->pageFormat,
212            'orientation'    => $template->pageOrientation,
213            'margin_top'     => $template->marginTop,
214            'margin_bottom'  => $template->marginBottom,
215            'margin_left'    => $template->marginLeft,
216            'margin_right'   => $template->marginRight,
217            'header_html'    => $headerHtml,
218            'footer_html'    => $footerHtml,
219            'css_styles'     => $template->cssStyles ?? '',
220            'title'          => $template->name,
221        ];
222
223        return $htmlDriver->renderPdf($bodyHtml, $options);
224    }
225
226    /**
227     * Saves digital document verification record into the database.
228     *
229     * @param string $hash      Unique verification hash.
230     * @param int    $tplId     Template ID.
231     * @param string $module    Module name.
232     * @param int    $recordId  Record ID.
233     * @param string $filename  File name.
234     * @param string $checksum  SHA-256 binary checksum.
235     * @param int    $userId    Creator user ID.
236     */
237    private function saveVerificationRecord(
238        string $hash,
239        int $tplId,
240        string $module,
241        int $recordId,
242        string $filename,
243        string $checksum,
244        int $userId
245    ): void {
246        if ($this->pdo === null || $recordId <= 0) {
247            return;
248        }
249
250        try {
251            $stmt = $this->pdo->prepare(
252                'INSERT INTO `a_mod_pdf_verification_records` ('
253                . '`document_hash`, `template_id`, `module_name`, `record_id`, `filename`, '
254                . '`checksum_sha256`, `created_by`'
255                . ') VALUES (:hash, :tpl, :mod, :rec, :file, :chk, :uid) '
256                . 'ON DUPLICATE KEY UPDATE `filename` = VALUES(`filename`), '
257                . '`checksum_sha256` = VALUES(`checksum_sha256`)'
258            );
259            $stmt->execute([
260                ':hash' => $hash,
261                ':tpl'  => $tplId,
262                ':mod'  => $module,
263                ':rec'  => $recordId,
264                ':file' => $filename,
265                ':chk'  => $checksum,
266                ':uid'  => $userId,
267            ]);
268        } catch (\Throwable) {
269            // Non-blocking for unmigrated or testing environments
270        }
271    }
272}