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

LibreSign / libresign / 20106823262

10 Dec 2025 05:03PM UTC coverage: 43.837%. First build
20106823262

Pull #6074

github

web-flow
Merge a025bc90f into dd2331915
Pull Request #6074: fix: multibyte text wrapping

26 of 48 new or added lines in 1 file covered. (54.17%)

5758 of 13135 relevant lines covered (43.84%)

5.12 hits per line

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

82.38
/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
        }
137✔
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();
22✔
106
                $signatureFontSize = $this->getSignatureFontSize();
22✔
107
                $signatureWidth = $this->getFullSignatureWidth();
22✔
108
                $signatureHeight = $this->getFullSignatureHeight();
22✔
109
                $renderMode = $this->getRenderMode();
22✔
110
                if (empty($template)) {
22✔
111
                        $template = $this->getTemplate();
16✔
112
                }
113
                if (empty($template)) {
22✔
114
                        return [
5✔
115
                                'parsed' => '',
5✔
116
                                'template' => $template,
5✔
117
                                'templateFontSize' => $templateFontSize,
5✔
118
                                'signatureFontSize' => $signatureFontSize,
5✔
119
                                'signatureWidth' => $signatureWidth,
5✔
120
                                'signatureHeight' => $signatureHeight,
5✔
121
                                'renderMode' => $renderMode,
5✔
122
                        ];
5✔
123
                }
124
                if (empty($context)) {
17✔
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(
17✔
141
                                new FilesystemLoader(),
17✔
142
                        );
17✔
143
                        $parsed = $twigEnvironment
17✔
144
                                ->createTemplate($template)
17✔
145
                                ->render($context);
17✔
146
                        return [
17✔
147
                                'parsed' => $parsed,
17✔
148
                                'template' => $template,
17✔
149
                                'templateFontSize' => $templateFontSize,
17✔
150
                                'signatureFontSize' => $signatureFontSize,
17✔
151
                                'signatureWidth' => $signatureWidth,
17✔
152
                                'signatureHeight' => $signatureHeight,
17✔
153
                                'renderMode' => $renderMode,
17✔
154
                        ];
17✔
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')) {
16✔
162
                        return $this->appConfig->getValueString(Application::APP_ID, 'signature_text_template');
16✔
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')) {
15✔
197
                        throw new Exception('Extension imagick is not loaded.');
×
198
                }
199
                $width *= $scale;
15✔
200
                $height *= $scale;
15✔
201

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

207
                $draw = new ImagickDraw();
15✔
208
                $fonts = Imagick::queryFonts();
15✔
209
                if ($fonts) {
15✔
210
                        $draw->setFont($fonts[0]);
15✔
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) {
15✔
220
                        $fontSize = $this->getSignatureFontSize();
12✔
221
                }
222
                $fontSize *= $scale;
15✔
223
                $draw->setFontSize($fontSize);
15✔
224
                $draw->setFillColor(new ImagickPixel($isDarkTheme ? 'white' : 'black'));
15✔
225
                $align = match ($align) {
15✔
226
                        'left' => Imagick::ALIGN_LEFT,
4✔
227
                        'center' => Imagick::ALIGN_CENTER,
7✔
228
                        'right' => Imagick::ALIGN_RIGHT,
4✔
229
                };
15✔
230
                $draw->setTextAlignment($align);
15✔
231

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

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

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

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

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

250
                return $blob;
15✔
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;
15✔
261
                $textBlockHeight = $lineHeight * $lineCount;
15✔
262
                $visualCenterOffset = ($ascender + $descender) / 2;
15✔
263

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

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

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

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

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

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

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

300
                return !empty($results) ? max($results) : $length;
39✔
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) {
15✔
NEW
314
                        return $text;
×
315
                }
316

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

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

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

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

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

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

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

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

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

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

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

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

388
        public function getDefaultTemplate(): string {
389
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
2✔
390
                if ($collectMetadata) {
2✔
391
                        return $this->l10n->t(
1✔
392
                                "Signed with LibreSign\n"
1✔
393
                                . "{{SignerCommonName}}\n"
1✔
394
                                . "Issuer: {{IssuerCommonName}}\n"
1✔
395
                                . "Date: {{ServerSignatureDate}}\n"
1✔
396
                                . "IP: {{SignerIP}}\n"
1✔
397
                                . 'User agent: {{SignerUserAgent}}'
1✔
398
                        );
1✔
399
                }
400
                return $this->l10n->t(
1✔
401
                        "Signed with LibreSign\n"
1✔
402
                        . "{{SignerCommonName}}\n"
1✔
403
                        . "Issuer: {{IssuerCommonName}}\n"
1✔
404
                        . 'Date: {{ServerSignatureDate}}'
1✔
405
                );
1✔
406
        }
407

408
        public function getFullSignatureWidth(): float {
409
                return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_width', self::DEFAULT_SIGNATURE_WIDTH);
22✔
410
        }
411

412
        public function getFullSignatureHeight(): float {
413
                return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_height', self::DEFAULT_SIGNATURE_HEIGHT);
22✔
414
        }
415

416
        public function getSignatureWidth(): float {
417
                $current = $this->appConfig->getValueFloat(Application::APP_ID, 'signature_width', self::DEFAULT_SIGNATURE_WIDTH);
×
418
                if ($this->getRenderMode() === 'GRAPHIC_ONLY' || !$this->getTemplate()) {
×
419
                        return $current;
×
420
                }
421
                return $current / 2;
×
422
        }
423

424
        public function getSignatureHeight(): float {
425
                return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_height', self::DEFAULT_SIGNATURE_HEIGHT);
×
426
        }
427

428
        public function getTemplateFontSize(): float {
429
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
22✔
430
                if ($collectMetadata) {
22✔
431
                        return $this->appConfig->getValueFloat(Application::APP_ID, 'template_font_size', self::TEMPLATE_DEFAULT_FONT_SIZE - 1);
7✔
432
                }
433
                return $this->appConfig->getValueFloat(Application::APP_ID, 'template_font_size', self::TEMPLATE_DEFAULT_FONT_SIZE);
15✔
434
        }
435

436
        public function getDefaultTemplateFontSize(): float {
437
                $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
×
438
                if ($collectMetadata) {
×
439
                        return self::TEMPLATE_DEFAULT_FONT_SIZE - 0.2;
×
440
                }
441
                return self::TEMPLATE_DEFAULT_FONT_SIZE;
×
442
        }
443

444
        public function getSignatureFontSize(): float {
445
                return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_font_size', self::SIGNATURE_DEFAULT_FONT_SIZE);
34✔
446
        }
447

448
        public function getRenderMode(): string {
449
                return $this->appConfig->getValueString(Application::APP_ID, 'signature_render_mode', SignerElementsService::RENDER_MODE_DEFAULT);
29✔
450
        }
451

452
        public function isEnabled(): bool {
453
                return !empty($this->getTemplate());
×
454
        }
455
}
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