Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
71.14% |
106 / 149 |
|
57.14% |
8 / 14 |
CRAP | |
0.00% |
0 / 1 |
| PhpNativeHandler | |
71.14% |
106 / 149 |
|
57.14% |
8 / 14 |
72.71 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| sign | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
| getSignedContent | |
40.68% |
24 / 59 |
|
0.00% |
0 / 1 |
4.88 | |||
| buildAppearanceForElement | |
96.30% |
26 / 27 |
|
0.00% |
0 / 1 |
8 | |||
| readCertificate | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
2.01 | |||
| buildMetadata | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
5.05 | |||
| resolvePageHeight | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| buildTimestampOptions | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
5 | |||
| getTsaSettings | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| resolveCertificationLevel | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| hasExistingSignatures | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| buildXObject | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| wrapTextForPdf | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| escapePdfText | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 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\Policy\PolicyService; |
| 15 | use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicy; |
| 16 | use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue; |
| 17 | use OCA\Libresign\Service\SignatureBackgroundService; |
| 18 | use OCA\Libresign\Service\SignatureStampPreview\SignatureStampAppearanceBuilder; |
| 19 | use OCA\Libresign\Service\SignatureTextService; |
| 20 | use OCA\Libresign\Service\SignerElementsService; |
| 21 | use OCP\Files\File; |
| 22 | use OCP\IAppConfig; |
| 23 | use SignerPHP\PdfSigner\Application\DTO\CertificateCredentialsDto; |
| 24 | use SignerPHP\PdfSigner\Application\DTO\CertificationLevel; |
| 25 | use SignerPHP\PdfSigner\Application\DTO\PdfContentDto; |
| 26 | use SignerPHP\PdfSigner\Application\DTO\SignatureActorDto; |
| 27 | use SignerPHP\PdfSigner\Application\DTO\SignatureAppearanceDto; |
| 28 | use SignerPHP\PdfSigner\Application\DTO\SignatureAppearanceXObjectDto; |
| 29 | use SignerPHP\PdfSigner\Application\DTO\SignatureMetadataDto; |
| 30 | use SignerPHP\PdfSigner\Application\DTO\SigningOptionsDto; |
| 31 | use SignerPHP\PdfSigner\Application\DTO\SignPdfRequestDto; |
| 32 | use SignerPHP\PdfSigner\Application\DTO\TimestampOptionsDto; |
| 33 | use SignerPHP\PdfSigner\Application\Service\PdfSigningService; |
| 34 | use SignerPHP\PdfSigner\Infrastructure\Legacy\OpenSslCertificateValidator; |
| 35 | use SignerPHP\PdfSigner\Infrastructure\Native\NativePdfSigningEngine; |
| 36 | |
| 37 | class 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 | } |