Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.03% |
275 / 316 |
|
71.43% |
20 / 28 |
CRAP | |
0.00% |
0 / 1 |
| SignatureTextService | |
87.03% |
275 / 316 |
|
71.43% |
20 / 28 |
106.30 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| save | |
94.59% |
35 / 37 |
|
0.00% |
0 / 1 |
9.01 | |||
| parse | |
96.23% |
51 / 53 |
|
0.00% |
0 / 1 |
13 | |||
| getTemplate | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getAvailableVariables | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
| getQrCodeImageBase64 | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| signerNameImage | |
85.71% |
36 / 42 |
|
0.00% |
0 / 1 |
6.10 | |||
| getCenteredBaselineY | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| splitAndGetLongestHalfLength | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
10 | |||
| mbWordwrap | |
53.19% |
25 / 47 |
|
0.00% |
0 / 1 |
51.23 | |||
| getDefaultSignatureStampConfig | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| getFullSignatureWidth | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getFullSignatureHeight | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getSignatureWidth | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| getSignatureHeight | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getSanitizedDimension | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| getTemplateFontSize | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getDefaultTemplateFontSize | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| isCollectMetadataEnabled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getSignatureFontSize | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getRenderMode | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| isEnabled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| buildValidationUrl | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| getSignatureStampPolicyConfig | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| syncCanonicalDefaultTemplateWithCollectMetadata | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| getPreviewSignerName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| normalizeRuntimeRenderMode | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| normalizePersistedRenderMode | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
1.02 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | /** |
| 5 | * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors |
| 6 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | */ |
| 8 | |
| 9 | namespace OCA\Libresign\Service; |
| 10 | |
| 11 | use DateTimeInterface; |
| 12 | use Exception; |
| 13 | use Imagick; |
| 14 | use ImagickDraw; |
| 15 | use ImagickPixel; |
| 16 | use OCA\Libresign\Exception\LibresignException; |
| 17 | use OCA\Libresign\Service\Policy\PolicyService; |
| 18 | use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy; |
| 19 | use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; |
| 20 | use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; |
| 21 | use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicy as SignatureTextPolicyProvider; |
| 22 | use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue; |
| 23 | use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color; |
| 24 | use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding; |
| 25 | use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel; |
| 26 | use OCA\Libresign\Vendor\Endroid\QrCode\QrCode; |
| 27 | use OCA\Libresign\Vendor\Endroid\QrCode\RoundBlockSizeMode; |
| 28 | use OCA\Libresign\Vendor\Endroid\QrCode\Writer\PngWriter; |
| 29 | use OCA\Libresign\Vendor\Twig\Error\SyntaxError; |
| 30 | use OCA\Libresign\Vendor\Twig\Sandbox\SecurityError; |
| 31 | use OCP\IDateTimeZone; |
| 32 | use OCP\IL10N; |
| 33 | use OCP\IRequest; |
| 34 | use OCP\IURLGenerator; |
| 35 | use OCP\IUserSession; |
| 36 | use Psr\Log\LoggerInterface; |
| 37 | use Sabre\DAV\UUIDUtil; |
| 38 | |
| 39 | class 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 | } |