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

LibreSign / libresign / 22690108908

04 Mar 2026 09:26PM UTC coverage: 53.923%. First build
22690108908

Pull #2595

github

web-flow
Merge 6ab948c35 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

66.81
/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
        }
34✔
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
                                        useDefaultAppearance: false,
×
NEW
77
                                ),
×
NEW
78
                        ));
×
79
                }
80

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

NEW
123
                return $pdfContent;
×
124
        }
125

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

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

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

162
                return new SignatureAppearanceDto(
5✔
163
                        backgroundImagePath: $imagePath,
5✔
164
                        rect: [
5✔
165
                                $llx,
5✔
166
                                $pageHeight - $ury,  // screen top = pageH - PDF ury
5✔
167
                                $urx,
5✔
168
                                $pageHeight - $lly,  // screen bottom = pageH - PDF lly
5✔
169
                        ],
5✔
170
                        page: $pageIndex,
5✔
171
                        xObject: $this->buildXObject($width, $height, $renderMode),
5✔
172
                        signatureImagePath: $userImgPath,
5✔
173
                        signatureImageFrame: $userImgRect,
5✔
174
                );
5✔
175
        }
176

177
        #[\Override]
178
        public function readCertificate(): array {
179
                $result = $this->certificateEngineFactory
1✔
180
                        ->getEngine()
1✔
181
                        ->readCertificate(
1✔
182
                                $this->getCertificate(),
1✔
183
                                $this->getPassword()
1✔
184
                        );
1✔
185

186
                if (!is_array($result)) {
1✔
NEW
187
                        throw new \RuntimeException('Failed to read certificate data');
×
188
                }
189

190
                return $result;
1✔
191
        }
192

193
        private function buildMetadata(): SignatureMetadataDto {
NEW
194
                $params = $this->getSignatureParams();
×
NEW
195
                $name = !empty($params['SignerCommonName']) ? (string)$params['SignerCommonName'] : null;
×
NEW
196
                $email = !empty($params['SignerEmail']) ? (string)$params['SignerEmail'] : null;
×
197

NEW
198
                return new SignatureMetadataDto(
×
NEW
199
                        actor: ($name !== null || $email !== null)
×
NEW
200
                                ? new SignatureActorDto(name: $name, contactInfo: $email)
×
NEW
201
                                : null,
×
NEW
202
                );
×
203
        }
204

205
        private function resolvePageHeight(array $pageDimensions, int $pageIndex): float {
206
                $pageHeight = $pageDimensions[$pageIndex]['h'] ?? null;
1✔
207
                if (!is_numeric($pageHeight) || (float)$pageHeight <= 0.0) {
1✔
208
                        throw new \RuntimeException(sprintf('Missing or invalid PageDimensions for page index %d.', $pageIndex));
1✔
209
                }
NEW
210
                return (float)$pageHeight;
×
211
        }
212

213
        private function buildTimestampOptions(): ?TimestampOptionsDto {
214
                $tsaUrl = $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', '');
4✔
215
                if (empty($tsaUrl)) {
4✔
216
                        return null;
1✔
217
                }
218

219
                $username = null;
3✔
220
                $password = null;
3✔
221
                $authType = $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none');
3✔
222
                if ($authType === 'basic') {
3✔
223
                        $username = $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', '') ?: null;
2✔
224
                        $password = $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', '') ?: null;
2✔
225
                }
226

227
                return new TimestampOptionsDto(
3✔
228
                        tsaUrl: $tsaUrl,
3✔
229
                        username: $username,
3✔
230
                        password: $password,
3✔
231
                );
3✔
232
        }
233

234
        private function resolveCertificationLevel(bool $noVisibleElements): ?CertificationLevel {
235
                if (!$this->docMdpConfigService->isEnabled()) {
4✔
236
                        return null;
1✔
237
                }
238

239
                // DocMDP values mirror CertificationLevel: 1=NoChanges, 2=FormFilling, 3=FormFillAndAnnotations
240
                $level = $this->docMdpConfigService->getLevel()->value;
3✔
241
                // Only certify on invisible signatures or on the first visible element
242
                if ($noVisibleElements || !$this->hasExistingSignatures($this->getInputFile()->getContent())) {
3✔
243
                        return CertificationLevel::fromInt($level);
2✔
244
                }
245

246
                return null;
1✔
247
        }
248

249
        private function hasExistingSignatures(string $pdfContent): bool {
250
                return (bool)preg_match('/\/ByteRange\s*\[|\/Type\s*\/Sig\b|\/DocMDP\b|\/Perms\b/', $pdfContent);
7✔
251
        }
252

253
        /**
254
         * Builds the xObject (n2 layer) for all render modes using only PDF text operators.
255
         *
256
         * DESCRIPTION_ONLY      → description text, full width.
257
         * GRAPHIC_AND_DESCRIPTION → description text, right half only
258
         *                           (user image is in imagePath/n0, handled natively by signer-php).
259
         * SIGNAME_AND_DESCRIPTION → signer name as large text on the left half
260
         *                           + description text on the right half.
261
         *                           No image generation: pure PDF text operators.
262
         */
263
        private function buildXObject(int $width, int $height, string $renderMode): SignatureAppearanceXObjectDto {
264
                // GRAPHIC_ONLY: only the background/signature image is shown; no text in n2.
265
                if ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY) {
11✔
266
                        return new SignatureAppearanceXObjectDto(stream: '', resources: []);
2✔
267
                }
268

269
                $params = $this->getSignatureParams();
9✔
270
                $serverTimezone = new \DateTimeZone(date_default_timezone_get());
9✔
271
                $now = new \DateTime('now', $serverTimezone);
9✔
272
                $params['ServerSignatureDate'] = $now->format('Y.m.d H:i:s \U\T\C');
9✔
273

274
                $textData = $this->signatureTextService->parse(context: $params);
9✔
275
                $parsed = trim((string)($textData['parsed'] ?? ''));
9✔
276

277
                $descFontSize = (float)($textData['templateFontSize'] ?? $this->signatureTextService->getTemplateFontSize());
9✔
278
                $descLineHeight = $descFontSize * 1.0;
9✔
279
                $leftPadding = max(2.0, $descFontSize * 0.15);
9✔
280

281
                $isDescriptionOnly = $renderMode === SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY;
9✔
282
                $textStartX = $isDescriptionOnly ? $leftPadding : ((float)$width / 2.0) + $leftPadding;
9✔
283
                $availableWidth = $isDescriptionOnly ? (float)$width : (float)$width / 2.0;
9✔
284

285
                $stream = '';
9✔
286

287
                // Left half: signer name as large text operators (SIGNAME_AND_DESCRIPTION only).
288
                // No image generation — the name is drawn directly with PDF text commands.
289
                if ($renderMode === SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION) {
9✔
290
                        $commonName = !empty($params['SignerCommonName'])
3✔
291
                                ? (string)$params['SignerCommonName']
2✔
292
                                : ($this->readCertificate()['subject']['CN'] ?? '');
1✔
293
                        if ($commonName !== '') {
3✔
294
                                $nameFontSize = $this->signatureTextService->getSignatureFontSize();
2✔
295
                                $leftHalfW = (float)$width / 2.0;
2✔
296
                                $nameLines = $this->wrapTextForPdf($commonName, $leftHalfW - $leftPadding * 2, $nameFontSize);
2✔
297
                                $nameLineCount = count($nameLines);
2✔
298
                                $totalNameHeight = $nameLineCount * $nameFontSize * 1.0;
2✔
299
                                $nameStartY = ((float)$height + $totalNameHeight) / 2.0 - $nameFontSize;
2✔
300
                                $nameStartY = max(0.0, $nameStartY);
2✔
301
                                $nameY = $nameStartY;
2✔
302
                                $estimatedCharWidth = $nameFontSize * 0.52;
2✔
303
                                foreach ($nameLines as $nameLine) {
2✔
304
                                        $lineWidth = strlen($nameLine) * $estimatedCharWidth;
2✔
305
                                        $nameX = max($leftPadding, ($leftHalfW - $lineWidth) / 2.0);
2✔
306
                                        $escaped = $this->escapePdfText($nameLine);
2✔
307
                                        $stream .= "BT\n";
2✔
308
                                        $stream .= sprintf("/F1 %.2F Tf\n", $nameFontSize);
2✔
309
                                        $stream .= "0 0 0 rg\n";
2✔
310
                                        $stream .= sprintf("%.2F %.2F Td\n", $nameX, $nameY);
2✔
311
                                        $stream .= sprintf("(%s) Tj\n", $escaped);
2✔
312
                                        $stream .= "ET\n";
2✔
313
                                        $nameY -= $nameFontSize * 1.0;
2✔
314
                                }
315
                        }
316
                }
317

318
                // Right half (or full width): description text.
319
                $currentY = (float)$height - $descFontSize - 2.0;
9✔
320
                foreach (explode(PHP_EOL, $parsed) as $line) {
9✔
321
                        $wrappedLines = $this->wrapTextForPdf($line, $availableWidth, $descFontSize);
9✔
322
                        foreach ($wrappedLines as $wrappedLine) {
9✔
323
                                if ($currentY < 0) {
9✔
NEW
324
                                        break 2;
×
325
                                }
326
                                $escaped = $this->escapePdfText($wrappedLine);
9✔
327
                                $stream .= "BT\n";
9✔
328
                                $stream .= sprintf("/F1 %.2F Tf\n", $descFontSize);
9✔
329
                                $stream .= "0 0 0 rg\n";
9✔
330
                                $stream .= sprintf("%.2F %.2F Td\n", $textStartX, $currentY);
9✔
331
                                $stream .= sprintf("(%s) Tj\n", $escaped);
9✔
332
                                $stream .= "ET\n";
9✔
333
                                $currentY -= $descLineHeight;
9✔
334
                        }
335
                }
336

337
                return new SignatureAppearanceXObjectDto(
9✔
338
                        stream: $stream,
9✔
339
                        resources: [
9✔
340
                                'Font' => [
9✔
341
                                        'F1' => [
9✔
342
                                                'Type' => '/Font',
9✔
343
                                                'Subtype' => '/Type1',
9✔
344
                                                'BaseFont' => '/Helvetica',
9✔
345
                                        ],
9✔
346
                                ],
9✔
347
                        ],
9✔
348
                );
9✔
349
        }
350

351
        /**
352
         * @return string[]
353
         */
354
        private function wrapTextForPdf(string $line, float $availableWidth, float $fontSize): array {
355
                $trimmed = trim($line);
13✔
356
                if ($trimmed === '') {
13✔
357
                        return [''];
1✔
358
                }
359

360
                $estimatedCharWidth = max(1.0, $fontSize * 0.52);
12✔
361
                $maxChars = max(1, (int)floor($availableWidth / $estimatedCharWidth));
12✔
362
                if (strlen($trimmed) <= $maxChars) {
12✔
363
                        return [$trimmed];
10✔
364
                }
365

366
                $result = [];
2✔
367
                $current = '';
2✔
368
                foreach (preg_split('/\s+/', $trimmed) ?: [] as $word) {
2✔
369
                        if ($word === '') {
2✔
NEW
370
                                continue;
×
371
                        }
372

373
                        $candidate = $current === '' ? $word : $current . ' ' . $word;
2✔
374
                        if (strlen($candidate) <= $maxChars) {
2✔
375
                                $current = $candidate;
1✔
376
                                continue;
1✔
377
                        }
378

379
                        if ($current !== '') {
2✔
380
                                $result[] = $current;
1✔
381
                                $current = '';
1✔
382
                        }
383

384
                        while (strlen($word) > $maxChars) {
2✔
385
                                $result[] = substr($word, 0, $maxChars);
1✔
386
                                $word = substr($word, $maxChars);
1✔
387
                        }
388

389
                        $current = $word;
2✔
390
                }
391

392
                if ($current !== '') {
2✔
393
                        $result[] = $current;
2✔
394
                }
395

396
                return $result;
2✔
397
        }
398

399
        private function escapePdfText(string $value): string {
400
                $value = str_replace('\\', '\\\\', $value);
14✔
401
                $value = str_replace('(', '\\(', $value);
14✔
402
                $value = str_replace(')', '\\)', $value);
14✔
403

404
                return $value;
14✔
405
        }
406
}
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