• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LibreSign / libresign / 22691386672

04 Mar 2026 10:00PM UTC coverage: 53.923%. First build
22691386672

Pull #2595

github

web-flow
Merge 04d871126 into 9f73ddc6b
Pull Request #2595: [WIP] Sign usign only PHP

159 of 252 new or added lines in 7 files covered. (63.1%)

9752 of 18085 relevant lines covered (53.92%)

6.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.97
/lib/Service/SignatureTextService.php
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\AppInfo\Application;
17
use OCA\Libresign\Exception\LibresignException;
18
use OCA\Libresign\Vendor\Twig\Environment;
19
use OCA\Libresign\Vendor\Twig\Error\SyntaxError;
20
use OCA\Libresign\Vendor\Twig\Loader\FilesystemLoader;
21
use OCP\IAppConfig;
22
use OCP\IDateTimeZone;
23
use OCP\IL10N;
24
use OCP\IRequest;
25
use OCP\IUserSession;
26
use Psr\Log\LoggerInterface;
27
use Sabre\DAV\UUIDUtil;
28

29
class SignatureTextService {
30
        public const TEMPLATE_DEFAULT_FONT_SIZE = 10;
31
        public const SIGNATURE_DEFAULT_FONT_SIZE = 20;
32
        public const FONT_SIZE_MINIMUM = 0.1;
33
        public const FRONT_SIZE_MAX = 30;
34
        public const DEFAULT_SIGNATURE_WIDTH = 350;
35
        public const DEFAULT_SIGNATURE_HEIGHT = 100;
36
        public function __construct(
37
                private IAppConfig $appConfig,
38
                private IL10N $l10n,
39
                private IDateTimeZone $dateTimeZone,
40
                private IRequest $request,
41
                private IUserSession $userSession,
42
                protected LoggerInterface $logger,
43
        ) {
44
        }
122✔
45

46
        /**
47
         * @return array{template: string, parsed: string, templateFontSize: float, signatureFontSize: float, signatureWidth: float, signatureHeight: float, renderMode: string}
48
         * @throws LibresignException
49
         */
50
        public function save(
51
                string $template,
52
                float $templateFontSize = self::TEMPLATE_DEFAULT_FONT_SIZE,
53
                float $signatureFontSize = self::SIGNATURE_DEFAULT_FONT_SIZE,
54
                float $signatureWidth = self::DEFAULT_SIGNATURE_WIDTH,
55
                float $signatureHeight = self::DEFAULT_SIGNATURE_HEIGHT,
56
                string $renderMode = SignerElementsService::RENDER_MODE_DEFAULT,
57
        ): array {
58
                if ($templateFontSize > self::FRONT_SIZE_MAX || $templateFontSize < self::FONT_SIZE_MINIMUM) {
7✔
59
                        // TRANSLATORS This message refers to the font size used in the text
60
                        // that is used together or to replace a person's handwritten
61
                        // signature in the signed PDF. The user must enter a numeric value
62
                        // within the accepted range.
63
                        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]));
×
64
                }
65
                if ($signatureFontSize > self::FRONT_SIZE_MAX || $signatureFontSize < self::FONT_SIZE_MINIMUM) {
7✔
66
                        // TRANSLATORS This message refers to the font size used in the text
67
                        // that is used together or to replace a person's handwritten
68
                        // signature in the signed PDF. The user must enter a numeric value
69
                        // within the accepted range.
70
                        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]));
×
71
                }
72
                $template = trim($template);
7✔
73
                $template = preg_replace(
7✔
74
                        [
7✔
75
                                '/>\s+</',
7✔
76
                                '/<br\s*\/?>/i',
7✔
77
                                '/<p[^>]*>/i',
7✔
78
                                '/<\/p>/i',
7✔
79
                        ],
7✔
80
                        [
7✔
81
                                '><',
7✔
82
                                "\n",
7✔
83
                                '',
7✔
84
                                "\n"
7✔
85
                        ],
7✔
86
                        $template
7✔
87
                );
7✔
88
                $template = strip_tags((string)$template);
7✔
89
                $template = trim($template);
7✔
90
                $template = html_entity_decode($template);
7✔
91
                $this->appConfig->setValueString(Application::APP_ID, 'signature_text_template', $template);
7✔
92
                $this->appConfig->setValueFloat(Application::APP_ID, 'signature_width', $signatureWidth);
7✔
93
                $this->appConfig->setValueFloat(Application::APP_ID, 'signature_height', $signatureHeight);
7✔
94
                $this->appConfig->setValueFloat(Application::APP_ID, 'template_font_size', $templateFontSize);
7✔
95
                $this->appConfig->setValueFloat(Application::APP_ID, 'signature_font_size', $signatureFontSize);
7✔
96
                $this->appConfig->setValueString(Application::APP_ID, 'signature_render_mode', $renderMode);
7✔
97
                return $this->parse($template);
7✔
98
        }
99

100
        /**
101
         * @return array{template: string, parsed: string, templateFontSize: float, signatureFontSize: float, signatureWidth: float, signatureHeight: float, renderMode: string}
102
         * @throws LibresignException
103
         */
104
        public function parse(string $template = '', array $context = []): array {
105
                $templateFontSize = $this->getTemplateFontSize();
12✔
106
                $signatureFontSize = $this->getSignatureFontSize();
12✔
107
                $signatureWidth = $this->getFullSignatureWidth();
12✔
108
                $signatureHeight = $this->getFullSignatureHeight();
12✔
109
                $renderMode = $this->getRenderMode();
12✔
110
                if (empty($template)) {
12✔
111
                        $template = $this->getTemplate();
6✔
112
                }
113
                if (empty($template)) {
12✔
114
                        return [
2✔
115
                                'parsed' => '',
2✔
116
                                'template' => $template,
2✔
117
                                'templateFontSize' => $templateFontSize,
2✔
118
                                'signatureFontSize' => $signatureFontSize,
2✔
119
                                'signatureWidth' => $signatureWidth,
2✔
120
                                'signatureHeight' => $signatureHeight,
2✔
121
                                'renderMode' => $renderMode,
2✔
122
                        ];
2✔
123
                }
124
                if (empty($context)) {
10✔
125
                        $date = new \DateTime('now', new \DateTimeZone('UTC'));
6✔
126
                        $context = [
6✔
127
                                'DocumentUUID' => UUIDUtil::getUUID(),
6✔
128
                                'IssuerCommonName' => 'Acme Cooperative',
6✔
129
                                'LocalSignerSignatureDateOnly' => ($date)->format('Y-m-d'),
6✔
130
                                'LocalSignerSignatureDateTime' => ($date)->format(DateTimeInterface::ATOM),
6✔
131
                                'LocalSignerTimezone' => $this->dateTimeZone->getTimeZone()->getName(),
6✔
132
                                'ServerSignatureDate' => ($date)->format(DateTimeInterface::ATOM),
6✔
133
                                'SignerIP' => $this->request->getRemoteAddress(),
6✔
134
                                'SignerCommonName' => $this->userSession?->getUser()?->getDisplayName() ?? 'John Doe',
6✔
135
                                'SignerEmail' => $this->userSession?->getUser()?->getEMailAddress() ?? 'john.doe@libresign.coop',
6✔
136
                                'SignerUserAgent' => $this->request->getHeader('User-Agent'),
6✔
137
                        ];
6✔
138
                }
139
                try {
140
                        $twigEnvironment = new Environment(
10✔
141
                                new FilesystemLoader(),
10✔
142
                        );
10✔
143
                        $parsed = $twigEnvironment
10✔
144
                                ->createTemplate($template)
10✔
145
                                ->render($context);
10✔
146
                        return [
10✔
147
                                'parsed' => $parsed,
10✔
148
                                'template' => $template,
10✔
149
                                'templateFontSize' => $templateFontSize,
10✔
150
                                'signatureFontSize' => $signatureFontSize,
10✔
151
                                'signatureWidth' => $signatureWidth,
10✔
152
                                'signatureHeight' => $signatureHeight,
10✔
153
                                'renderMode' => $renderMode,
10✔
154
                        ];
10✔
155
                } catch (SyntaxError $e) {
×
156
                        throw new LibresignException((string)preg_replace('/in "[^"]+" at line \d+/', '', $e->getMessage()));
×
157
                }
158
        }
159

160
        public function getTemplate(): string {
161
                if ($this->appConfig->hasKey(Application::APP_ID, 'signature_text_template')) {
6✔
162
                        return $this->appConfig->getValueString(Application::APP_ID, 'signature_text_template');
6✔
163
                }
164
                return $this->getDefaultTemplate();
×
165
        }
166

167
        public function getAvailableVariables(): array {
168
                $list = [
2✔
169
                        '{{DocumentUUID}}' => $this->l10n->t('Unique identifier of the signed document'),
2✔
170
                        '{{IssuerCommonName}}' => $this->l10n->t('Name of the certificate issuer used for the signature.'),
2✔
171
                        '{{LocalSignerSignatureDateOnly}}' => $this->l10n->t('Date when the signer sent the request to sign (without time, in their local time zone).'),
2✔
172
                        '{{LocalSignerSignatureDateTime}}' => $this->l10n->t('Date and time when the signer sent the request to sign (in their local time zone).'),
2✔
173
                        '{{LocalSignerTimezone}}' => $this->l10n->t('Time zone of signer when sent the request to sign (in their local time zone).'),
2✔
174
                        '{{ServerSignatureDate}}' => $this->l10n->t('Date and time when the signature was applied on the server. Cannot be formatted using Twig.'),
2✔
175
                        '{{SignerCommonName}}' => $this->l10n->t('Common Name (CN) used to identify the document signer.'),
2✔
176
                        '{{SignerEmail}}' => $this->l10n->t('The signer\'s email is optional and can be left blank.'),
2✔
177
                        '{{SignerIdentifier}}' => $this->l10n->t('Unique information used to identify the signer (such as email, phone number, or username).'),
2✔
178
                ];
2✔
179
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
2✔
180
                if ($collectMetadata) {
2✔
181
                        $list['{{SignerIP}}'] = $this->l10n->t('IP address of the person who signed the document.');
1✔
182
                        $list['{{SignerUserAgent}}'] = $this->l10n->t('Browser and device information of the person who signed the document.');
1✔
183
                }
184
                return $list;
2✔
185
        }
186

187
        public function signerNameImage(
188
                string $text,
189
                int $width,
190
                int $height,
191
                string $align = 'center',
192
                float $fontSize = 0,
193
                bool $isDarkTheme = false,
194
                float $scale = 5,
195
        ): string {
196
                if (!extension_loaded('imagick')) {
12✔
197
                        throw new Exception('Extension imagick is not loaded.');
×
198
                }
199
                $width *= $scale;
12✔
200
                $height *= $scale;
12✔
201

202
                $image = new Imagick();
12✔
203
                $image->setResolution(600, 600);
12✔
204
                $image->newImage((int)$width, (int)$height, new ImagickPixel('transparent'));
12✔
205
                $image->setImageFormat('png');
12✔
206

207
                $draw = new ImagickDraw();
12✔
208
                $fonts = Imagick::queryFonts();
12✔
209
                if ($fonts) {
12✔
210
                        $draw->setFont($fonts[0]);
12✔
211
                } else {
212
                        $fallbackFond = __DIR__ . '/../../3rdparty/composer/mpdf/mpdf/ttfonts/DejaVuSerifCondensed.ttf';
×
213
                        if (!file_exists($fallbackFond)) {
×
214
                                $this->logger->error('No fonts available at system, and fallback font not found: ' . $fallbackFond);
×
215
                                throw new LibresignException('No fonts available at system, and fallback font not found: ' . $fallbackFond);
×
216
                        }
217
                        $draw->setFont(__DIR__ . '/../../3rdparty/composer/mpdf/mpdf/ttfonts/DejaVuSerifCondensed.ttf');
×
218
                }
219
                if (!$fontSize) {
12✔
220
                        $fontSize = $this->getSignatureFontSize();
12✔
221
                }
222
                $fontSize *= $scale;
12✔
223
                $draw->setFontSize($fontSize);
12✔
224
                $draw->setFillColor(new ImagickPixel($isDarkTheme ? 'white' : 'black'));
12✔
225
                $align = match ($align) {
12✔
226
                        'left' => Imagick::ALIGN_LEFT,
4✔
227
                        'center' => Imagick::ALIGN_CENTER,
4✔
228
                        'right' => Imagick::ALIGN_RIGHT,
4✔
229
                };
12✔
230
                $draw->setTextAlignment($align);
12✔
231

232
                $maxCharsPerLine = $this->splitAndGetLongestHalfLength($text);
12✔
233
                $wrappedText = $this->mbWordwrap($text, $maxCharsPerLine, "\n", true);
12✔
234

235
                $textMetrics = $image->queryFontMetrics($draw, $wrappedText);
12✔
236
                $lineCount = substr_count($wrappedText, "\n") + 1;
12✔
237
                $y = $this->getCenteredBaselineY($height, $lineCount, $textMetrics['textHeight'], $textMetrics['ascender'], $textMetrics['descender']);
12✔
238

239
                $x = match ($align) {
12✔
240
                        Imagick::ALIGN_LEFT => 0,
4✔
241
                        Imagick::ALIGN_CENTER => $width / 2,
4✔
242
                        Imagick::ALIGN_RIGHT => $width,
4✔
243
                };
12✔
244

245
                $image->annotateImage($draw, $x, $y, 0, $wrappedText);
12✔
246

247
                $blob = $image->getImagesBlob();
12✔
248
                $image->destroy();
12✔
249

250
                return $blob;
12✔
251
        }
252

253
        private function getCenteredBaselineY(
254
                float $canvasHeight,
255
                int $lineCount,
256
                float $lineHeight,
257
                float $ascender,
258
                float $descender,
259
        ): float {
260
                $centerY = $canvasHeight / 2;
12✔
261
                $textBlockHeight = $lineHeight * $lineCount;
12✔
262
                $visualCenterOffset = ($ascender + $descender) / 2;
12✔
263

264
                return $centerY - ($textBlockHeight / 2) + $lineHeight - $visualCenterOffset;
12✔
265
        }
266

267
        private function splitAndGetLongestHalfLength(string $text): int {
268
                $text = trim($text);
38✔
269
                $length = mb_strlen($text);
38✔
270

271
                if ($length === 0) {
38✔
272
                        return 0;
2✔
273
                }
274

275
                $middle = (int)($length / 2);
36✔
276
                $results = [];
36✔
277

278
                foreach (['backward' => -1, 'forward' => 1] as $directionName => $direction) {
36✔
279
                        $index = $middle;
36✔
280

281
                        while (
282
                                $index >= 0
36✔
283
                                && $index < $length
36✔
284
                                && mb_substr($text, $index, 1) !== ' '
36✔
285
                        ) {
286
                                $index += $direction;
27✔
287
                        }
288

289
                        if (
290
                                $index > 0
36✔
291
                                && $index < $length
36✔
292
                                && mb_substr($text, $index, 1) === ' '
36✔
293
                        ) {
294
                                $first = mb_substr($text, 0, $index);
29✔
295
                                $second = mb_substr($text, $index + 1);
29✔
296
                                $results[] = max(mb_strlen($first), mb_strlen($second));
29✔
297
                        }
298
                }
299

300
                return !empty($results) ? max($results) : $length;
36✔
301
        }
302

303
        /**
304
         * Multibyte-safe version of wordwrap
305
         *
306
         * @param string $text The text to wrap
307
         * @param int $width The number of characters at which the string will be wrapped
308
         * @param string $break The line break character
309
         * @param bool $cut If true, words longer than $width will be broken
310
         * @return string The wrapped text
311
         */
312
        private function mbWordwrap(string $text, int $width, string $break = "\n", bool $cut = false): string {
313
                if ($width <= 0) {
12✔
314
                        return $text;
×
315
                }
316

317
                $lines = [];
12✔
318
                $currentLine = '';
12✔
319
                $currentLength = 0;
12✔
320

321
                $paragraphs = explode("\n", $text);
12✔
322

323
                foreach ($paragraphs as $paragraphIndex => $paragraph) {
12✔
324
                        if ($paragraph === '') {
12✔
325
                                if ($currentLength > 0) {
×
326
                                        $lines[] = $currentLine;
×
327
                                        $currentLine = '';
×
328
                                        $currentLength = 0;
×
329
                                }
330
                                $lines[] = '';
×
331
                                continue;
×
332
                        }
333

334
                        $words = explode(' ', $paragraph);
12✔
335

336
                        foreach ($words as $word) {
12✔
337
                                $wordLength = mb_strlen($word);
12✔
338

339
                                if ($cut && $wordLength > $width) {
12✔
340
                                        if ($currentLength > 0) {
×
341
                                                $lines[] = $currentLine;
×
342
                                                $currentLine = '';
×
343
                                                $currentLength = 0;
×
344
                                        }
345

346
                                        while ($wordLength > $width) {
×
347
                                                $lines[] = mb_substr($word, 0, $width);
×
348
                                                $word = mb_substr($word, $width);
×
349
                                                $wordLength = mb_strlen($word);
×
350
                                        }
351

352
                                        if ($wordLength > 0) {
×
353
                                                $currentLine = $word;
×
354
                                                $currentLength = $wordLength;
×
355
                                        }
356
                                        continue;
×
357
                                }
358

359
                                $spaceLength = ($currentLength > 0) ? 1 : 0;
12✔
360
                                if ($currentLength + $spaceLength + $wordLength > $width && $currentLength > 0) {
12✔
361
                                        $lines[] = $currentLine;
11✔
362
                                        $currentLine = $word;
11✔
363
                                        $currentLength = $wordLength;
11✔
364
                                } else {
365
                                        if ($currentLength > 0) {
12✔
366
                                                $currentLine .= ' ';
5✔
367
                                                $currentLength++;
5✔
368
                                        }
369
                                        $currentLine .= $word;
12✔
370
                                        $currentLength += $wordLength;
12✔
371
                                }
372
                        }
373

374
                        if ($currentLength > 0 && $paragraphIndex < count($paragraphs) - 1) {
12✔
375
                                $lines[] = $currentLine;
×
376
                                $currentLine = '';
×
377
                                $currentLength = 0;
×
378
                        }
379
                }
380

381
                if ($currentLength > 0) {
12✔
382
                        $lines[] = $currentLine;
12✔
383
                }
384

385
                return implode($break, $lines);
12✔
386
        }
387

388
        public function getDefaultTemplate(): string {
389
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
2✔
390
                if ($collectMetadata) {
2✔
391
                        // TRANSLATORS Variables enclosed in double curly braces {{variableName}} are template placeholders.
392
                        //
393
                        // DO NOT translate or remove these variables:
394
                        // - {{SignerCommonName}}
395
                        // - {{IssuerCommonName}}
396
                        // - {{ServerSignatureDate}}
397
                        // - {{SignerIP}}
398
                        // - {{SignerUserAgent}}
399
                        //
400
                        // Only translate the text outside the curly braces, such as:
401
                        // - "Signed with LibreSign"
402
                        // - "Issuer:"
403
                        // - "Date:"
404
                        // - "IP:"
405
                        // - "User agent:"
406
                        return $this->l10n->t(
1✔
407
                                "Signed with LibreSign\n"
1✔
408
                                . "{{SignerCommonName}}\n"
1✔
409
                                . "Issuer: {{IssuerCommonName}}\n"
1✔
410
                                . "Date: {{ServerSignatureDate}}\n"
1✔
411
                                . "IP: {{SignerIP}}\n"
1✔
412
                                . 'User agent: {{SignerUserAgent}}'
1✔
413
                        );
1✔
414
                }
415
                // TRANSLATORS Variables enclosed in double curly braces {{variableName}} are template placeholders.
416
                //
417
                // DO NOT translate or remove these variables:
418
                // - {{SignerCommonName}}
419
                // - {{IssuerCommonName}}
420
                // - {{ServerSignatureDate}}
421
                //
422
                // Only translate the text outside the curly braces, such as:
423
                // - "Signed with LibreSign"
424
                // - "Issuer:"
425
                // - "Date:"
426
                return $this->l10n->t(
1✔
427
                        "Signed with LibreSign\n"
1✔
428
                        . "{{SignerCommonName}}\n"
1✔
429
                        . "Issuer: {{IssuerCommonName}}\n"
1✔
430
                        . 'Date: {{ServerSignatureDate}}'
1✔
431
                );
1✔
432
        }
433

434
        public function getFullSignatureWidth(): float {
435
                return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_width', self::DEFAULT_SIGNATURE_WIDTH);
12✔
436
        }
437

438
        public function getFullSignatureHeight(): float {
439
                return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_height', self::DEFAULT_SIGNATURE_HEIGHT);
12✔
440
        }
441

442
        public function getSignatureWidth(): float {
443
                $current = $this->appConfig->getValueFloat(Application::APP_ID, 'signature_width', self::DEFAULT_SIGNATURE_WIDTH);
×
NEW
444
                if ($this->getRenderMode() === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY || !$this->getTemplate()) {
×
445
                        return $current;
×
446
                }
447
                return $current / 2;
×
448
        }
449

450
        public function getSignatureHeight(): float {
451
                return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_height', self::DEFAULT_SIGNATURE_HEIGHT);
×
452
        }
453

454
        public function getTemplateFontSize(): float {
455
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
12✔
456
                if ($collectMetadata) {
12✔
457
                        return $this->appConfig->getValueFloat(Application::APP_ID, 'template_font_size', self::TEMPLATE_DEFAULT_FONT_SIZE - 1);
×
458
                }
459
                return $this->appConfig->getValueFloat(Application::APP_ID, 'template_font_size', self::TEMPLATE_DEFAULT_FONT_SIZE);
12✔
460
        }
461

462
        public function getDefaultTemplateFontSize(): float {
463
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
×
464
                if ($collectMetadata) {
×
465
                        return self::TEMPLATE_DEFAULT_FONT_SIZE - 0.2;
×
466
                }
467
                return self::TEMPLATE_DEFAULT_FONT_SIZE;
×
468
        }
469

470
        public function getSignatureFontSize(): float {
471
                return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_font_size', self::SIGNATURE_DEFAULT_FONT_SIZE);
24✔
472
        }
473

474
        public function getRenderMode(): string {
475
                return $this->appConfig->getValueString(Application::APP_ID, 'signature_render_mode', SignerElementsService::RENDER_MODE_DEFAULT);
19✔
476
        }
477

478
        public function isEnabled(): bool {
479
                return !empty($this->getTemplate());
×
480
        }
481
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc