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

LibreSign / libresign / 22681365316

04 Mar 2026 05:34PM UTC coverage: 53.904%. First build
22681365316

Pull #2595

github

web-flow
Merge 96aa37c46 into b3df6e8bc
Pull Request #2595: [WIP] Sign usign only PHP

148 of 234 new or added lines in 7 files covered. (63.25%)

9740 of 18069 relevant lines covered (53.9%)

6.38 hits per line

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

65.75
/lib/Handler/SignEngine/PhpNativeHandler.php
1
<?php
2

3
declare(strict_types=1);
4
/**
5
 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6
 * SPDX-License-Identifier: AGPL-3.0-or-later
7
 */
8

9
namespace OCA\Libresign\Handler\SignEngine;
10

11
use OCA\Libresign\AppInfo\Application;
12
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
13
use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService;
14
use OCA\Libresign\Service\SignatureBackgroundService;
15
use OCA\Libresign\Service\SignatureTextService;
16
use OCA\Libresign\Service\SignerElementsService;
17
use OCP\Files\File;
18
use OCP\IAppConfig;
19
use SignerPHP\Application\DTO\CertificateCredentialsDto;
20
use SignerPHP\Application\DTO\CertificationLevel;
21
use SignerPHP\Application\DTO\PdfContentDto;
22
use SignerPHP\Application\DTO\SignatureActorDto;
23
use SignerPHP\Application\DTO\SignatureAppearanceDto;
24
use SignerPHP\Application\DTO\SignatureAppearanceXObjectDto;
25
use SignerPHP\Application\DTO\SignatureMetadataDto;
26
use SignerPHP\Application\DTO\SigningOptionsDto;
27
use SignerPHP\Application\DTO\SignPdfRequestDto;
28
use SignerPHP\Application\DTO\TimestampOptionsDto;
29
use SignerPHP\Application\Service\PdfSigningService;
30
use SignerPHP\Infrastructure\Legacy\OpenSslCertificateValidator;
31
use SignerPHP\Infrastructure\Native\NativePdfSigningEngine;
32

33
class PhpNativeHandler extends Pkcs12Handler {
34
        public function __construct(
35
                private IAppConfig $appConfig,
36
                private DocMdpConfigService $docMdpConfigService,
37
                private SignatureTextService $signatureTextService,
38
                private SignatureBackgroundService $signatureBackgroundService,
39
                protected CertificateEngineFactory $certificateEngineFactory,
40
        ) {
41
        }
31✔
42

43
        #[\Override]
44
        public function sign(): File {
NEW
45
                $this->beforeSign();
×
NEW
46
                $signedContent = $this->getSignedContent();
×
NEW
47
                $this->getInputFile()->putContent($signedContent);
×
NEW
48
                return $this->getInputFile();
×
49
        }
50

51
        #[\Override]
52
        public function getSignedContent(): string {
NEW
53
                $pdfContent = $this->getInputFile()->getContent();
×
NEW
54
                $certificate = CertificateCredentialsDto::fromContent(
×
NEW
55
                        $this->getCertificate(),
×
NEW
56
                        $this->getPassword(),
×
NEW
57
                );
×
NEW
58
                $service = new PdfSigningService(
×
NEW
59
                        new OpenSslCertificateValidator(),
×
NEW
60
                        new NativePdfSigningEngine(),
×
NEW
61
                );
×
62

NEW
63
                $visibleElements = $this->getVisibleElements();
×
NEW
64
                $metadata = $this->buildMetadata();
×
NEW
65
                $timestamp = $this->buildTimestampOptions();
×
NEW
66
                $certificationLevel = $this->resolveCertificationLevel(empty($visibleElements));
×
67

NEW
68
                if (empty($visibleElements)) {
×
NEW
69
                        return $service->sign(SignPdfRequestDto::fromRequired(
×
NEW
70
                                new PdfContentDto($pdfContent),
×
NEW
71
                                $certificate,
×
NEW
72
                                new SigningOptionsDto(
×
NEW
73
                                        metadata: $metadata,
×
NEW
74
                                        timestamp: $timestamp,
×
NEW
75
                                        certificationLevel: $certificationLevel,
×
NEW
76
                                ),
×
NEW
77
                        ));
×
78
                }
79

NEW
80
                $applyOnce = $certificationLevel;
×
81
                // signer-php expects screen/top-left coords (Y=0 at top, grows downward).
82
                // LibreSign stores PDF bottom-left coords (Y=0 at bottom, lly < ury).
83
                // Conversion: screen_y = pageHeight - pdf_y
84
                // Page dimensions come from FileEntity::getMetadata()['d'] (0-based array of ['w','h']).
NEW
85
                $pageDimensions = $this->getSignatureParams()['PageDimensions'] ?? [];
×
NEW
86
                foreach ($visibleElements as $element) {
×
NEW
87
                        $fileElement = $element->getFileElement();
×
NEW
88
                        $llx = (float)($fileElement->getLlx() ?? 0);
×
NEW
89
                        $lly = (float)($fileElement->getLly() ?? 0);
×
NEW
90
                        $urx = (float)($fileElement->getUrx() ?? 0);
×
NEW
91
                        $ury = (float)($fileElement->getUry() ?? 0);
×
NEW
92
                        $width = (int)($urx - $llx);
×
NEW
93
                        $height = (int)($ury - $lly);
×
94
                        // signer-php uses 0-based page index; LibreSign stores 1-based
NEW
95
                        $pageIndex = max(0, $fileElement->getPage() - 1);
×
NEW
96
                        $pageHeight = $this->resolvePageHeight($pageDimensions, $pageIndex);
×
NEW
97
                        $appearance = $this->buildAppearanceForElement(
×
NEW
98
                                llx: $llx,
×
NEW
99
                                lly: $lly,
×
NEW
100
                                urx: $urx,
×
NEW
101
                                ury: $ury,
×
NEW
102
                                pageHeight: $pageHeight,
×
NEW
103
                                pageIndex: $pageIndex,
×
NEW
104
                                width: $width,
×
NEW
105
                                height: $height,
×
NEW
106
                                signatureImagePath: $element->getTempFile(),
×
NEW
107
                        );
×
NEW
108
                        $pdfContent = $service->sign(SignPdfRequestDto::fromRequired(
×
NEW
109
                                new PdfContentDto($pdfContent),
×
NEW
110
                                $certificate,
×
NEW
111
                                new SigningOptionsDto(
×
NEW
112
                                        metadata: $metadata,
×
NEW
113
                                        appearance: $appearance,
×
NEW
114
                                        timestamp: $timestamp,
×
115
                                        // DocMDP only applies once (the first signature certifies)
NEW
116
                                        certificationLevel: $applyOnce,
×
NEW
117
                                ),
×
NEW
118
                        ));
×
NEW
119
                        $applyOnce = null;
×
120
                }
121

NEW
122
                return $pdfContent;
×
123
        }
124

125
        private function buildAppearanceForElement(
126
                float $llx,
127
                float $lly,
128
                float $urx,
129
                float $ury,
130
                float $pageHeight,
131
                int $pageIndex,
132
                int $width,
133
                int $height,
134
                string $signatureImagePath = '',
135
        ): SignatureAppearanceDto {
136
                $renderMode = $this->signatureTextService->getRenderMode();
4✔
137

138
                // n0 layer: background stamp is always placed full-bbox when enabled.
139
                $imagePath = $this->signatureBackgroundService->isEnabled()
4✔
NEW
140
                        ? $this->signatureBackgroundService->getImagePath()
×
141
                        : null;
4✔
142

143
                // GRAPHIC_AND_DESCRIPTION: user's drawn image goes into the n2 xObject layer
144
                // on the left half of the bbox so it does not distort or cover the description text.
145
                // Background (if enabled) still occupies the full n0 layer behind everything.
146
                $userImgPath = null;
4✔
147
                $userImgRect = null;
4✔
148
                if ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION) {
4✔
149
                        if ($signatureImagePath !== '' && is_file($signatureImagePath)) {
2✔
150
                                $userImgPath = $signatureImagePath;
1✔
151
                                $userImgRect = [0.0, 0.0, (float)$width / 2.0, (float)$height];
1✔
152
                        }
153
                }
154

155
                return new SignatureAppearanceDto(
4✔
156
                        backgroundImagePath: $imagePath,
4✔
157
                        rect: [
4✔
158
                                $llx,
4✔
159
                                $pageHeight - $ury,  // screen top = pageH - PDF ury
4✔
160
                                $urx,
4✔
161
                                $pageHeight - $lly,  // screen bottom = pageH - PDF lly
4✔
162
                        ],
4✔
163
                        page: $pageIndex,
4✔
164
                        xObject: $this->buildXObject($width, $height, $renderMode),
4✔
165
                        signatureImagePath: $userImgPath,
4✔
166
                        signatureImageFrame: $userImgRect,
4✔
167
                );
4✔
168
        }
169

170
        #[\Override]
171
        public function readCertificate(): array {
172
                $result = $this->certificateEngineFactory
1✔
173
                        ->getEngine()
1✔
174
                        ->readCertificate(
1✔
175
                                $this->getCertificate(),
1✔
176
                                $this->getPassword()
1✔
177
                        );
1✔
178

179
                if (!is_array($result)) {
1✔
NEW
180
                        throw new \RuntimeException('Failed to read certificate data');
×
181
                }
182

183
                return $result;
1✔
184
        }
185

186
        private function buildMetadata(): SignatureMetadataDto {
NEW
187
                $params = $this->getSignatureParams();
×
NEW
188
                $name = !empty($params['SignerCommonName']) ? (string)$params['SignerCommonName'] : null;
×
NEW
189
                $email = !empty($params['SignerEmail']) ? (string)$params['SignerEmail'] : null;
×
190

NEW
191
                return new SignatureMetadataDto(
×
NEW
192
                        actor: ($name !== null || $email !== null)
×
NEW
193
                                ? new SignatureActorDto(name: $name, contactInfo: $email)
×
NEW
194
                                : null,
×
NEW
195
                );
×
196
        }
197

198
        private function resolvePageHeight(array $pageDimensions, int $pageIndex): float {
199
                $pageHeight = $pageDimensions[$pageIndex]['h'] ?? null;
1✔
200
                if (!is_numeric($pageHeight) || (float)$pageHeight <= 0.0) {
1✔
201
                        throw new \RuntimeException(sprintf('Missing or invalid PageDimensions for page index %d.', $pageIndex));
1✔
202
                }
NEW
203
                return (float)$pageHeight;
×
204
        }
205

206
        private function buildTimestampOptions(): ?TimestampOptionsDto {
207
                $tsaUrl = $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', '');
4✔
208
                if (empty($tsaUrl)) {
4✔
209
                        return null;
1✔
210
                }
211

212
                $username = null;
3✔
213
                $password = null;
3✔
214
                $authType = $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none');
3✔
215
                if ($authType === 'basic') {
3✔
216
                        $username = $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', '') ?: null;
2✔
217
                        $password = $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', '') ?: null;
2✔
218
                }
219

220
                return new TimestampOptionsDto(
3✔
221
                        tsaUrl: $tsaUrl,
3✔
222
                        username: $username,
3✔
223
                        password: $password,
3✔
224
                );
3✔
225
        }
226

227
        private function resolveCertificationLevel(bool $noVisibleElements): ?CertificationLevel {
228
                if (!$this->docMdpConfigService->isEnabled()) {
4✔
229
                        return null;
1✔
230
                }
231

232
                // DocMDP values mirror CertificationLevel: 1=NoChanges, 2=FormFilling, 3=FormFillAndAnnotations
233
                $level = $this->docMdpConfigService->getLevel()->value;
3✔
234
                // Only certify on invisible signatures or on the first visible element
235
                if ($noVisibleElements || !$this->hasExistingSignatures($this->getInputFile()->getContent())) {
3✔
236
                        return CertificationLevel::fromInt($level);
2✔
237
                }
238

239
                return null;
1✔
240
        }
241

242
        private function hasExistingSignatures(string $pdfContent): bool {
243
                return (bool)preg_match('/\/ByteRange\s*\[|\/Type\s*\/Sig\b|\/DocMDP\b|\/Perms\b/', $pdfContent);
7✔
244
        }
245

246
        /**
247
         * Builds the xObject (n2 layer) for all render modes using only PDF text operators.
248
         *
249
         * DESCRIPTION_ONLY      → description text, full width.
250
         * GRAPHIC_AND_DESCRIPTION → description text, right half only
251
         *                           (user image is in imagePath/n0, handled natively by signer-php).
252
         * SIGNAME_AND_DESCRIPTION → signer name as large text on the left half
253
         *                           + description text on the right half.
254
         *                           No image generation: pure PDF text operators.
255
         */
256
        private function buildXObject(int $width, int $height, string $renderMode): SignatureAppearanceXObjectDto {
257
                $params = $this->getSignatureParams();
8✔
258
                $serverTimezone = new \DateTimeZone(date_default_timezone_get());
8✔
259
                $now = new \DateTime('now', $serverTimezone);
8✔
260
                $params['ServerSignatureDate'] = $now->format('Y.m.d H:i:s \U\T\C');
8✔
261

262
                $textData = $this->signatureTextService->parse(context: $params);
8✔
263
                $parsed = trim((string)($textData['parsed'] ?? ''));
8✔
264

265
                $descFontSize = (float)($textData['templateFontSize'] ?? $this->signatureTextService->getTemplateFontSize());
8✔
266
                $descLineHeight = $descFontSize * 1.2;
8✔
267
                $leftPadding = max(2.0, $descFontSize * 0.15);
8✔
268

269
                $isDescriptionOnly = $renderMode === SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY;
8✔
270
                $textStartX = $isDescriptionOnly ? $leftPadding : ((float)$width / 2.0) + $leftPadding;
8✔
271
                $availableWidth = $isDescriptionOnly ? (float)$width : (float)$width / 2.0;
8✔
272

273
                $stream = '';
8✔
274

275
                // Left half: signer name as large text operators (SIGNAME_AND_DESCRIPTION only).
276
                // No image generation — the name is drawn directly with PDF text commands.
277
                if ($renderMode === SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION) {
8✔
278
                        $commonName = !empty($params['SignerCommonName'])
2✔
279
                                ? (string)$params['SignerCommonName']
1✔
280
                                : ($this->readCertificate()['subject']['CN'] ?? '');
1✔
281
                        if ($commonName !== '') {
2✔
282
                                $nameFontSize = $this->signatureTextService->getSignatureFontSize();
1✔
283
                                $leftHalfW = (float)$width / 2.0 - $leftPadding * 2;
1✔
284
                                $nameLines = $this->wrapTextForPdf($commonName, $leftHalfW, $nameFontSize);
1✔
285
                                $nameLineCount = count($nameLines);
1✔
286
                                $totalNameHeight = $nameLineCount * $nameFontSize * 1.2;
1✔
287
                                $nameStartY = ((float)$height + $totalNameHeight) / 2.0 - $nameFontSize;
1✔
288
                                $nameStartY = max(0.0, $nameStartY);
1✔
289
                                $nameY = $nameStartY;
1✔
290
                                foreach ($nameLines as $nameLine) {
1✔
291
                                        $escaped = $this->escapePdfText($nameLine);
1✔
292
                                        $stream .= "BT\n";
1✔
293
                                        $stream .= sprintf("/F1 %.2F Tf\n", $nameFontSize);
1✔
294
                                        $stream .= "0 0 0 rg\n";
1✔
295
                                        $stream .= sprintf("%.2F %.2F Td\n", $leftPadding, $nameY);
1✔
296
                                        $stream .= sprintf("(%s) Tj\n", $escaped);
1✔
297
                                        $stream .= "ET\n";
1✔
298
                                        $nameY -= $nameFontSize * 1.2;
1✔
299
                                }
300
                        }
301
                }
302

303
                // Right half (or full width): description text.
304
                $currentY = (float)$height - $descFontSize - 2.0;
8✔
305
                foreach (explode(PHP_EOL, $parsed) as $line) {
8✔
306
                        $wrappedLines = $this->wrapTextForPdf($line, $availableWidth, $descFontSize);
8✔
307
                        foreach ($wrappedLines as $wrappedLine) {
8✔
308
                                if ($currentY < 0) {
8✔
NEW
309
                                        break 2;
×
310
                                }
311
                                $escaped = $this->escapePdfText($wrappedLine);
8✔
312
                                $stream .= "BT\n";
8✔
313
                                $stream .= sprintf("/F1 %.2F Tf\n", $descFontSize);
8✔
314
                                $stream .= "0 0 0 rg\n";
8✔
315
                                $stream .= sprintf("%.2F %.2F Td\n", $textStartX, $currentY);
8✔
316
                                $stream .= sprintf("(%s) Tj\n", $escaped);
8✔
317
                                $stream .= "ET\n";
8✔
318
                                $currentY -= $descLineHeight;
8✔
319
                        }
320
                }
321

322
                return new SignatureAppearanceXObjectDto(
8✔
323
                        stream: $stream,
8✔
324
                        resources: [
8✔
325
                                'Font' => [
8✔
326
                                        'F1' => [
8✔
327
                                                'Type' => '/Font',
8✔
328
                                                'Subtype' => '/Type1',
8✔
329
                                                'BaseFont' => '/Helvetica',
8✔
330
                                        ],
8✔
331
                                ],
8✔
332
                        ],
8✔
333
                );
8✔
334
        }
335

336
        /**
337
         * @return string[]
338
         */
339
        private function wrapTextForPdf(string $line, float $availableWidth, float $fontSize): array {
340
                $trimmed = trim($line);
12✔
341
                if ($trimmed === '') {
12✔
342
                        return [''];
1✔
343
                }
344

345
                $estimatedCharWidth = max(1.0, $fontSize * 0.52);
11✔
346
                $maxChars = max(1, (int)floor($availableWidth / $estimatedCharWidth));
11✔
347
                if (strlen($trimmed) <= $maxChars) {
11✔
348
                        return [$trimmed];
9✔
349
                }
350

351
                $result = [];
2✔
352
                $current = '';
2✔
353
                foreach (preg_split('/\s+/', $trimmed) ?: [] as $word) {
2✔
354
                        if ($word === '') {
2✔
NEW
355
                                continue;
×
356
                        }
357

358
                        $candidate = $current === '' ? $word : $current . ' ' . $word;
2✔
359
                        if (strlen($candidate) <= $maxChars) {
2✔
360
                                $current = $candidate;
1✔
361
                                continue;
1✔
362
                        }
363

364
                        if ($current !== '') {
2✔
365
                                $result[] = $current;
1✔
366
                                $current = '';
1✔
367
                        }
368

369
                        while (strlen($word) > $maxChars) {
2✔
370
                                $result[] = substr($word, 0, $maxChars);
1✔
371
                                $word = substr($word, $maxChars);
1✔
372
                        }
373

374
                        $current = $word;
2✔
375
                }
376

377
                if ($current !== '') {
2✔
378
                        $result[] = $current;
2✔
379
                }
380

381
                return $result;
2✔
382
        }
383

384
        private function escapePdfText(string $value): string {
385
                $value = str_replace('\\', '\\\\', $value);
13✔
386
                $value = str_replace('(', '\\(', $value);
13✔
387
                $value = str_replace(')', '\\)', $value);
13✔
388

389
                return $value;
13✔
390
        }
391
}
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