Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.92% covered (success)
97.92%
94 / 96
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureStampAppearanceBuilder
97.92% covered (success)
97.92%
94 / 96
50.00% covered (danger)
50.00%
2 / 4
24
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
 buildXObject
98.46% covered (success)
98.46%
64 / 65
0.00% covered (danger)
0.00%
0 / 1
11
 wrapTextForPdf
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
11
 escapePdfText
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\SignatureStampPreview;
10
11use OCA\Libresign\Service\SignatureTextService;
12use OCA\Libresign\Service\SignerElementsService;
13use SignerPHP\PdfSigner\Application\DTO\SignatureAppearanceXObjectDto;
14
15class SignatureStampAppearanceBuilder {
16    public function __construct(
17        private SignatureTextService $signatureTextService,
18    ) {
19    }
20
21    public function buildXObject(
22        int $width,
23        int $height,
24        string $renderMode,
25        array $context = [],
26        string $template = '',
27        ?float $templateFontSize = null,
28        ?float $signatureFontSize = null,
29        ?string $fallbackCommonName = null,
30    ): SignatureAppearanceXObjectDto {
31        if ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY) {
32            return new SignatureAppearanceXObjectDto(stream: '', resources: []);
33        }
34
35        $context['ServerSignatureDate'] = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
36            ->format(\DateTimeInterface::ATOM);
37
38        $textData = $this->signatureTextService->parse($template, $context);
39        $parsed = trim((string)($textData['parsed'] ?? ''));
40
41        $descFontSize = $templateFontSize
42            ?? (float)($textData['templateFontSize'] ?? $this->signatureTextService->getTemplateFontSize());
43        $descLineHeight = $descFontSize * 1.0;
44        $leftPadding = max(2.0, $descFontSize * 0.15);
45
46        $isDescriptionOnly = $renderMode === SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY;
47        $textStartX = $isDescriptionOnly ? $leftPadding : ((float)$width / 2.0) + $leftPadding;
48        $availableWidth = $isDescriptionOnly ? (float)$width : (float)$width / 2.0;
49
50        $stream = '';
51
52        if ($renderMode === SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION) {
53            $commonName = !empty($context['SignerCommonName'])
54                ? (string)$context['SignerCommonName']
55                : ($fallbackCommonName ?? '');
56
57            if ($commonName !== '') {
58                $nameFontSize = $signatureFontSize ?? $this->signatureTextService->getSignatureFontSize();
59                $leftHalfW = (float)$width / 2.0;
60                $nameLines = $this->wrapTextForPdf($commonName, $leftHalfW - $leftPadding * 2, $nameFontSize);
61                $nameLineCount = count($nameLines);
62                $totalNameHeight = $nameLineCount * $nameFontSize * 1.0;
63                $nameStartY = ((float)$height + $totalNameHeight) / 2.0 - $nameFontSize;
64                $nameStartY = max(0.0, $nameStartY);
65                $nameY = $nameStartY;
66                $estimatedCharWidth = $nameFontSize * 0.52;
67                foreach ($nameLines as $nameLine) {
68                    $lineWidth = strlen($nameLine) * $estimatedCharWidth;
69                    $nameX = max($leftPadding, ($leftHalfW - $lineWidth) / 2.0);
70                    $escaped = $this->escapePdfText($nameLine);
71                    $stream .= "BT\n";
72                    $stream .= sprintf("/F1 %.2F Tf\n", $nameFontSize);
73                    $stream .= "0 0 0 rg\n";
74                    $stream .= sprintf("%.2F %.2F Td\n", $nameX, $nameY);
75                    $stream .= sprintf("(%s) Tj\n", $escaped);
76                    $stream .= "ET\n";
77                    $nameY -= $nameFontSize * 1.0;
78                }
79            }
80        }
81
82        $currentY = (float)$height - $descFontSize - 2.0;
83        foreach (explode(PHP_EOL, $parsed) as $line) {
84            $wrappedLines = $this->wrapTextForPdf($line, $availableWidth, $descFontSize);
85            foreach ($wrappedLines as $wrappedLine) {
86                if ($currentY < 0) {
87                    break 2;
88                }
89                $escaped = $this->escapePdfText($wrappedLine);
90                $stream .= "BT\n";
91                $stream .= sprintf("/F1 %.2F Tf\n", $descFontSize);
92                $stream .= "0 0 0 rg\n";
93                $stream .= sprintf("%.2F %.2F Td\n", $textStartX, $currentY);
94                $stream .= sprintf("(%s) Tj\n", $escaped);
95                $stream .= "ET\n";
96                $currentY -= $descLineHeight;
97            }
98        }
99
100        return new SignatureAppearanceXObjectDto(
101            stream: $stream,
102            resources: [
103                'Font' => [
104                    'F1' => [
105                        'Type' => '/Font',
106                        'Subtype' => '/Type1',
107                        'BaseFont' => '/Helvetica',
108                    ],
109                ],
110            ],
111        );
112    }
113
114    /**
115     * @return string[]
116     */
117    public function wrapTextForPdf(string $line, float $availableWidth, float $fontSize): array {
118        $trimmed = trim($line);
119        if ($trimmed === '') {
120            return [''];
121        }
122
123        $estimatedCharWidth = max(1.0, $fontSize * 0.52);
124        $maxChars = max(1, (int)floor($availableWidth / $estimatedCharWidth));
125        if (strlen($trimmed) <= $maxChars) {
126            return [$trimmed];
127        }
128
129        $result = [];
130        $current = '';
131        foreach (preg_split('/\s+/', $trimmed) ?: [] as $word) {
132            if ($word === '') {
133                continue;
134            }
135
136            $candidate = $current === '' ? $word : $current . ' ' . $word;
137            if (strlen($candidate) <= $maxChars) {
138                $current = $candidate;
139                continue;
140            }
141
142            if ($current !== '') {
143                $result[] = $current;
144                $current = '';
145            }
146
147            while (strlen($word) > $maxChars) {
148                $result[] = substr($word, 0, $maxChars);
149                $word = substr($word, $maxChars);
150            }
151
152            $current = $word;
153        }
154
155        if ($current !== '') {
156            $result[] = $current;
157        }
158
159        return $result;
160    }
161
162    public function escapePdfText(string $value): string {
163        $value = str_replace('\\', '\\\\', $value);
164        $value = str_replace('(', '\\(', $value);
165        $value = str_replace(')', '\\)', $value);
166
167        return $value;
168    }
169}