diff --git a/composer.json b/composer.json index 4f92b207b3..a1a86f4c4c 100644 --- a/composer.json +++ b/composer.json @@ -58,8 +58,15 @@ "OCA\\Libresign\\Tests\\Fixtures\\": "tests/php/fixtures/" } }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/vitormattos/pdf-signer-php" + } + ], "require": { "cweagans/composer-patches": "^2.0", + "jeidison/signer-php": "dev-feat/visible-signature-appearance", "phpseclib/phpseclib": "^3.0" } } diff --git a/composer.lock b/composer.lock index c2a1b5f148..7ddebdd54f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5391f2086daf61ac7f2b327593f3aba1", + "content-hash": "b2fc683ae5213b2a8c596bba51874dd2", "packages": [ { "name": "cweagans/composer-configurable-plugin", @@ -129,6 +129,112 @@ ], "time": "2025-10-30T23:44:22+00:00" }, + { + "name": "jeidison/signer-php", + "version": "dev-feat/visible-signature-appearance", + "source": { + "type": "git", + "url": "https://github.com/vitormattos/pdf-signer-php.git", + "reference": "3564b19a13178fdda8b628733f71a496d2be15ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vitormattos/pdf-signer-php/zipball/3564b19a13178fdda8b628733f71a496d2be15ba", + "reference": "3564b19a13178fdda8b628733f71a496d2be15ba", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-fileinfo": "*", + "ext-openssl": "*", + "ext-zlib": "*", + "php": "^8.2" + }, + "require-dev": { + "deptrac/deptrac": "^4.6", + "infection/infection": "^0.29", + "laravel/pint": "^1.20", + "pestphp/pest": "^3.0", + "roave/security-advisories": "dev-latest", + "symfony/var-dumper": "^6.4.2", + "vimeo/psalm": "^6.15" + }, + "bin": [ + "bin/signer-sign", + "bin/signer-inspect", + "bin/signer-doctor" + ], + "type": "library", + "autoload": { + "psr-4": { + "SignerPHP\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "SignerPHP\\Tests\\": "tests/" + } + }, + "scripts": { + "tests": [ + "vendor/bin/pest --configuration phpunit.xml tests" + ], + "tests:coverage": [ + "vendor/bin/pest --configuration phpunit.xml tests --coverage --min=98.1" + ], + "pint:fix": [ + "vendor/bin/pint" + ], + "pint:check": [ + "vendor/bin/pint --test" + ], + "security:audit": [ + "composer audit --no-interaction" + ], + "psalm": [ + "vendor/bin/psalm --no-progress" + ], + "deptrac": [ + "vendor/bin/deptrac analyse --no-progress" + ], + "infection": [ + "vendor/bin/infection --threads=max --min-msi=65 --min-covered-msi=70" + ], + "quality:all": [ + "@pint:check", + "@security:audit", + "@tests:coverage", + "@psalm", + "@deptrac", + "@infection" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeidison Farias" + } + ], + "description": "Open-source PHP library for PDF digital signing with multiple signatures, RFC3161 timestamping, PAdES profiles, and PDF protection", + "keywords": [ + "digital-signature", + "docmdp", + "icp-brasil", + "multiple-signatures", + "pades", + "pdf", + "php", + "rfc3161", + "signer", + "timestamp" + ], + "support": { + "source": "https://github.com/vitormattos/pdf-signer-php/tree/feat/visible-signature-appearance" + }, + "time": "2026-03-04T16:10:38+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -1695,6 +1801,7 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { + "jeidison/signer-php": 20, "nextcloud/ocp": 20, "roave/security-advisories": 20 }, diff --git a/lib/Handler/SignEngine/JSignPdfHandler.php b/lib/Handler/SignEngine/JSignPdfHandler.php index dd63e2eeb9..09c222d312 100644 --- a/lib/Handler/SignEngine/JSignPdfHandler.php +++ b/lib/Handler/SignEngine/JSignPdfHandler.php @@ -366,20 +366,24 @@ private function signUsingVisibleElements(string $normalizedPdf, string $hashAlg $params['--bg-path'] = $signatureImagePath; } } elseif ($params['--l2-text'] === '""') { - if ($backgroundPathForElement) { + if ($backgroundPathForElement && $signatureImagePath) { $params['--bg-path'] = $this->mergeBackgroundWithSignature( $backgroundPathForElement, $signatureImagePath, $this->normalizeScaleFactor($scaleFactor), ); - } else { + } elseif ($backgroundPathForElement) { + $params['--bg-path'] = $backgroundPathForElement; + } elseif ($signatureImagePath) { $params['--bg-path'] = $signatureImagePath; } } else { if ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION) { $params['--render-mode'] = SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION; $params['--bg-path'] = $backgroundPathForElement; - $params['--img-path'] = $signatureImagePath; + if ($signatureImagePath) { + $params['--img-path'] = $signatureImagePath; + } } elseif ($renderMode === SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION) { $params['--render-mode'] = SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION; $params['--bg-path'] = $backgroundPathForElement; @@ -586,7 +590,7 @@ private function parseSignatureText(): array { public function getSignatureText(): string { $renderMode = $this->signatureTextService->getRenderMode(); - if ($renderMode !== 'GRAPHIC_ONLY') { + if ($renderMode !== SignerElementsService::RENDER_MODE_GRAPHIC_ONLY) { $data = $this->parseSignatureText(); $signatureText = '"' . str_replace( ['"', '$'], diff --git a/lib/Handler/SignEngine/PhpNativeHandler.php b/lib/Handler/SignEngine/PhpNativeHandler.php new file mode 100644 index 0000000000..aee4ad8cde --- /dev/null +++ b/lib/Handler/SignEngine/PhpNativeHandler.php @@ -0,0 +1,406 @@ +beforeSign(); + $signedContent = $this->getSignedContent(); + $this->getInputFile()->putContent($signedContent); + return $this->getInputFile(); + } + + #[\Override] + public function getSignedContent(): string { + $pdfContent = $this->getInputFile()->getContent(); + $certificate = CertificateCredentialsDto::fromContent( + $this->getCertificate(), + $this->getPassword(), + ); + $service = new PdfSigningService( + new OpenSslCertificateValidator(), + new NativePdfSigningEngine(), + ); + + $visibleElements = $this->getVisibleElements(); + $metadata = $this->buildMetadata(); + $timestamp = $this->buildTimestampOptions(); + $certificationLevel = $this->resolveCertificationLevel(empty($visibleElements)); + + if (empty($visibleElements)) { + return $service->sign(SignPdfRequestDto::fromRequired( + new PdfContentDto($pdfContent), + $certificate, + new SigningOptionsDto( + metadata: $metadata, + timestamp: $timestamp, + certificationLevel: $certificationLevel, + useDefaultAppearance: false, + ), + )); + } + + $applyOnce = $certificationLevel; + // signer-php expects screen/top-left coords (Y=0 at top, grows downward). + // LibreSign stores PDF bottom-left coords (Y=0 at bottom, lly < ury). + // Conversion: screen_y = pageHeight - pdf_y + // Page dimensions come from FileEntity::getMetadata()['d'] (0-based array of ['w','h']). + $pageDimensions = $this->getSignatureParams()['PageDimensions'] ?? []; + foreach ($visibleElements as $element) { + $fileElement = $element->getFileElement(); + $llx = (float)($fileElement->getLlx() ?? 0); + $lly = (float)($fileElement->getLly() ?? 0); + $urx = (float)($fileElement->getUrx() ?? 0); + $ury = (float)($fileElement->getUry() ?? 0); + $width = (int)($urx - $llx); + $height = (int)($ury - $lly); + // signer-php uses 0-based page index; LibreSign stores 1-based + $pageIndex = max(0, $fileElement->getPage() - 1); + $pageHeight = $this->resolvePageHeight($pageDimensions, $pageIndex); + $appearance = $this->buildAppearanceForElement( + llx: $llx, + lly: $lly, + urx: $urx, + ury: $ury, + pageHeight: $pageHeight, + pageIndex: $pageIndex, + width: $width, + height: $height, + signatureImagePath: $element->getTempFile(), + ); + $pdfContent = $service->sign(SignPdfRequestDto::fromRequired( + new PdfContentDto($pdfContent), + $certificate, + new SigningOptionsDto( + metadata: $metadata, + appearance: $appearance, + timestamp: $timestamp, + // DocMDP only applies once (the first signature certifies) + certificationLevel: $applyOnce, + ), + )); + $applyOnce = null; + } + + return $pdfContent; + } + + private function buildAppearanceForElement( + float $llx, + float $lly, + float $urx, + float $ury, + float $pageHeight, + int $pageIndex, + int $width, + int $height, + string $signatureImagePath = '', + ): SignatureAppearanceDto { + $renderMode = $this->signatureTextService->getRenderMode(); + + // n0 layer: background stamp is always placed full-bbox when enabled. + $imagePath = $this->signatureBackgroundService->isEnabled() + ? $this->signatureBackgroundService->getImagePath() + : null; + + // GRAPHIC_AND_DESCRIPTION: user's drawn image goes into the n2 xObject layer + // on the left half of the bbox so it does not distort or cover the description text. + // Background (if enabled) still occupies the full n0 layer behind everything. + // GRAPHIC_ONLY: user's drawn image occupies the full bbox in n2; no description text. + $userImgPath = null; + $userImgRect = null; + if ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION) { + if ($signatureImagePath !== '' && is_file($signatureImagePath)) { + $userImgPath = $signatureImagePath; + $userImgRect = [0.0, 0.0, (float)$width / 2.0, (float)$height]; + } + } elseif ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY) { + if ($signatureImagePath !== '' && is_file($signatureImagePath)) { + $userImgPath = $signatureImagePath; + $userImgRect = null; // full bbox + } + } + + return new SignatureAppearanceDto( + backgroundImagePath: $imagePath, + rect: [ + $llx, + $pageHeight - $ury, // screen top = pageH - PDF ury + $urx, + $pageHeight - $lly, // screen bottom = pageH - PDF lly + ], + page: $pageIndex, + xObject: $this->buildXObject($width, $height, $renderMode), + signatureImagePath: $userImgPath, + signatureImageFrame: $userImgRect, + ); + } + + #[\Override] + public function readCertificate(): array { + $result = $this->certificateEngineFactory + ->getEngine() + ->readCertificate( + $this->getCertificate(), + $this->getPassword() + ); + + if (!is_array($result)) { + throw new \RuntimeException('Failed to read certificate data'); + } + + return $result; + } + + private function buildMetadata(): SignatureMetadataDto { + $params = $this->getSignatureParams(); + $name = !empty($params['SignerCommonName']) ? (string)$params['SignerCommonName'] : null; + $email = !empty($params['SignerEmail']) ? (string)$params['SignerEmail'] : null; + + return new SignatureMetadataDto( + actor: ($name !== null || $email !== null) + ? new SignatureActorDto(name: $name, contactInfo: $email) + : null, + ); + } + + private function resolvePageHeight(array $pageDimensions, int $pageIndex): float { + $pageHeight = $pageDimensions[$pageIndex]['h'] ?? null; + if (!is_numeric($pageHeight) || (float)$pageHeight <= 0.0) { + throw new \RuntimeException(sprintf('Missing or invalid PageDimensions for page index %d.', $pageIndex)); + } + return (float)$pageHeight; + } + + private function buildTimestampOptions(): ?TimestampOptionsDto { + $tsaUrl = $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', ''); + if (empty($tsaUrl)) { + return null; + } + + $username = null; + $password = null; + $authType = $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none'); + if ($authType === 'basic') { + $username = $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', '') ?: null; + $password = $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', '') ?: null; + } + + return new TimestampOptionsDto( + tsaUrl: $tsaUrl, + username: $username, + password: $password, + ); + } + + private function resolveCertificationLevel(bool $noVisibleElements): ?CertificationLevel { + if (!$this->docMdpConfigService->isEnabled()) { + return null; + } + + // DocMDP values mirror CertificationLevel: 1=NoChanges, 2=FormFilling, 3=FormFillAndAnnotations + $level = $this->docMdpConfigService->getLevel()->value; + // Only certify on invisible signatures or on the first visible element + if ($noVisibleElements || !$this->hasExistingSignatures($this->getInputFile()->getContent())) { + return CertificationLevel::fromInt($level); + } + + return null; + } + + private function hasExistingSignatures(string $pdfContent): bool { + return (bool)preg_match('/\/ByteRange\s*\[|\/Type\s*\/Sig\b|\/DocMDP\b|\/Perms\b/', $pdfContent); + } + + /** + * Builds the xObject (n2 layer) for all render modes using only PDF text operators. + * + * DESCRIPTION_ONLY → description text, full width. + * GRAPHIC_AND_DESCRIPTION → description text, right half only + * (user image is in imagePath/n0, handled natively by signer-php). + * SIGNAME_AND_DESCRIPTION → signer name as large text on the left half + * + description text on the right half. + * No image generation: pure PDF text operators. + */ + private function buildXObject(int $width, int $height, string $renderMode): SignatureAppearanceXObjectDto { + // GRAPHIC_ONLY: only the background/signature image is shown; no text in n2. + if ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY) { + return new SignatureAppearanceXObjectDto(stream: '', resources: []); + } + + $params = $this->getSignatureParams(); + $serverTimezone = new \DateTimeZone(date_default_timezone_get()); + $now = new \DateTime('now', $serverTimezone); + $params['ServerSignatureDate'] = $now->format('Y.m.d H:i:s \U\T\C'); + + $textData = $this->signatureTextService->parse(context: $params); + $parsed = trim((string)($textData['parsed'] ?? '')); + + $descFontSize = (float)($textData['templateFontSize'] ?? $this->signatureTextService->getTemplateFontSize()); + $descLineHeight = $descFontSize * 1.0; + $leftPadding = max(2.0, $descFontSize * 0.15); + + $isDescriptionOnly = $renderMode === SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY; + $textStartX = $isDescriptionOnly ? $leftPadding : ((float)$width / 2.0) + $leftPadding; + $availableWidth = $isDescriptionOnly ? (float)$width : (float)$width / 2.0; + + $stream = ''; + + // Left half: signer name as large text operators (SIGNAME_AND_DESCRIPTION only). + // No image generation — the name is drawn directly with PDF text commands. + if ($renderMode === SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION) { + $commonName = !empty($params['SignerCommonName']) + ? (string)$params['SignerCommonName'] + : ($this->readCertificate()['subject']['CN'] ?? ''); + if ($commonName !== '') { + $nameFontSize = $this->signatureTextService->getSignatureFontSize(); + $leftHalfW = (float)$width / 2.0; + $nameLines = $this->wrapTextForPdf($commonName, $leftHalfW - $leftPadding * 2, $nameFontSize); + $nameLineCount = count($nameLines); + $totalNameHeight = $nameLineCount * $nameFontSize * 1.0; + $nameStartY = ((float)$height + $totalNameHeight) / 2.0 - $nameFontSize; + $nameStartY = max(0.0, $nameStartY); + $nameY = $nameStartY; + $estimatedCharWidth = $nameFontSize * 0.52; + foreach ($nameLines as $nameLine) { + $lineWidth = strlen($nameLine) * $estimatedCharWidth; + $nameX = max($leftPadding, ($leftHalfW - $lineWidth) / 2.0); + $escaped = $this->escapePdfText($nameLine); + $stream .= "BT\n"; + $stream .= sprintf("/F1 %.2F Tf\n", $nameFontSize); + $stream .= "0 0 0 rg\n"; + $stream .= sprintf("%.2F %.2F Td\n", $nameX, $nameY); + $stream .= sprintf("(%s) Tj\n", $escaped); + $stream .= "ET\n"; + $nameY -= $nameFontSize * 1.0; + } + } + } + + // Right half (or full width): description text. + $currentY = (float)$height - $descFontSize - 2.0; + foreach (explode(PHP_EOL, $parsed) as $line) { + $wrappedLines = $this->wrapTextForPdf($line, $availableWidth, $descFontSize); + foreach ($wrappedLines as $wrappedLine) { + if ($currentY < 0) { + break 2; + } + $escaped = $this->escapePdfText($wrappedLine); + $stream .= "BT\n"; + $stream .= sprintf("/F1 %.2F Tf\n", $descFontSize); + $stream .= "0 0 0 rg\n"; + $stream .= sprintf("%.2F %.2F Td\n", $textStartX, $currentY); + $stream .= sprintf("(%s) Tj\n", $escaped); + $stream .= "ET\n"; + $currentY -= $descLineHeight; + } + } + + return new SignatureAppearanceXObjectDto( + stream: $stream, + resources: [ + 'Font' => [ + 'F1' => [ + 'Type' => '/Font', + 'Subtype' => '/Type1', + 'BaseFont' => '/Helvetica', + ], + ], + ], + ); + } + + /** + * @return string[] + */ + private function wrapTextForPdf(string $line, float $availableWidth, float $fontSize): array { + $trimmed = trim($line); + if ($trimmed === '') { + return ['']; + } + + $estimatedCharWidth = max(1.0, $fontSize * 0.52); + $maxChars = max(1, (int)floor($availableWidth / $estimatedCharWidth)); + if (strlen($trimmed) <= $maxChars) { + return [$trimmed]; + } + + $result = []; + $current = ''; + foreach (preg_split('/\s+/', $trimmed) ?: [] as $word) { + if ($word === '') { + continue; + } + + $candidate = $current === '' ? $word : $current . ' ' . $word; + if (strlen($candidate) <= $maxChars) { + $current = $candidate; + continue; + } + + if ($current !== '') { + $result[] = $current; + $current = ''; + } + + while (strlen($word) > $maxChars) { + $result[] = substr($word, 0, $maxChars); + $word = substr($word, $maxChars); + } + + $current = $word; + } + + if ($current !== '') { + $result[] = $current; + } + + return $result; + } + + private function escapePdfText(string $value): string { + $value = str_replace('\\', '\\\\', $value); + $value = str_replace('(', '\\(', $value); + $value = str_replace(')', '\\)', $value); + + return $value; + } +} diff --git a/lib/Handler/SignEngine/Pkcs12Handler.php b/lib/Handler/SignEngine/Pkcs12Handler.php index 6b5ead2c01..5a4cc74911 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -30,6 +30,7 @@ class Pkcs12Handler extends SignEngineHandler { protected string $certificate = ''; private array $signaturesFromPoppler = []; private ?JSignPdfHandler $jSignPdfHandler = null; + private ?PhpNativeHandler $phpNativeHandler = null; private string $rootCertificatePem = ''; private bool $isLibreSignFile = false; diff --git a/lib/Service/Install/ConfigureCheckService.php b/lib/Service/Install/ConfigureCheckService.php index 158af002b6..d4bf39a243 100644 --- a/lib/Service/Install/ConfigureCheckService.php +++ b/lib/Service/Install/ConfigureCheckService.php @@ -164,6 +164,12 @@ public function checkJSignPdf(): array { if (!empty($this->result['jsignpdf'])) { return $this->result['jsignpdf']; } + + $signatureEngine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf'); + if ($signatureEngine !== 'JSignPdf') { + return []; + } + $jsignpdJarPath = $this->appConfig->getValueString(Application::APP_ID, 'jsignpdf_jar_path'); if ($jsignpdJarPath) { $resultOfVerify = $this->verify('jsignpdf'); @@ -379,6 +385,7 @@ private function checkJava(): array { if (!empty($this->result['java'])) { return $this->result['java']; } + $javaPath = $this->javaHelper->getJavaPath(); if ($javaPath) { $resultOfVerify = $this->verify('java'); diff --git a/lib/Service/Install/InstallService.php b/lib/Service/Install/InstallService.php index c8889e6866..0f76941858 100644 --- a/lib/Service/Install/InstallService.php +++ b/lib/Service/Install/InstallService.php @@ -346,6 +346,10 @@ private function writeAppSignature(): void { } public function installJava(?bool $async = false): void { + $signatureEngine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf'); + if ($signatureEngine !== 'JSignPdf') { + return; + } $this->setResource('java'); if ($async) { $this->runAsync(); @@ -444,6 +448,11 @@ public function uninstallJava(): void { } public function installJSignPdf(?bool $async = false): void { + $signatureEngine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf'); + if ($signatureEngine !== 'JSignPdf') { + return; + } + if (!extension_loaded('zip')) { throw new RuntimeException('Zip extension is not available'); } diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 3d3f4e04fb..7a39e787a8 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -286,6 +286,10 @@ public function setVisibleElements(array $list): self { } $element = $this->array_find($list, fn (array $element): bool => ($element['documentElementId'] ?? '') === $fileElementId); if (!$element) { + // No user-submitted image for this element (e.g. clickToSign). + // Still include the file element so the admin background image (n0 layer) + // is rendered in the signature stamp on the document. + $newElements[$fileElementId] = new VisibleElementAssoc($fileElement); continue; } $nodeId = $this->getNodeId($element, $fileElement); @@ -960,6 +964,12 @@ private function addMetadataToSignatureParams(array $signatureParams): array { if (isset($signRequestMetadata['user-agent'])) { $signatureParams['SignerUserAgent'] = $signRequestMetadata['user-agent']; } + if ($this->libreSignFile?->getMetadata()) { + $metadata = $this->libreSignFile->getMetadata(); + if (isset($metadata['d']) && !empty($metadata['d'])) { + $signatureParams['PageDimensions'] = $metadata['d']; + } + } return $signatureParams; } diff --git a/lib/Service/SignatureTextService.php b/lib/Service/SignatureTextService.php index e847c6d5a3..696d885415 100644 --- a/lib/Service/SignatureTextService.php +++ b/lib/Service/SignatureTextService.php @@ -441,7 +441,7 @@ public function getFullSignatureHeight(): float { public function getSignatureWidth(): float { $current = $this->appConfig->getValueFloat(Application::APP_ID, 'signature_width', self::DEFAULT_SIGNATURE_WIDTH); - if ($this->getRenderMode() === 'GRAPHIC_ONLY' || !$this->getTemplate()) { + if ($this->getRenderMode() === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY || !$this->getTemplate()) { return $current; } return $current / 2; diff --git a/lib/Service/SignerElementsService.php b/lib/Service/SignerElementsService.php index 1076273a49..61d4a65dfb 100644 --- a/lib/Service/SignerElementsService.php +++ b/lib/Service/SignerElementsService.php @@ -22,6 +22,7 @@ class SignerElementsService { public const RENDER_MODE_DESCRIPTION_ONLY = 'DESCRIPTION_ONLY'; public const RENDER_MODE_SIGNAME_AND_DESCRIPTION = 'SIGNAME_AND_DESCRIPTION'; public const RENDER_MODE_GRAPHIC_AND_DESCRIPTION = 'GRAPHIC_AND_DESCRIPTION'; + public const RENDER_MODE_GRAPHIC_ONLY = 'GRAPHIC_ONLY'; public const RENDER_MODE_DEFAULT = 'GRAPHIC_AND_DESCRIPTION'; public function __construct( private FolderService $folderService, diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index e056322968..6e81475c68 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -71,6 +71,7 @@ public function getForm(): TemplateResponse { $this->initialState->provideInitialState('footer_template_variables', $this->footerService->getTemplateVariablesMetadata()); $this->initialState->provideInitialState('footer_template', $this->footerService->getTemplate()); $this->initialState->provideInitialState('footer_template_is_default', $this->footerService->isDefaultTemplate()); + $this->initialState->provideInitialState('signature_engine', $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf')); $this->initialState->provideInitialState('signature_render_mode', $this->signatureTextService->getRenderMode()); $this->initialState->provideInitialState('signature_text_template', $this->signatureTextService->getTemplate()); $this->initialState->provideInitialState('signature_width', $this->signatureTextService->getFullSignatureWidth()); diff --git a/src/services/SigningRequirementValidator.ts b/src/services/SigningRequirementValidator.ts index 7dd297e894..a8f9df46b1 100644 --- a/src/services/SigningRequirementValidator.ts +++ b/src/services/SigningRequirementValidator.ts @@ -110,14 +110,18 @@ export class SigningRequirementValidator { } needsCreateSignature(config: ValidatorConfig = {}): boolean { + if (!config.canCreateSignature || config.hasSignatures) { + return false + } + const signer = this.signStore.document?.signers?.find(row => row.me) || {} - const visibleElements = this.signStore.document?.visibleElements || [] + const signRequestId = (signer as { signRequestId?: string | number }).signRequestId - return !!( - (signer as { signRequestId?: string | number }).signRequestId && - visibleElements.some(row => row.signRequestId === (signer as { signRequestId?: string | number }).signRequestId) && - !config.hasSignatures && - config.canCreateSignature - ) + if (!signRequestId) { + return false + } + + const visibleElements = this.signStore.document?.visibleElements || [] + return visibleElements.some(row => String(row.signRequestId) === String(signRequestId)) } } diff --git a/src/store/configureCheck.js b/src/store/configureCheck.js index e37a827ce3..baa780e871 100644 --- a/src/store/configureCheck.js +++ b/src/store/configureCheck.js @@ -6,6 +6,7 @@ import { defineStore } from 'pinia' import axios from '@nextcloud/axios' +import { subscribe } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { generateOcsUrl } from '@nextcloud/router' @@ -94,6 +95,8 @@ export const useConfigureCheckStore = function(...args) { // Make sure we only register the initialize once if (!configureCheckStore._initialized) { configureCheckStore.checkSetup() + subscribe('libresign:certificate-engine:changed', () => configureCheckStore.checkSetup()) + subscribe('libresign:signature-engine:changed', () => configureCheckStore.checkSetup()) configureCheckStore._initialized = true } return configureCheckStore diff --git a/src/tests/services/SigningRequirementValidator.spec.ts b/src/tests/services/SigningRequirementValidator.spec.ts index f22bc534a5..2c06d1e12d 100644 --- a/src/tests/services/SigningRequirementValidator.spec.ts +++ b/src/tests/services/SigningRequirementValidator.spec.ts @@ -162,6 +162,29 @@ describe('SigningRequirementValidator', () => { expect(result).toBe(false) }) + it('does not require createSignature when signer has no placed visibleElements (clickToSign scenario)', () => { + // Regression: signerHasSignRequest shortcut was bypassing the visibleElements check, + // causing the draw modal to appear for clickToSign documents with no placed element boxes. + const stores = createStores({ + signStore: { + document: { + signers: [{ me: true, signRequestId: 10 }], + visibleElements: [], + }, + }, + }) + const validator = new SigningRequirementValidator( + stores.signStore, + stores.signMethodsStore, + stores.identificationDocumentStore, + ) + + // No placed element → should NOT require createSignature, so the sign button is reachable + const result = validator.needsCreateSignature({ hasSignatures: false, canCreateSignature: true }) + + expect(result).toBe(false) + }) + it('returns createSignature before clickToSign when no signature exists', () => { const stores = createStores({ signMethodsStore: { diff --git a/src/tests/views/SignPDF/Sign.spec.ts b/src/tests/views/SignPDF/Sign.spec.ts index cec48adc5a..fbfc05ee12 100644 --- a/src/tests/views/SignPDF/Sign.spec.ts +++ b/src/tests/views/SignPDF/Sign.spec.ts @@ -1023,6 +1023,57 @@ describe('Sign.vue - signWithTokenCode', () => { expect(wrapper.vm.hasSignatures).toBe(true) expect(wrapper.vm.needCreateSignature).toBe(false) }) + + it('returns false for needCreateSignature when signer has no placed visibleElements (clickToSign scenario)', async () => { + // Regression: commit e9ea79495 removed visibleElements.some() check, causing + // needCreateSignature to return true for clickToSign documents where no visual + // element box was placed — hiding the "Sign the document." button. + const { default: realSign } = await import('../../../views/SignPDF/_partials/Sign.vue') + const { useSignStore } = await import('../../../store/sign.js') + + const signStore = useSignStore() + + // Signer has signRequestId but NO placed visibleElements (typical clickToSign) + signStore.document = { + id: 1, + nodeType: 'file', + signers: [ + { signRequestId: 501, me: true }, + ], + visibleElements: [], + } + + const wrapper = mount(realSign, { + global: { + stubs: { + NcButton: true, + NcDialog: true, + NcLoadingIcon: true, + TokenManager: true, + EmailManager: true, + UploadCertificate: true, + Documents: true, + Signatures: true, + Draw: true, + ManagePassword: true, + CreatePassword: true, + NcNoteCard: true, + NcPasswordField: true, + NcRichText: true, + }, + mocks: { + $watch: vi.fn(), + }, + }, + }) + + // canCreateSignature is true (mocked globally), but no element was placed for + // this signer, so we must NOT show "Define your signature" — the "Sign the + // document." button should be reachable. + expect(wrapper.vm.canCreateSignature).toBe(true) + expect(wrapper.vm.hasSignatures).toBe(false) + expect(wrapper.vm.needCreateSignature).toBe(false) + }) }) describe('Sign.vue - create signature modal', () => { diff --git a/src/views/Settings/Settings.vue b/src/views/Settings/Settings.vue index 69c2108a98..1c16a3fc64 100644 --- a/src/views/Settings/Settings.vue +++ b/src/views/Settings/Settings.vue @@ -7,6 +7,7 @@
+ @@ -35,7 +36,7 @@ import { t } from '@nextcloud/l10n' import AllowedGroups from './AllowedGroups.vue' import CertificateEngine from './CertificateEngine.vue' -import CollectMetadata from './CollectMetadata.vue' +import SignatureEngine from './SignatureEngine.vue' import ConfigureCheck from './ConfigureCheck.vue' import DefaultUserFolder from './DefaultUserFolder.vue' import DocMDP from './DocMDP.vue' @@ -49,6 +50,7 @@ import RootCertificateCfssl from './RootCertificateCfssl.vue' import RootCertificateOpenSsl from './RootCertificateOpenSsl.vue' import SignatureFlow from './SignatureFlow.vue' import SignatureHashAlgorithm from './SignatureHashAlgorithm.vue' +import CollectMetadata from './CollectMetadata.vue' import SignatureStamp from './SignatureStamp.vue' import SigningMode from './SigningMode.vue' import Envelope from './Envelope.vue' @@ -61,7 +63,7 @@ export default { components: { AllowedGroups, CertificateEngine, - CollectMetadata, + SignatureEngine, ConfigureCheck, DefaultUserFolder, DocMDP, @@ -75,6 +77,7 @@ export default { RootCertificateOpenSsl, SignatureFlow, SignatureHashAlgorithm, + CollectMetadata, SignatureStamp, SigningMode, Envelope, diff --git a/src/views/Settings/SignatureEngine.vue b/src/views/Settings/SignatureEngine.vue new file mode 100644 index 0000000000..499f322419 --- /dev/null +++ b/src/views/Settings/SignatureEngine.vue @@ -0,0 +1,75 @@ + + + + diff --git a/src/views/SignPDF/_partials/Sign.vue b/src/views/SignPDF/_partials/Sign.vue index f00ed830e7..bd0b6eff46 100644 --- a/src/views/SignPDF/_partials/Sign.vue +++ b/src/views/SignPDF/_partials/Sign.vue @@ -268,25 +268,16 @@ export default { return this.elements.length > 0 }, needCreateSignature() { + if (!this.canCreateSignature || this.hasSignatures) { + return false + } const document = this.signStore.document || {} const signer = document?.signers?.find(row => row.me) || {} - - const signRequestIds = new Set() - if (signer.signRequestId) { - signRequestIds.add(String(signer.signRequestId)) + if (!signer.signRequestId) { + return false } - if (Array.isArray(document?.files)) { - document.files - .flatMap(file => getFileSigners(file)) - .filter(row => row.me && row.signRequestId) - .forEach(row => signRequestIds.add(String(row.signRequestId))) - } - - const visibleElements = getVisibleElementsFromDocument(document) - return signRequestIds.size > 0 - && visibleElements.some(row => signRequestIds.has(String(row.signRequestId))) - && !this.hasSignatures - && this.canCreateSignature + const visibleElements = document?.visibleElements || [] + return visibleElements.some(row => String(row.signRequestId) === String(signer.signRequestId)) }, needIdentificationDocuments() { return this.identificationDocumentStore.showDocumentsComponent() diff --git a/tests/php/Unit/Handler/SignEngine/JSignPdfHandlerTest.php b/tests/php/Unit/Handler/SignEngine/JSignPdfHandlerTest.php index e858621abb..224738311f 100644 --- a/tests/php/Unit/Handler/SignEngine/JSignPdfHandlerTest.php +++ b/tests/php/Unit/Handler/SignEngine/JSignPdfHandlerTest.php @@ -468,6 +468,27 @@ public static function providerSignAffectedParams(): array { 'hashAlgorithm' => '', 'params' => '-a -kst PKCS12 --l2-text "aaaaa" -V -pg 2 -llx 10 -lly 20 -urx 30 -ury 40 --render-mode GRAPHIC_AND_DESCRIPTION --img-path text_image.png --hash-algorithm SHA256' ], + // Regression: background with GRAPHIC_AND_DESCRIPTION but NO user signature image. + // Before the fix, mergeBackgroundWithSignature('...', '') crashed with new Imagick(''). + // Now the background is used directly and no --img-path is emitted. + 'GRAPHIC_AND_DESCRIPTION with background but no signature image: bg-path = background, no img-path' => [ + 'visibleElements' => [self::getElement([ + 'page' => 2, + 'llx' => 10, + 'lly' => 20, + 'urx' => 30, + 'ury' => 40, + ], '')], // empty imagePath — clickToSign scenario + 'signatureWidth' => 20, + 'signatureHeight' => 20, + 'template' => 'aaaaa', + 'signatureBackgroundType' => 'default', + 'renderMode' => SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION, + 'templateFontSize' => 10, + 'pdfContent' => '%PDF-1.6', + 'hashAlgorithm' => '', + 'params' => '-a -kst PKCS12 --l2-text "aaaaa" -V -pg 2 -llx 10 -lly 20 -urx 30 -ury 40 --render-mode GRAPHIC_AND_DESCRIPTION --bg-path background.png --hash-algorithm SHA256' + ], 'background without template: bg-path = merged with signature, without img-path' => [ 'visibleElements' => [self::getElement([ 'page' => 2, diff --git a/tests/php/Unit/Handler/SignEngine/PhpNativeHandlerTest.php b/tests/php/Unit/Handler/SignEngine/PhpNativeHandlerTest.php new file mode 100644 index 0000000000..8c82431770 --- /dev/null +++ b/tests/php/Unit/Handler/SignEngine/PhpNativeHandlerTest.php @@ -0,0 +1,454 @@ +appConfig = $this->getMockAppConfigWithReset(); + $this->docMdpConfigService = $this->createMock(DocMdpConfigService::class); + $this->signatureTextService = $this->createMock(SignatureTextService::class); + $this->signatureBackgroundService = $this->createMock(SignatureBackgroundService::class); + $this->certificateEngineFactory = $this->createMock(CertificateEngineFactory::class); + } + + public function testBuildAppearanceSkipsBackgroundWhenDisabled(): void { + $handler = $this->getHandler(); + + $this->signatureBackgroundService + ->expects($this->once()) + ->method('isEnabled') + ->willReturn(false); + $this->signatureBackgroundService + ->expects($this->never()) + ->method('getImagePath'); + + $appearance = $this->callPrivateMethod( + $handler, + 'buildAppearanceForElement', + 10.0, + 20.0, + 110.0, + 70.0, + 800.0, + 0, + 100, + 50, + ); + + $this->assertInstanceOf(SignatureAppearanceDto::class, $appearance); + $this->assertNull($appearance->backgroundImagePath); + } + + public function testBuildAppearanceConvertsPdfCoordinatesToScreenCoordinates(): void { + $handler = $this->getHandler(); + + $this->signatureBackgroundService->method('isEnabled')->willReturn(false); + + $appearance = $this->callPrivateMethod( + $handler, + 'buildAppearanceForElement', + 10.0, + 20.0, + 110.0, + 70.0, + 800.0, + 1, + 100, + 50, + ); + + $this->assertSame([10.0, 730.0, 110.0, 780.0], $appearance->rect); + $this->assertSame(1, $appearance->page); + $this->assertNotNull($appearance->xObject); + $this->assertStringContainsString('Signed by', $appearance->xObject->stream); + } + + public function testResolvePageHeightThrowsWhenDimensionsAreMissing(): void { + $handler = $this->getHandler(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing or invalid PageDimensions for page index 0.'); + + $this->callPrivateMethod($handler, 'resolvePageHeight', [], 0); + } + + #[DataProvider('providerWrapTextForPdf')] + public function testWrapTextForPdf(string $line, float $availableWidth, float $fontSize, array $expected): void { + $handler = $this->getHandler(); + $actual = $this->callPrivateMethod($handler, 'wrapTextForPdf', $line, $availableWidth, $fontSize); + $this->assertSame($expected, $actual); + } + + public static function providerWrapTextForPdf(): array { + return [ + 'empty string returns single empty element' => [ + '', 100.0, 10.0, [''], + ], + 'short text that fits in one line' => [ + 'hello', 100.0, 10.0, ['hello'], + ], + 'multiple words wrapped at word boundaries' => [ + // fontSize=10 → estimatedCharWidth=5.2; availableWidth=20 → maxChars=3 + // 'ab cd ef' → ['ab', 'cd', 'ef'] + 'ab cd ef', 20.0, 10.0, ['ab', 'cd', 'ef'], + ], + 'single long word is hard-split at maxChars' => [ + // maxChars=floor(15/5.2)=2: 'abcdefgh' → ['ab','cd','ef','gh'] + 'abcdefgh', 15.0, 10.0, ['ab', 'cd', 'ef', 'gh'], + ], + ]; + } + + #[DataProvider('providerEscapePdfText')] + public function testEscapePdfText(string $input, string $expected): void { + $handler = $this->getHandler(); + $actual = $this->callPrivateMethod($handler, 'escapePdfText', $input); + $this->assertSame($expected, $actual); + } + + public static function providerEscapePdfText(): array { + return [ + 'plain text is unchanged' => ['hello world', 'hello world'], + 'backslash is doubled' => ['back\\slash', 'back\\\\slash'], + 'opening parenthesis is escaped' => ['open(paren', 'open\\(paren'], + 'closing parenthesis is escaped' => ['close)paren', 'close\\)paren'], + 'multiple special chars in one string' => ['a\\b(c)d', 'a\\\\b\\(c\\)d'], + ]; + } + + #[DataProvider('providerHasExistingSignatures')] + public function testHasExistingSignatures(string $pdfContent, bool $expected): void { + $handler = $this->getHandler(); + $actual = $this->callPrivateMethod($handler, 'hasExistingSignatures', $pdfContent); + $this->assertSame($expected, $actual); + } + + public static function providerHasExistingSignatures(): array { + return [ + 'ByteRange marker signals existing signature' => ['/ByteRange [0 0 0 0]', true], + '/Type /Sig signals existing signature' => ['/Type /Sig ', true], + '/DocMDP signals existing signature' => ['/DocMDP ', true], + '/Perms signals existing signature' => ['/Perms ', true], + 'plain PDF content has no signature' => ['%PDF-1.4 startxref 0 %%EOF', false], + ]; + } + + #[DataProvider('providerBuildTimestampOptions')] + public function testBuildTimestampOptions( + string $tsaUrl, + string $authType, + string $username, + string $password, + bool $expectNull, + ?string $expectedUrl, + ?string $expectedUsername, + ?string $expectedPassword, + ): void { + $this->appConfig->setValueString('libresign', 'tsa_url', $tsaUrl); + $this->appConfig->setValueString('libresign', 'tsa_auth_type', $authType); + $this->appConfig->setValueString('libresign', 'tsa_username', $username); + $this->appConfig->setValueString('libresign', 'tsa_password', $password); + + $handler = $this->getHandler(); + $result = $this->callPrivateMethod($handler, 'buildTimestampOptions'); + + if ($expectNull) { + $this->assertNull($result); + return; + } + + $this->assertInstanceOf(TimestampOptionsDto::class, $result); + $this->assertSame($expectedUrl, $result->tsaUrl); + $this->assertSame($expectedUsername, $result->username); + $this->assertSame($expectedPassword, $result->password); + } + + public static function providerBuildTimestampOptions(): array { + return [ + 'no TSA URL returns null' => [ + '', 'none', '', '', true, null, null, null, + ], + 'TSA URL with no auth returns DTO without credentials' => [ + 'http://tsa.example.com', 'none', 'ignored', 'ignored', + false, 'http://tsa.example.com', null, null, + ], + 'TSA URL with basic auth returns DTO with credentials' => [ + 'http://tsa.example.com', 'basic', 'alice', 's3cr3t', + false, 'http://tsa.example.com', 'alice', 's3cr3t', + ], + 'basic auth with empty username and password returns null credentials in DTO' => [ + 'http://tsa.example.com', 'basic', '', '', + false, 'http://tsa.example.com', null, null, + ], + ]; + } + + #[DataProvider('providerResolveCertificationLevel')] + public function testResolveCertificationLevel( + bool $docMdpEnabled, + bool $noVisibleElements, + string $pdfContent, + bool $expectNull, + ): void { + $this->docMdpConfigService->method('isEnabled')->willReturn($docMdpEnabled); + if ($docMdpEnabled) { + $this->docMdpConfigService->method('getLevel') + ->willReturn(DocMdpLevel::CERTIFIED_FORM_FILLING); + } + + $handler = $this->getHandler(); + + if (!$noVisibleElements) { + $inputFile = $this->createMock(File::class); + $inputFile->method('getContent')->willReturn($pdfContent); + $handler->setInputFile($inputFile); + } + + $result = $this->callPrivateMethod($handler, 'resolveCertificationLevel', $noVisibleElements); + + if ($expectNull) { + $this->assertNull($result); + } else { + $this->assertInstanceOf(CertificationLevel::class, $result); + } + } + + public static function providerResolveCertificationLevel(): array { + return [ + 'DocMDP disabled always returns null' => [ + false, true, '', true, + ], + 'DocMDP enabled with no visible elements certifies' => [ + true, true, '', false, + ], + 'DocMDP enabled, visible elements, clean PDF certifies first signature' => [ + true, false, '%PDF-1.4 startxref 0 %%EOF', false, + ], + 'DocMDP enabled, visible elements, PDF already signed skips certification' => [ + true, false, '/ByteRange [0 0 0 0]', true, + ], + ]; + } + + public function testBuildAppearanceForElementSetsSignatureImageInGraphicAndDescriptionMode(): void { + $imagePath = realpath(__DIR__ . '/../../../../../img/app-dark.png'); + $this->assertNotFalse($imagePath, 'Test image must exist'); + + $handler = $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION); + $this->signatureBackgroundService->method('isEnabled')->willReturn(false); + + $appearance = $this->callPrivateMethod( + $handler, + 'buildAppearanceForElement', + 10.0, 20.0, 110.0, 70.0, 800.0, 0, 100, 50, + $imagePath, + ); + + $this->assertInstanceOf(SignatureAppearanceDto::class, $appearance); + $this->assertSame($imagePath, $appearance->signatureImagePath); + // Frame positions the image on the left half: [0, 0, width/2, height] + $this->assertSame([0.0, 0.0, 50.0, 50.0], $appearance->signatureImageFrame); + } + + public function testBuildAppearanceForElementDoesNotSetSignatureImageWhenNoFile(): void { + $handler = $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION); + $this->signatureBackgroundService->method('isEnabled')->willReturn(false); + + $appearance = $this->callPrivateMethod( + $handler, + 'buildAppearanceForElement', + 10.0, 20.0, 110.0, 70.0, 800.0, 0, 100, 50, + '', // empty path + ); + + $this->assertNull($appearance->signatureImagePath); + $this->assertNull($appearance->signatureImageFrame); + } + + public function testBuildXObjectDescriptionOnlyPositionsTextAtLeftPadding(): void { + // leftPadding = max(2.0, 10.0 * 0.15) = 2.0; currentY = 50 - 10 - 2 = 38.0 + $handler = $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY); + $xObject = $this->callPrivateMethod( + $handler, 'buildXObject', 100, 50, SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY, + ); + + // Description text must begin at X = leftPadding = 2.00 (full width, not offset to right half) + $this->assertStringContainsString('2.00 38.00 Td', $xObject->stream); + $this->assertStringNotContainsString('52.00 ', $xObject->stream); + } + + public function testBuildXObjectGraphicAndDescriptionPositionsTextAtRightHalf(): void { + // textStartX = width/2 + leftPadding = 50 + 2 = 52.0; currentY = 50 - 10 - 2 = 38.0 + $handler = $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION); + $xObject = $this->callPrivateMethod( + $handler, 'buildXObject', 100, 50, SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION, + ); + + // Text must start at the right half (X = 52.00), not at leftPadding alone + $this->assertStringContainsString('52.00 38.00 Td', $xObject->stream); + // Ensure text is NOT starting at leftPadding only (would be \n2.00 ... in DESCRIPTION_ONLY) + $this->assertStringNotContainsString("\n2.00 38.00 Td", $xObject->stream); + } + + public function testBuildXObjectSignameAndDescriptionIncludesNameAndDescriptionBlocks(): void { + $handler = $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION); + $handler->setSignatureParams(['SignerCommonName' => 'Test User']); + $xObject = $this->callPrivateMethod( + $handler, 'buildXObject', 200, 80, SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION, + ); + + // Name block uses the larger signature font (20.0) + $this->assertStringContainsString('/F1 20.00 Tf', $xObject->stream); + $this->assertStringContainsString('(Test User) Tj', $xObject->stream); + // Description block uses the description font (10.0) + $this->assertStringContainsString('/F1 10.00 Tf', $xObject->stream); + // Description text positioned on the right half (X = 200/2 + 2 = 102.0) + $this->assertStringContainsString('102.00 ', $xObject->stream); + } + + /** + * Regression: GRAPHIC_ONLY mode must not render any text in the n2 xObject layer. + * Before the fix, the method fell through to the description block and wrote text + * into the stamp. + */ + public function testBuildXObjectGraphicOnlyReturnsEmptyStream(): void { + $handler = $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_GRAPHIC_ONLY); + $xObject = $this->callPrivateMethod( + $handler, 'buildXObject', 100, 50, SignerElementsService::RENDER_MODE_GRAPHIC_ONLY, + ); + + $this->assertSame('', $xObject->stream); + $this->assertSame([], $xObject->resources); + } + + /** + * Regression: GRAPHIC_ONLY mode must assign the user's drawn image to the full bbox + * (signatureImageFrame = null). Before the fix only GRAPHIC_AND_DESCRIPTION set + * signatureImagePath, leaving GRAPHIC_ONLY with no image (blank stamp). + */ + public function testBuildAppearanceForElementSetsSignatureImageInGraphicOnlyMode(): void { + $imagePath = realpath(__DIR__ . '/../../../../../img/app-dark.png'); + $this->assertNotFalse($imagePath, 'Test image must exist'); + + $handler = $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_GRAPHIC_ONLY); + $this->signatureBackgroundService->method('isEnabled')->willReturn(false); + + $appearance = $this->callPrivateMethod( + $handler, + 'buildAppearanceForElement', + 10.0, 20.0, 110.0, 70.0, 800.0, 0, 100, 50, + $imagePath, + ); + + $this->assertInstanceOf(SignatureAppearanceDto::class, $appearance); + // Image must fill the entire stamp bbox (no split) + $this->assertSame($imagePath, $appearance->signatureImagePath); + $this->assertNull($appearance->signatureImageFrame); + } + + /** + * Regression: in SIGNAME_AND_DESCRIPTION the signer name must be horizontally + * centred within the left half of the stamp, not pinned to leftPadding (left edge). + * + * Layout math for width=200, height=80, fontSize=20, name="Al": + * leftHalfW = 100.0 + * lineWidth = strlen("Al") * (20 * 0.52) = 2 * 10.4 = 20.8 + * nameX = max(2.0, (100 - 20.8) / 2) = 39.6 → "39.60" + * totalNameHeight = 1 * 20 * 1.0 = 20 (lineHeight factor = 1.0) + * nameStartY = (80 + 20) / 2 - 20 = 30.0 → "30.00" + * Old (broken) code always used leftPadding=2.0 → "2.00 30.00 Td" + */ + public function testBuildXObjectSignameAndDescriptionCentersNameInLeftHalf(): void { + $handler = $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION); + $handler->setSignatureParams(['SignerCommonName' => 'Al']); + $xObject = $this->callPrivateMethod( + $handler, 'buildXObject', 200, 80, SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION, + ); + + // Centred position must appear + $this->assertStringContainsString('39.60 30.00 Td', $xObject->stream); + // Old left-aligned position must NOT appear + $this->assertStringNotContainsString('2.00 30.00 Td', $xObject->stream); + } + + public function testBuildXObjectSignameAndDescriptionWithEmptyNameOmitsNameBlock(): void { + // When SignerCommonName is absent and certificate has no CN, no name block should appear + $engine = $this->createMock(\OCA\Libresign\Handler\CertificateEngine\IEngineHandler::class); + $engine->method('readCertificate')->willReturn(['subject' => ['CN' => '']]); + $this->certificateEngineFactory->method('getEngine')->willReturn($engine); + + $handler = $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION); + $handler->setSignatureParams([]); // no SignerCommonName + $handler->setCertificate('cert'); + $handler->setPassword('pass'); + + $xObject = $this->callPrivateMethod( + $handler, 'buildXObject', 200, 80, SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION, + ); + + // Large font (20.0) must NOT appear when there is no name to render + $this->assertStringNotContainsString('/F1 20.00 Tf', $xObject->stream); + // The stream may be empty or contain only description lines, but no name Tj + $this->assertStringNotContainsString('() Tj', $xObject->stream); + } + + private function getHandler(): PhpNativeHandler { + return $this->getHandlerWithMode(SignerElementsService::RENDER_MODE_DESCRIPTION_ONLY); + } + + private function getHandlerWithMode(string $renderMode): PhpNativeHandler { + $this->signatureTextService->method('getRenderMode') + ->willReturn($renderMode); + $this->signatureTextService->method('parse') + ->willReturn([ + 'parsed' => 'Signed by', + 'templateFontSize' => 10.0, + ]); + $this->signatureTextService->method('getTemplateFontSize') + ->willReturn(10.0); + $this->signatureTextService->method('getSignatureFontSize') + ->willReturn(20.0); + + return new PhpNativeHandler( + $this->appConfig, + $this->docMdpConfigService, + $this->signatureTextService, + $this->signatureBackgroundService, + $this->certificateEngineFactory, + ); + } + + private function callPrivateMethod(object $instance, string $methodName, mixed ...$args): mixed { + $method = new \ReflectionMethod($instance, $methodName); + $method->setAccessible(true); + return $method->invoke($instance, ...$args); + } +} diff --git a/tests/php/Unit/Service/SignFileServiceTest.php b/tests/php/Unit/Service/SignFileServiceTest.php index 613c778c82..ed4960b843 100644 --- a/tests/php/Unit/Service/SignFileServiceTest.php +++ b/tests/php/Unit/Service/SignFileServiceTest.php @@ -1402,6 +1402,50 @@ public static function providerGetSignatureParamsMetadata(): array { ]; } + #[DataProvider('providerGetSignatureParamsPageDimensions')] + public function testGetSignatureParamsPageDimensions( + ?array $fileMetadata, + bool $expectPageDimensions, + ): void { + $service = $this->getService(['readCertificate']); + $service->method('readCertificate')->willReturn([]); + + $signRequest = $this->createMock(SignRequest::class); + $signRequest->method('__call')->willReturnCallback(fn (string $method) => match ($method) { + 'getId' => 1, + 'getMetadata' => [], + default => null, + }); + $service->setSignRequest($signRequest); + + $libreSignFile = new File(); + if ($fileMetadata !== null) { + $libreSignFile->setMetadata($fileMetadata); + } + $service->setLibreSignFile($libreSignFile); + + $actual = $this->invokePrivate($service, 'getSignatureParams'); + + if ($expectPageDimensions) { + $this->assertArrayHasKey('PageDimensions', $actual); + $this->assertSame($fileMetadata['d'], $actual['PageDimensions']); + } else { + $this->assertArrayNotHasKey('PageDimensions', $actual); + } + } + + public static function providerGetSignatureParamsPageDimensions(): array { + return [ + 'file entity is null' => [null, false], + 'metadata has no d key' => [['other' => 'data'], false], + 'metadata with empty d array' => [['d' => []], false], + 'metadata with page dimensions populates PageDimensions' => [ + ['d' => [['w' => 800, 'h' => 600]]], + true, + ], + ]; + } + #[DataProvider('providerSetVisibleElements')] public function testSetVisibleElements( array $signerList, @@ -1595,6 +1639,18 @@ public static function providerSetVisibleElements(): array { expectedException: LibresignException::class, ), + // Regression: canCreateSignature=true but signer submits no element (clickToSign). + // Before the fix `if (!$element) { continue; }` silently skipped the DB file element, + // producing an empty visibleElements array and no stamp on the document. + 'canCreateSignature true, signer submits no element (clickToSign): element still included' => self::createScenarioSetVisibleElements( + signerList: [], // user did not submit any drawn signature + fileElements: [['id' => $validDocumentId]], // admin placed element on doc + tempFiles: [], + signatureFile: [], + canCreateSignature: true, + isAuthenticatedSigner: true, + ), + 'cannot create signature, visible element fallback' => self::createScenarioSetVisibleElements( signerList: [ ['documentElementId' => $validDocumentId], diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index 386db73657..192f22453f 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -2960,5 +2960,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/vendor-bin/rector/composer.lock b/vendor-bin/rector/composer.lock index b2cb96a42b..14f96dbcc0 100644 --- a/vendor-bin/rector/composer.lock +++ b/vendor-bin/rector/composer.lock @@ -500,5 +500,5 @@ "prefer-lowest": false, "platform": {}, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" }