Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.14% covered (warning)
71.14%
106 / 149
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
PhpNativeHandler
71.14% covered (warning)
71.14%
106 / 149
57.14% covered (warning)
57.14%
8 / 14
72.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sign
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getSignedContent
40.68% covered (danger)
40.68%
24 / 59
0.00% covered (danger)
0.00%
0 / 1
4.88
 buildAppearanceForElement
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
8
 readCertificate
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 buildMetadata
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 resolvePageHeight
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 buildTimestampOptions
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 getTsaSettings
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 resolveCertificationLevel
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 hasExistingSignatures
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildXObject
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 wrapTextForPdf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 escapePdfText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Handler\SignEngine;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
13use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService;
14use OCA\Libresign\Service\Policy\PolicyService;
15use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicy;
16use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue;
17use OCA\Libresign\Service\SignatureBackgroundService;
18use OCA\Libresign\Service\SignatureStampPreview\SignatureStampAppearanceBuilder;
19use OCA\Libresign\Service\SignatureTextService;
20use OCA\Libresign\Service\SignerElementsService;
21use OCP\Files\File;
22use OCP\IAppConfig;
23use SignerPHP\PdfSigner\Application\DTO\CertificateCredentialsDto;
24use SignerPHP\PdfSigner\Application\DTO\CertificationLevel;
25use SignerPHP\PdfSigner\Application\DTO\PdfContentDto;
26use SignerPHP\PdfSigner\Application\DTO\SignatureActorDto;
27use SignerPHP\PdfSigner\Application\DTO\SignatureAppearanceDto;
28use SignerPHP\PdfSigner\Application\DTO\SignatureAppearanceXObjectDto;
29use SignerPHP\PdfSigner\Application\DTO\SignatureMetadataDto;
30use SignerPHP\PdfSigner\Application\DTO\SigningOptionsDto;
31use SignerPHP\PdfSigner\Application\DTO\SignPdfRequestDto;
32use SignerPHP\PdfSigner\Application\DTO\TimestampOptionsDto;
33use SignerPHP\PdfSigner\Application\Service\PdfSigningService;
34use SignerPHP\PdfSigner\Infrastructure\Legacy\OpenSslCertificateValidator;
35use SignerPHP\PdfSigner\Infrastructure\Native\NativePdfSigningEngine;
36
37class PhpNativeHandler extends Pkcs12Handler {
38    public function __construct(
39        private IAppConfig $appConfig,
40        private DocMdpConfigService $docMdpConfigService,
41        private SignatureTextService $signatureTextService,
42        private SignatureStampAppearanceBuilder $signatureStampAppearanceBuilder,
43        private SignatureBackgroundService $signatureBackgroundService,
44        private PolicyService $policyService,
45        protected CertificateEngineFactory $certificateEngineFactory,
46    ) {
47    }
48
49    #[\Override]
50    public function sign(): File {
51        $this->beforeSign();
52        $signedContent = $this->getSignedContent();
53        $this->getInputFile()->putContent($signedContent);
54        return $this->getInputFile();
55    }
56
57    #[\Override]
58    public function getSignedContent(): string {
59        $pdfContent = $this->getInputFile()->getContent();
60        $certificate = CertificateCredentialsDto::fromContent(
61            $this->getCertificate(),
62            $this->getPassword(),
63        );
64        $service = new PdfSigningService(
65            new OpenSslCertificateValidator(),
66            new NativePdfSigningEngine(),
67        );
68
69        $visibleElements = $this->getVisibleElements();
70        $metadata = $this->buildMetadata();
71        $timestamp = $this->buildTimestampOptions();
72        $certificationLevel = $this->resolveCertificationLevel(empty($visibleElements));
73
74        if (empty($visibleElements)) {
75            return $service->sign(SignPdfRequestDto::fromRequired(
76                new PdfContentDto($pdfContent),
77                $certificate,
78                new SigningOptionsDto(
79                    metadata: $metadata,
80                    timestamp: $timestamp,
81                    certificationLevel: $certificationLevel,
82                    useDefaultAppearance: false,
83                ),
84            ));
85        }
86
87        $applyOnce = $certificationLevel;
88        // signer-php expects screen/top-left coords (Y=0 at top, grows downward).
89        // LibreSign stores PDF bottom-left coords (Y=0 at bottom, lly < ury).
90        // Conversion: screen_y = pageHeight - pdf_y
91        // Page dimensions come from FileEntity::getMetadata()['d'] (0-based array of ['w','h']).
92        $pageDimensions = $this->getSignatureParams()['PageDimensions'] ?? [];
93        foreach ($visibleElements as $element) {
94            $fileElement = $element->getFileElement();
95            $llx = (float)($fileElement->getLlx() ?? 0);
96            $lly = (float)($fileElement->getLly() ?? 0);
97            $urx = (float)($fileElement->getUrx() ?? 0);
98            $ury = (float)($fileElement->getUry() ?? 0);
99            $width = (int)($urx - $llx);
100            $height = (int)($ury - $lly);
101            // signer-php uses 0-based page index; LibreSign stores 1-based
102            $pageIndex = max(0, $fileElement->getPage() - 1);
103            $pageHeight = $this->resolvePageHeight($pageDimensions, $pageIndex);
104            $appearance = $this->buildAppearanceForElement(
105                llx: $llx,
106                lly: $lly,
107                urx: $urx,
108                ury: $ury,
109                pageHeight: $pageHeight,
110                pageIndex: $pageIndex,
111                width: $width,
112                height: $height,
113                signatureImagePath: $element->getTempFile(),
114            );
115            $pdfContent = $service->sign(SignPdfRequestDto::fromRequired(
116                new PdfContentDto($pdfContent),
117                $certificate,
118                new SigningOptionsDto(
119                    metadata: $metadata,
120                    appearance: $appearance,
121                    timestamp: $timestamp,
122                    // DocMDP only applies once (the first signature certifies)
123                    certificationLevel: $applyOnce,
124                ),
125            ));
126            $applyOnce = null;
127        }
128
129        return $pdfContent;
130    }
131
132    private function buildAppearanceForElement(
133        float $llx,
134        float $lly,
135        float $urx,
136        float $ury,
137        float $pageHeight,
138        int $pageIndex,
139        int $width,
140        int $height,
141        string $signatureImagePath = '',
142    ): SignatureAppearanceDto {
143        $renderMode = $this->signatureTextService->getRenderMode();
144
145        // n0 layer: background stamp is always placed full-bbox when enabled.
146        $imagePath = $this->signatureBackgroundService->isEnabled()
147            ? $this->signatureBackgroundService->getImagePath()
148            : null;
149
150        // GRAPHIC_AND_DESCRIPTION: user's drawn image goes into the n2 xObject layer
151        // on the left half of the bbox so it does not distort or cover the description text.
152        // Background (if enabled) still occupies the full n0 layer behind everything.
153        // GRAPHIC_ONLY: user's drawn image occupies the full bbox in n2; no description text.
154        $userImgPath = null;
155        $userImgRect = null;
156        if ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION) {
157            if ($signatureImagePath !== '' && is_file($signatureImagePath)) {
158                $userImgPath = $signatureImagePath;
159                $userImgRect = [0.0, 0.0, (float)$width / 2.0, (float)$height];
160            }
161        } elseif ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY) {
162            if ($signatureImagePath !== '' && is_file($signatureImagePath)) {
163                $userImgPath = $signatureImagePath;
164                $userImgRect = null; // full bbox
165            }
166        }
167
168        return new SignatureAppearanceDto(
169            backgroundImagePath: $imagePath,
170            rect: [
171                $llx,
172                $pageHeight - $ury,  // screen top = pageH - PDF ury
173                $urx,
174                $pageHeight - $lly,  // screen bottom = pageH - PDF lly
175            ],
176            page: $pageIndex,
177            xObject: $this->buildXObject($width, $height, $renderMode),
178            signatureImagePath: $userImgPath,
179            signatureImageFrame: $userImgRect,
180        );
181    }
182
183    #[\Override]
184    public function readCertificate(): array {
185        $result = $this->certificateEngineFactory
186            ->getEngine()
187            ->readCertificate(
188                $this->getCertificate(),
189                $this->getPassword()
190            );
191
192        if (!is_array($result)) {
193            throw new \RuntimeException('Failed to read certificate data');
194        }
195
196        return $result;
197    }
198
199    private function buildMetadata(): SignatureMetadataDto {
200        $params = $this->getSignatureParams();
201        $name = !empty($params['SignerCommonName']) ? (string)$params['SignerCommonName'] : null;
202        $email = !empty($params['SignerEmail']) ? (string)$params['SignerEmail'] : null;
203
204        return new SignatureMetadataDto(
205            actor: ($name !== null || $email !== null)
206                ? new SignatureActorDto(name: $name, contactInfo: $email)
207                : null,
208        );
209    }
210
211    private function resolvePageHeight(array $pageDimensions, int $pageIndex): float {
212        $pageHeight = $pageDimensions[$pageIndex]['h'] ?? null;
213        if (!is_numeric($pageHeight) || (float)$pageHeight <= 0.0) {
214            throw new \RuntimeException(sprintf('Missing or invalid PageDimensions for page index %d.', $pageIndex));
215        }
216        return (float)$pageHeight;
217    }
218
219    private function buildTimestampOptions(): ?TimestampOptionsDto {
220        $tsaSettings = $this->getTsaSettings();
221        $tsaUrl = $tsaSettings['url'];
222        if (empty($tsaUrl)) {
223            return null;
224        }
225
226        $username = null;
227        $password = null;
228        $authType = $tsaSettings['auth_type'];
229        if ($authType === 'basic') {
230            $username = $tsaSettings['username'] ?: null;
231            $password = $this->appConfig->getValueString(Application::APP_ID, TsaPolicy::PASSWORD_APP_CONFIG_KEY, '') ?: null;
232        }
233
234        return new TimestampOptionsDto(
235            tsaUrl: $tsaUrl,
236            hashAlgorithm: $tsaSettings['hash_algorithm'],
237            username: $username,
238            password: $password,
239        );
240    }
241
242    /**
243     * @return array{url: string, policy_oid: string, auth_type: string, username: string, hash_algorithm: string}
244     */
245    private function getTsaSettings(): array {
246        $resolved = $this->policyService->resolve(TsaPolicy::KEY)->getEffectiveValue();
247        $settings = TsaPolicyValue::decode($resolved);
248        return $settings;
249    }
250
251    private function resolveCertificationLevel(bool $noVisibleElements): ?CertificationLevel {
252        if (!$this->docMdpConfigService->isEnabled()) {
253            return null;
254        }
255
256        // DocMDP values mirror CertificationLevel: 1=NoChanges, 2=FormFilling, 3=FormFillAndAnnotations
257        $level = $this->docMdpConfigService->getLevel()->value;
258        // Only certify on invisible signatures or on the first visible element
259        if ($noVisibleElements || !$this->hasExistingSignatures($this->getInputFile()->getContent())) {
260            return CertificationLevel::fromInt($level);
261        }
262
263        return null;
264    }
265
266    private function hasExistingSignatures(string $pdfContent): bool {
267        return (bool)preg_match('/\/ByteRange\s*\[|\/Type\s*\/Sig\b|\/DocMDP\b|\/Perms\b/', $pdfContent);
268    }
269
270    /**
271     * Builds the xObject (n2 layer) for all render modes using only PDF text operators.
272     *
273     * DESCRIPTION_ONLY      → description text, full width.
274     * GRAPHIC_AND_DESCRIPTION → description text, right half only
275     *                           (user image is in imagePath/n0, handled natively by signer-php).
276     * SIGNAME_AND_DESCRIPTION → signer name as large text on the left half
277     *                           + description text on the right half.
278     *                           No image generation: pure PDF text operators.
279     */
280    private function buildXObject(int $width, int $height, string $renderMode): SignatureAppearanceXObjectDto {
281        $params = $this->getSignatureParams();
282        $fallbackCommonName = $this->readCertificate()['subject']['CN'] ?? null;
283
284        return $this->signatureStampAppearanceBuilder->buildXObject(
285            width: $width,
286            height: $height,
287            renderMode: $renderMode,
288            context: $params,
289            fallbackCommonName: is_string($fallbackCommonName) ? $fallbackCommonName : null,
290        );
291    }
292
293    /**
294     * @return string[]
295     */
296    private function wrapTextForPdf(string $line, float $availableWidth, float $fontSize): array {
297        return $this->signatureStampAppearanceBuilder->wrapTextForPdf($line, $availableWidth, $fontSize);
298    }
299
300    private function escapePdfText(string $value): string {
301        return $this->signatureStampAppearanceBuilder->escapePdfText($value);
302    }
303}