Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.03% covered (warning)
87.03%
275 / 316
71.43% covered (warning)
71.43%
20 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureTextService
87.03% covered (warning)
87.03%
275 / 316
71.43% covered (warning)
71.43%
20 / 28
106.30
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
 save
94.59% covered (success)
94.59%
35 / 37
0.00% covered (danger)
0.00%
0 / 1
9.01
 parse
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
13
 getTemplate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAvailableVariables
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 getQrCodeImageBase64
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 signerNameImage
85.71% covered (warning)
85.71%
36 / 42
0.00% covered (danger)
0.00%
0 / 1
6.10
 getCenteredBaselineY
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 splitAndGetLongestHalfLength
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 mbWordwrap
53.19% covered (warning)
53.19%
25 / 47
0.00% covered (danger)
0.00%
0 / 1
51.23
 getDefaultSignatureStampConfig
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getFullSignatureWidth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFullSignatureHeight
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSignatureWidth
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getSignatureHeight
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSanitizedDimension
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getTemplateFontSize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultTemplateFontSize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isCollectMetadataEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSignatureFontSize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRenderMode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildValidationUrl
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getSignatureStampPolicyConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 syncCanonicalDefaultTemplateWithCollectMetadata
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getPreviewSignerName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeRuntimeRenderMode
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 normalizePersistedRenderMode
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
1.02
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use DateTimeInterface;
12use Exception;
13use Imagick;
14use ImagickDraw;
15use ImagickPixel;
16use OCA\Libresign\Exception\LibresignException;
17use OCA\Libresign\Service\Policy\PolicyService;
18use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy;
19use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy;
20use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue;
21use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicy as SignatureTextPolicyProvider;
22use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue;
23use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color;
24use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding;
25use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel;
26use OCA\Libresign\Vendor\Endroid\QrCode\QrCode;
27use OCA\Libresign\Vendor\Endroid\QrCode\RoundBlockSizeMode;
28use OCA\Libresign\Vendor\Endroid\QrCode\Writer\PngWriter;
29use OCA\Libresign\Vendor\Twig\Error\SyntaxError;
30use OCA\Libresign\Vendor\Twig\Sandbox\SecurityError;
31use OCP\IDateTimeZone;
32use OCP\IL10N;
33use OCP\IRequest;
34use OCP\IURLGenerator;
35use OCP\IUserSession;
36use Psr\Log\LoggerInterface;
37use Sabre\DAV\UUIDUtil;
38
39class SignatureTextService {
40    public const SIGNATURE_DIMENSION_MINIMUM = 1;
41    public const FONT_SIZE_MINIMUM = 0.1;
42    public const FRONT_SIZE_MAX = 30;
43    private const int QRCODE_SIZE = 100;
44
45    public function __construct(
46        private IL10N $l10n,
47        private IDateTimeZone $dateTimeZone,
48        private IRequest $request,
49        private IUserSession $userSession,
50        private IURLGenerator $urlGenerator,
51        protected LoggerInterface $logger,
52        private PolicyService $policyService,
53    ) {
54    }
55
56    /**
57     * @return array{template: string, parsed: string, templateFontSize: float, signatureFontSize: float, signatureWidth: float, signatureHeight: float, renderMode: string}
58     * @throws LibresignException
59     */
60    public function save(
61        string $template,
62        float $templateFontSize = SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE,
63        float $signatureFontSize = SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE,
64        float $signatureWidth = SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH,
65        float $signatureHeight = SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT,
66        string $renderMode = SignerElementsService::RENDER_MODE_DEFAULT,
67    ): array {
68        if ($templateFontSize > self::FRONT_SIZE_MAX || $templateFontSize < self::FONT_SIZE_MINIMUM) {
69            // TRANSLATORS This message refers to the font size used in the text
70            // that is used together or to replace a person's handwritten
71            // signature in the signed PDF. The user must enter a numeric value
72            // within the accepted range.
73            throw new LibresignException($this->l10n->t('Invalid template font size. The value must be between %.1f and %.0f.', [self::FONT_SIZE_MINIMUM, self::FRONT_SIZE_MAX]));
74        }
75        if ($signatureFontSize > self::FRONT_SIZE_MAX || $signatureFontSize < self::FONT_SIZE_MINIMUM) {
76            // TRANSLATORS This message refers to the font size used in the text
77            // that is used together or to replace a person's handwritten
78            // signature in the signed PDF. The user must enter a numeric value
79            // within the accepted range.
80            throw new LibresignException($this->l10n->t('Invalid signature font size. The value must be between %.1f and %.0f.', [self::FONT_SIZE_MINIMUM, self::FRONT_SIZE_MAX]));
81        }
82        if (
83            !is_finite($signatureWidth)
84            || !is_finite($signatureHeight)
85            || $signatureWidth < self::SIGNATURE_DIMENSION_MINIMUM
86            || $signatureHeight < self::SIGNATURE_DIMENSION_MINIMUM
87        ) {
88            // TRANSLATORS This message is shown when the visible signature box size
89            // configured by the admin is invalid. "Signature box" is the rectangular
90            // area reserved for the handwritten-style signature image in the signed
91            // PDF. "Width" and "height" are its pixel dimensions. %.0f is the
92            // minimum allowed value for each dimension.
93            throw new LibresignException($this->l10n->t('Invalid signature box size. Width and height must be at least %.0f.', [self::SIGNATURE_DIMENSION_MINIMUM]));
94        }
95        $template = trim($template);
96        $template = preg_replace(
97            [
98                '/>\s+</',
99                '/<br\s*\/?>/i',
100                '/<p[^>]*>/i',
101                '/<\/p>/i',
102            ],
103            [
104                '><',
105                "\n",
106                '',
107                "\n"
108            ],
109            $template
110        );
111        $template = strip_tags((string)$template);
112        $template = trim($template);
113        $template = html_entity_decode($template);
114        $resolvedConfig = $this->getSignatureStampPolicyConfig();
115        $resolvedConfig['template'] = $template;
116        $resolvedConfig['signature_width'] = $signatureWidth;
117        $resolvedConfig['signature_height'] = $signatureHeight;
118        $resolvedConfig['template_font_size'] = $templateFontSize;
119        $resolvedConfig['signature_font_size'] = $signatureFontSize;
120        $resolvedConfig['render_mode'] = $this->normalizePersistedRenderMode($renderMode);
121        $this->policyService->saveSystem(SignatureTextPolicyProvider::KEY, SignatureTextPolicyValue::encode($resolvedConfig));
122        return $this->parse($template);
123    }
124
125    /**
126     * @return array{template: string, parsed: string, templateFontSize: float, signatureFontSize: float, signatureWidth: float, signatureHeight: float, renderMode: string}
127     * @throws LibresignException
128     */
129    public function parse(string $template = '', array $context = []): array {
130        $templateFontSize = $this->getTemplateFontSize();
131        $signatureFontSize = $this->getSignatureFontSize();
132        $signatureWidth = $this->getFullSignatureWidth();
133        $signatureHeight = $this->getFullSignatureHeight();
134        $renderMode = $this->getRenderMode();
135        if (empty($template)) {
136            $template = $this->getTemplate();
137        }
138        if (empty($template)) {
139            return [
140                'parsed' => '',
141                'template' => $template,
142                'templateFontSize' => $templateFontSize,
143                'signatureFontSize' => $signatureFontSize,
144                'signatureWidth' => $signatureWidth,
145                'signatureHeight' => $signatureHeight,
146                'renderMode' => $renderMode,
147            ];
148        }
149        if (empty($context)) {
150            $date = new \DateTime('now', new \DateTimeZone('UTC'));
151            $documentUuid = UUIDUtil::getUUID();
152            $validationUrl = $this->buildValidationUrl($documentUuid);
153            $context = [
154                'DocumentUUID' => $documentUuid,
155                'IssuerCommonName' => 'Acme Cooperative',
156                'LocalSignerSignatureDateOnly' => ($date)->format('Y-m-d'),
157                'LocalSignerSignatureDateTime' => ($date)->format(DateTimeInterface::ATOM),
158                'LocalSignerTimezone' => $this->dateTimeZone->getTimeZone()->getName(),
159                'ServerSignatureDate' => ($date)->format(DateTimeInterface::ATOM),
160                'SignerIP' => $this->request->getRemoteAddress(),
161                'SignerCommonName' => $this->userSession?->getUser()?->getDisplayName() ?? 'John Doe',
162                'SignerEmail' => $this->userSession?->getUser()?->getEMailAddress() ?? 'john.doe@libresign.coop',
163                'SignerUserAgent' => $this->request->getHeader('User-Agent'),
164                'ValidationURL' => $validationUrl,
165                'qrcode' => $this->getQrCodeImageBase64($validationUrl),
166            ];
167        }
168
169        if (!isset($context['ValidationURL']) && isset($context['DocumentUUID']) && is_string($context['DocumentUUID']) && $context['DocumentUUID'] !== '') {
170            $context['ValidationURL'] = $this->buildValidationUrl($context['DocumentUUID']);
171        }
172        if (!isset($context['qrcode']) && isset($context['ValidationURL']) && is_string($context['ValidationURL'])) {
173            $context['qrcode'] = $this->getQrCodeImageBase64($context['ValidationURL']);
174        }
175        try {
176            $parsed = SandboxedTemplateRenderer::render($template, $context);
177            return [
178                'parsed' => $parsed,
179                'template' => $template,
180                'templateFontSize' => $templateFontSize,
181                'signatureFontSize' => $signatureFontSize,
182                'signatureWidth' => $signatureWidth,
183                'signatureHeight' => $signatureHeight,
184                'renderMode' => $renderMode,
185            ];
186        } catch (SecurityError $e) {
187            // TRANSLATORS A previously saved signature stamp template uses unsupported Twig operations and must be corrected in the policy settings.
188            throw new LibresignException($this->l10n->t('The configured signature stamp template is not supported. Ask an administrator to review the signature stamp policy.'), 0, $e);
189        } catch (SyntaxError $e) {
190            throw new LibresignException((string)preg_replace('/in "[^"]+" at line \d+/', '', $e->getMessage()), 0, $e);
191        }
192    }
193
194    public function getTemplate(): string {
195        $config = $this->getSignatureStampPolicyConfig();
196        return (string)($config['template'] ?? '');
197    }
198
199    public function getAvailableVariables(): array {
200        $list = [
201            // TRANSLATORS Description for the template variable {{DocumentUUID}} shown in the signature template helper list.
202            '{{DocumentUUID}}' => $this->l10n->t('Unique identifier of the signed document'),
203            // TRANSLATORS Description for the template variable {{IssuerCommonName}} shown in the signature template helper list.
204            '{{IssuerCommonName}}' => $this->l10n->t('Name of the certificate issuer used for the signature.'),
205            // TRANSLATORS Description for the template variable {{LocalSignerSignatureDateOnly}} shown in the signature template helper list.
206            '{{LocalSignerSignatureDateOnly}}' => $this->l10n->t('Date when the signer created the signature request (without time, in their local time zone).'),
207            // TRANSLATORS Description for the template variable {{LocalSignerSignatureDateTime}} shown in the signature template helper list.
208            '{{LocalSignerSignatureDateTime}}' => $this->l10n->t('Date and time when the signer created the signature request (in their local time zone).'),
209            // TRANSLATORS Description for the template variable {{LocalSignerTimezone}} shown in the signature template helper list.
210            '{{LocalSignerTimezone}}' => $this->l10n->t('Time zone of signer when the signature request was created (in their local time zone).'),
211            // TRANSLATORS Description for the template variable {{ServerSignatureDate}} shown in the signature template helper list. Keep "ISO 8601" unchanged. "date filter" refers to template syntax used to format dates.
212            '{{ServerSignatureDate}}' => $this->l10n->t('Date and time when the signature was applied on the server (ISO 8601 format). Can be formatted using the Twig date filter.'),
213            // TRANSLATORS Description for the template variable {{SignerCommonName}} shown in the signature template helper list. "Common Name (CN)" is the X.509 certificate subject field and "CN" must remain unchanged.
214            '{{SignerCommonName}}' => $this->l10n->t('Common Name (CN) used to identify the document signer.'),
215            // TRANSLATORS Description for the template variable {{SignerEmail}} shown in the signature template helper list.
216            '{{SignerEmail}}' => $this->l10n->t('The signer\'s email is optional and can be left blank.'),
217            // TRANSLATORS Description for the template variable {{SignerIdentifier}} shown in the signature template helper list.
218            '{{SignerIdentifier}}' => $this->l10n->t('Unique information used to identify the signer (such as email, phone number, or username).'),
219            // TRANSLATORS Description for the template variable {{ValidationURL}} shown in the signature template helper list.
220            '{{ValidationURL}}' => $this->l10n->t('Validation URL of the signed document.'),
221            // TRANSLATORS This sentence is a description shown in the list of
222            // available template variables.
223            // Keep placeholder names unchanged: {{ qrcode }} and {{ValidationURL}}.
224            // Keep this HTML snippet unchanged:
225            // <img src="data:image/png;base64,{{ qrcode }}">
226            '{{qrcode}}' => $this->l10n->t('Base64-encoded PNG QR code for the validation URL. In HTML/Twig, use <img src="data:image/png;base64,{{ qrcode }}">. In plain-text templates, use {{ValidationURL}}.'),
227        ];
228        $collectMetadata = $this->isCollectMetadataEnabled();
229        if ($collectMetadata) {
230            // TRANSLATORS Description for the template variable {{SignerIP}} shown in the signature template helper list. This variable is available only when metadata collection policy is enabled.
231            $list['{{SignerIP}}'] = $this->l10n->t('IP address of the person who signed the document.');
232            // TRANSLATORS Description for the template variable {{SignerUserAgent}} shown in the signature template helper list. This variable is available only when metadata collection policy is enabled.
233            $list['{{SignerUserAgent}}'] = $this->l10n->t('Browser and device information of the person who signed the document.');
234        }
235        return $list;
236    }
237
238    private function getQrCodeImageBase64(string $text): string {
239        $qrCode = new QrCode(
240            data: $text,
241            encoding: new Encoding('UTF-8'),
242            errorCorrectionLevel: ErrorCorrectionLevel::Low,
243            size: self::QRCODE_SIZE,
244            margin: 4,
245            roundBlockSizeMode: RoundBlockSizeMode::Margin,
246            foregroundColor: new Color(0, 0, 0),
247            backgroundColor: new Color(255, 255, 255)
248        );
249
250        $writer = new PngWriter();
251        $result = $writer->write($qrCode);
252
253        return base64_encode($result->getString());
254    }
255
256    public function signerNameImage(
257        string $text,
258        int $width,
259        int $height,
260        string $align = 'center',
261        float $fontSize = 0,
262        bool $isDarkTheme = false,
263        float $scale = 5,
264    ): string {
265        if (!extension_loaded('imagick')) {
266            throw new Exception('Extension imagick is not loaded.');
267        }
268        $width *= $scale;
269        $height *= $scale;
270
271        $image = new Imagick();
272        $image->setResolution(600, 600);
273        $image->newImage((int)$width, (int)$height, new ImagickPixel('transparent'));
274        $image->setImageFormat('png');
275
276        $draw = new ImagickDraw();
277        $fonts = Imagick::queryFonts();
278        if ($fonts) {
279            $draw->setFont($fonts[0]);
280        } else {
281            $fallbackFond = __DIR__ . '/../../3rdparty/composer/mpdf/mpdf/ttfonts/DejaVuSerifCondensed.ttf';
282            if (!file_exists($fallbackFond)) {
283                $this->logger->error('No fonts available at system, and fallback font not found: ' . $fallbackFond);
284                throw new LibresignException('No fonts available at system, and fallback font not found: ' . $fallbackFond);
285            }
286            $draw->setFont(__DIR__ . '/../../3rdparty/composer/mpdf/mpdf/ttfonts/DejaVuSerifCondensed.ttf');
287        }
288        if (!$fontSize) {
289            $fontSize = $this->getSignatureFontSize();
290        }
291        $fontSize *= $scale;
292        $draw->setFontSize($fontSize);
293        $draw->setFillColor(new ImagickPixel($isDarkTheme ? 'white' : 'black'));
294        $align = match ($align) {
295            'left' => Imagick::ALIGN_LEFT,
296            'center' => Imagick::ALIGN_CENTER,
297            'right' => Imagick::ALIGN_RIGHT,
298        };
299        $draw->setTextAlignment($align);
300
301        $maxCharsPerLine = $this->splitAndGetLongestHalfLength($text);
302        $wrappedText = $this->mbWordwrap($text, $maxCharsPerLine, "\n", true);
303
304        $textMetrics = $image->queryFontMetrics($draw, $wrappedText);
305        $lineCount = substr_count($wrappedText, "\n") + 1;
306        $y = $this->getCenteredBaselineY($height, $lineCount, $textMetrics['textHeight'], $textMetrics['ascender'], $textMetrics['descender']);
307
308        $x = match ($align) {
309            Imagick::ALIGN_LEFT => 0,
310            Imagick::ALIGN_CENTER => $width / 2,
311            Imagick::ALIGN_RIGHT => $width,
312        };
313
314        $image->annotateImage($draw, $x, $y, 0, $wrappedText);
315
316        $blob = $image->getImagesBlob();
317        $image->destroy();
318
319        return $blob;
320    }
321
322    private function getCenteredBaselineY(
323        float $canvasHeight,
324        int $lineCount,
325        float $lineHeight,
326        float $ascender,
327        float $descender,
328    ): float {
329        $centerY = $canvasHeight / 2;
330        $textBlockHeight = $lineHeight * $lineCount;
331        $visualCenterOffset = ($ascender + $descender) / 2;
332
333        return $centerY - ($textBlockHeight / 2) + $lineHeight - $visualCenterOffset;
334    }
335
336    private function splitAndGetLongestHalfLength(string $text): int {
337        $text = trim($text);
338        $length = mb_strlen($text);
339
340        if ($length === 0) {
341            return 0;
342        }
343
344        $middle = (int)($length / 2);
345        $results = [];
346
347        foreach (['backward' => -1, 'forward' => 1] as $directionName => $direction) {
348            $index = $middle;
349
350            while (
351                $index >= 0
352                && $index < $length
353                && mb_substr($text, $index, 1) !== ' '
354            ) {
355                $index += $direction;
356            }
357
358            if (
359                $index > 0
360                && $index < $length
361                && mb_substr($text, $index, 1) === ' '
362            ) {
363                $first = mb_substr($text, 0, $index);
364                $second = mb_substr($text, $index + 1);
365                $results[] = max(mb_strlen($first), mb_strlen($second));
366            }
367        }
368
369        return !empty($results) ? max($results) : $length;
370    }
371
372    /**
373     * Multibyte-safe version of wordwrap
374     *
375     * @param string $text The text to wrap
376     * @param int $width The number of characters at which the string will be wrapped
377     * @param string $break The line break character
378     * @param bool $cut If true, words longer than $width will be broken
379     * @return string The wrapped text
380     */
381    private function mbWordwrap(string $text, int $width, string $break = "\n", bool $cut = false): string {
382        if ($width <= 0) {
383            return $text;
384        }
385
386        $lines = [];
387        $currentLine = '';
388        $currentLength = 0;
389
390        $paragraphs = explode("\n", $text);
391
392        foreach ($paragraphs as $paragraphIndex => $paragraph) {
393            if ($paragraph === '') {
394                if ($currentLength > 0) {
395                    $lines[] = $currentLine;
396                    $currentLine = '';
397                    $currentLength = 0;
398                }
399                $lines[] = '';
400                continue;
401            }
402
403            $words = explode(' ', $paragraph);
404
405            foreach ($words as $word) {
406                $wordLength = mb_strlen($word);
407
408                if ($cut && $wordLength > $width) {
409                    if ($currentLength > 0) {
410                        $lines[] = $currentLine;
411                        $currentLine = '';
412                        $currentLength = 0;
413                    }
414
415                    while ($wordLength > $width) {
416                        $lines[] = mb_substr($word, 0, $width);
417                        $word = mb_substr($word, $width);
418                        $wordLength = mb_strlen($word);
419                    }
420
421                    if ($wordLength > 0) {
422                        $currentLine = $word;
423                        $currentLength = $wordLength;
424                    }
425                    continue;
426                }
427
428                $spaceLength = ($currentLength > 0) ? 1 : 0;
429                if ($currentLength + $spaceLength + $wordLength > $width && $currentLength > 0) {
430                    $lines[] = $currentLine;
431                    $currentLine = $word;
432                    $currentLength = $wordLength;
433                } else {
434                    if ($currentLength > 0) {
435                        $currentLine .= ' ';
436                        $currentLength++;
437                    }
438                    $currentLine .= $word;
439                    $currentLength += $wordLength;
440                }
441            }
442
443            if ($currentLength > 0 && $paragraphIndex < count($paragraphs) - 1) {
444                $lines[] = $currentLine;
445                $currentLine = '';
446                $currentLength = 0;
447            }
448        }
449
450        if ($currentLength > 0) {
451            $lines[] = $currentLine;
452        }
453
454        return implode($break, $lines);
455    }
456
457    /**
458     * @return array{
459     *     template: string,
460     *     template_font_size: float,
461     *     signature_font_size: float,
462     *     signature_width: float,
463     *     signature_height: float,
464     *     background_type: 'default'|'custom'|'deleted',
465     *     render_mode: 'default'|'graphic'|'text'|'description_only',
466     * }
467     */
468    public function getDefaultSignatureStampConfig(): array {
469        return [
470            'template' => SignatureTextTemplate::translated($this->l10n, $this->isCollectMetadataEnabled()),
471            'template_font_size' => $this->getDefaultTemplateFontSize(),
472            'signature_font_size' => SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE,
473            'signature_width' => SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH,
474            'signature_height' => SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT,
475            'background_type' => 'default',
476            'render_mode' => 'default',
477        ];
478    }
479
480    public function getFullSignatureWidth(): float {
481        return $this->getSanitizedDimension('signature_width', SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH);
482    }
483
484    public function getFullSignatureHeight(): float {
485        return $this->getSanitizedDimension('signature_height', SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT);
486    }
487
488    public function getSignatureWidth(): float {
489        $config = $this->getSignatureStampPolicyConfig();
490        $current = (float)($config['signature_width'] ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH);
491        if ($this->getRenderMode() === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY || !$this->getTemplate()) {
492            return $current;
493        }
494        return $current / 2;
495    }
496
497    public function getSignatureHeight(): float {
498        return $this->getFullSignatureHeight();
499    }
500
501    private function getSanitizedDimension(string $key, float $default): float {
502        $config = $this->getSignatureStampPolicyConfig();
503        $value = (float)($config[$key] ?? $default);
504        if (!is_finite($value) || $value < self::SIGNATURE_DIMENSION_MINIMUM) {
505            $this->logger->warning('Invalid signature dimension found in policy resolution. Falling back to default value in memory.', [
506                'key' => $key,
507                'value' => $value,
508                'default' => $default,
509            ]);
510            return $default;
511        }
512        return $value;
513    }
514
515    public function getTemplateFontSize(): float {
516        $config = $this->getSignatureStampPolicyConfig();
517        return (float)($config['template_font_size'] ?? SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE);
518    }
519
520    public function getDefaultTemplateFontSize(): float {
521        $collectMetadata = $this->isCollectMetadataEnabled();
522        if ($collectMetadata) {
523            return SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE - 0.2;
524        }
525        return SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE;
526    }
527
528    private function isCollectMetadataEnabled(): bool {
529        return (bool)$this->policyService->resolve(CollectMetadataPolicy::KEY)->getEffectiveValue();
530    }
531
532    public function getSignatureFontSize(): float {
533        $config = $this->getSignatureStampPolicyConfig();
534        return (float)($config['signature_font_size'] ?? SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE);
535    }
536
537    public function getRenderMode(): string {
538        $config = $this->getSignatureStampPolicyConfig();
539        return $this->normalizeRuntimeRenderMode((string)($config['render_mode'] ?? SignerElementsService::RENDER_MODE_DEFAULT));
540    }
541
542    public function isEnabled(): bool {
543        return !empty($this->getTemplate());
544    }
545
546    private function buildValidationUrl(string $uuid): string {
547        $footerPolicy = FooterPolicyValue::normalize(
548            $this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue()
549        );
550        $validationSite = trim($footerPolicy['validationSite']);
551        if ($validationSite !== '') {
552            return rtrim($validationSite, '/') . '/' . $uuid;
553        }
554
555        return $this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFileWithShortUrl', [
556            'uuid' => $uuid,
557        ]);
558    }
559
560    /**
561     * @return array{
562     *     template: string,
563     *     template_font_size: float,
564     *     signature_font_size: float,
565     *     signature_width: float,
566     *     signature_height: float,
567     *     background_type: 'default'|'custom'|'deleted',
568     *     render_mode: 'default'|'graphic'|'text'|'description_only',
569     * }
570     */
571    private function getSignatureStampPolicyConfig(): array {
572        $rawValue = $this->policyService->resolve(SignatureTextPolicyProvider::KEY)->getEffectiveValue();
573        return $this->syncCanonicalDefaultTemplateWithCollectMetadata(
574            SignatureTextPolicyValue::normalize($rawValue, $this->getDefaultSignatureStampConfig())
575        );
576    }
577
578    /**
579     * @param array{
580     *     template: string,
581     *     template_font_size: float,
582     *     signature_font_size: float,
583     *     signature_width: float,
584     *     signature_height: float,
585     *     background_type: 'default'|'custom'|'deleted',
586     *     render_mode: 'default'|'graphic'|'text'|'description_only',
587     * } $config
588     * @return array{
589     *     template: string,
590     *     template_font_size: float,
591     *     signature_font_size: float,
592     *     signature_width: float,
593     *     signature_height: float,
594     *     background_type: 'default'|'custom'|'deleted',
595     *     render_mode: 'default'|'graphic'|'text'|'description_only',
596     * }
597     */
598    private function syncCanonicalDefaultTemplateWithCollectMetadata(array $config): array {
599        $template = (string)($config['template'] ?? '');
600        $canonicalWithoutMetadata = SignatureTextTemplate::translated($this->l10n, false);
601        $canonicalWithMetadata = SignatureTextTemplate::translated($this->l10n, true);
602
603        if ($template !== $canonicalWithoutMetadata && $template !== $canonicalWithMetadata) {
604            return $config;
605        }
606
607        $config['template'] = $this->isCollectMetadataEnabled()
608            ? $canonicalWithMetadata
609            : $canonicalWithoutMetadata;
610
611        return $config;
612    }
613
614    public function getPreviewSignerName(): string {
615        return $this->userSession?->getUser()?->getDisplayName() ?? 'John Doe';
616    }
617
618    private function normalizeRuntimeRenderMode(string $renderMode): string {
619        return match ($renderMode) {
620            'default' => SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION,
621            'graphic' => SignerElementsService::RENDER_MODE_GRAPHIC_ONLY,
622            'text' => SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION,
623            'description_only',
624            SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY,
625            SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION,
626            SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION,
627            SignerElementsService::RENDER_MODE_GRAPHIC_ONLY => $renderMode,
628            default => SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION,
629        };
630    }
631
632    private function normalizePersistedRenderMode(string $renderMode): string {
633        return match ($renderMode) {
634            SignerElementsService::RENDER_MODE_GRAPHIC_ONLY, 'graphic' => 'graphic',
635            SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY, 'description_only' => 'description_only',
636            SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION, 'text' => 'text',
637            SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION, 'default' => 'default',
638            default => 'default',
639        };
640    }
641}