Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.04% |
214 / 230 |
|
54.55% |
6 / 11 |
CRAP | |
0.00% |
0 / 1 |
| SignatureStampPreviewNativeService | |
93.04% |
214 / 230 |
|
54.55% |
6 / 11 |
49.81 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| renderPreviewPdf | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
| buildPreviewContext | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
| getPreviewSignatureImage | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
6.29 | |||
| buildSinglePagePdf | |
100.00% |
92 / 92 |
|
100.00% |
1 / 1 |
11 | |||
| resolveBackgroundJpeg | |
60.00% |
9 / 15 |
|
0.00% |
0 / 1 |
12.10 | |||
| convertImageBlobToJpeg | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
2.01 | |||
| fitWithinBounds | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
6.02 | |||
| convertImageBlobToPdfRgbaImage | |
78.26% |
18 / 23 |
|
0.00% |
0 / 1 |
6.37 | |||
| pixelsToBinary | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
| assemblePdf | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
3 | |||
| 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\Service\SignatureStampPreview; |
| 10 | |
| 11 | use Imagick; |
| 12 | use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue; |
| 13 | use OCA\Libresign\Service\SignatureBackgroundService; |
| 14 | use OCA\Libresign\Service\SignatureTextService; |
| 15 | use OCA\Libresign\Service\SignerElementsService; |
| 16 | use OCP\Files\NotFoundException; |
| 17 | |
| 18 | class SignatureStampPreviewNativeService { |
| 19 | private const PREVIEW_DOCUMENT_UUID = '11111111-2222-4333-8444-555555555555'; |
| 20 | private const PREVIEW_ISSUER_COMMON_NAME = 'Preview Issuer'; |
| 21 | private const PREVIEW_SIGNER_EMAIL = 'preview.signer@libresign.test'; |
| 22 | private const PREVIEW_SIGNER_IDENTIFIER = 'preview-signer'; |
| 23 | private const PREVIEW_SIGNER_IP = '192.0.2.10'; |
| 24 | private const PREVIEW_SIGNER_USER_AGENT = 'LibreSign Preview Browser'; |
| 25 | |
| 26 | public function __construct( |
| 27 | private SignatureStampAppearanceBuilder $appearanceBuilder, |
| 28 | private SignatureTextService $signatureTextService, |
| 29 | private SignatureBackgroundService $signatureBackgroundService, |
| 30 | ) { |
| 31 | } |
| 32 | |
| 33 | public function renderPreviewPdf( |
| 34 | string $template = '', |
| 35 | float $templateFontSize = SignatureTextPolicyValue::DEFAULT_TEMPLATE_FONT_SIZE, |
| 36 | float $signatureFontSize = SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE, |
| 37 | float $signatureWidth = SignatureTextPolicyValue::DEFAULT_SIGNATURE_WIDTH, |
| 38 | float $signatureHeight = SignatureTextPolicyValue::DEFAULT_SIGNATURE_HEIGHT, |
| 39 | string $renderMode = SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION, |
| 40 | string $backgroundType = 'default', |
| 41 | ): string { |
| 42 | $width = max(1.0, $signatureWidth); |
| 43 | $height = max(1.0, $signatureHeight); |
| 44 | |
| 45 | $backgroundType = trim(strtolower($backgroundType)); |
| 46 | |
| 47 | $xObject = $this->appearanceBuilder->buildXObject( |
| 48 | width: (int)round($width), |
| 49 | height: (int)round($height), |
| 50 | renderMode: $renderMode, |
| 51 | context: $this->buildPreviewContext(), |
| 52 | template: $template, |
| 53 | templateFontSize: $templateFontSize, |
| 54 | signatureFontSize: $signatureFontSize, |
| 55 | fallbackCommonName: $this->signatureTextService->getPreviewSignerName(), |
| 56 | ); |
| 57 | |
| 58 | $contentStream = $xObject->stream; |
| 59 | |
| 60 | $backgroundJpeg = $this->resolveBackgroundJpeg($backgroundType); |
| 61 | $previewSignatureImage = $this->getPreviewSignatureImage($renderMode); |
| 62 | |
| 63 | return $this->buildSinglePagePdf($width, $height, $contentStream, $backgroundJpeg, $previewSignatureImage, $renderMode); |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * @return array<string, string> |
| 68 | */ |
| 69 | private function buildPreviewContext(): array { |
| 70 | $previewSignerName = $this->signatureTextService->getPreviewSignerName(); |
| 71 | |
| 72 | return [ |
| 73 | 'DocumentUUID' => self::PREVIEW_DOCUMENT_UUID, |
| 74 | 'IssuerCommonName' => self::PREVIEW_ISSUER_COMMON_NAME, |
| 75 | 'LocalSignerSignatureDateOnly' => '2026-05-20', |
| 76 | 'LocalSignerSignatureDateTime' => '2026-05-20T14:30:00+00:00', |
| 77 | 'LocalSignerTimezone' => 'UTC', |
| 78 | 'ServerSignatureDate' => '2026-05-20T14:30:00+00:00', |
| 79 | 'SignerCommonName' => $previewSignerName, |
| 80 | 'SignerEmail' => self::PREVIEW_SIGNER_EMAIL, |
| 81 | 'SignerIdentifier' => self::PREVIEW_SIGNER_IDENTIFIER, |
| 82 | 'SignerIP' => self::PREVIEW_SIGNER_IP, |
| 83 | 'SignerUserAgent' => self::PREVIEW_SIGNER_USER_AGENT, |
| 84 | ]; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Loads the preview signature asset (PNG converted to JPEG) for placement in the preview PDF. |
| 89 | * |
| 90 | * The asset is located at img/preview_signature.png and is embedded directly into the |
| 91 | * preview PDF for graphic modes. This simplifies the implementation significantly compared |
| 92 | * to procedurally drawing Bezier curves, and makes the preview nature of the graphic clear. |
| 93 | * |
| 94 | * @return array{kind:'rgba',width:int,height:int,rgbData:string,alphaData:string}|null |
| 95 | */ |
| 96 | private function getPreviewSignatureImage(string $renderMode): ?array { |
| 97 | if ( |
| 98 | $renderMode !== SignerElementsService::RENDER_MODE_GRAPHIC_ONLY |
| 99 | && $renderMode !== SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION |
| 100 | ) { |
| 101 | return null; |
| 102 | } |
| 103 | |
| 104 | $assetPath = dirname(__DIR__, 3) . '/img/preview_signature.png'; |
| 105 | if (!file_exists($assetPath)) { |
| 106 | return null; |
| 107 | } |
| 108 | $blob = @file_get_contents($assetPath); |
| 109 | if (!is_string($blob) || $blob === '') { |
| 110 | return null; |
| 111 | } |
| 112 | |
| 113 | return $this->convertImageBlobToPdfRgbaImage($blob); |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * @param array{width:int,height:int,data:string}|null $backgroundJpeg |
| 118 | * @param array{kind:'rgba',width:int,height:int,rgbData:string,alphaData:string}|null $previewSignatureImage |
| 119 | */ |
| 120 | private function buildSinglePagePdf(float $width, float $height, string $contentStream, ?array $backgroundJpeg, ?array $previewSignatureImage, string $renderMode): string { |
| 121 | $widthFormatted = number_format($width, 2, '.', ''); |
| 122 | $heightFormatted = number_format($height, 2, '.', ''); |
| 123 | $stream = ''; |
| 124 | $xObjectReferences = []; |
| 125 | $nextObjectId = 5; |
| 126 | |
| 127 | $objects = [ |
| 128 | 1 => '<< /Type /Catalog /Pages 2 0 R >>', |
| 129 | 2 => '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', |
| 130 | 4 => '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', |
| 131 | ]; |
| 132 | |
| 133 | if ($backgroundJpeg !== null) { |
| 134 | $fit = $this->fitWithinBounds( |
| 135 | width: $backgroundJpeg['width'], |
| 136 | height: $backgroundJpeg['height'], |
| 137 | maxWidth: (int)round($width), |
| 138 | maxHeight: (int)round($height), |
| 139 | ); |
| 140 | if ($fit['width'] > 0 && $fit['height'] > 0) { |
| 141 | $stream .= sprintf( |
| 142 | "q\n%d 0 0 %d %d %d cm\n/Im1 Do\nQ\n", |
| 143 | $fit['width'], |
| 144 | $fit['height'], |
| 145 | $fit['x'], |
| 146 | $fit['y'], |
| 147 | ); |
| 148 | $backgroundObjectId = $nextObjectId; |
| 149 | $nextObjectId += 1; |
| 150 | $xObjectReferences[] = '/Im1 ' . $backgroundObjectId . ' 0 R'; |
| 151 | $objects[$backgroundObjectId] = sprintf( |
| 152 | "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n%sendstream", |
| 153 | $backgroundJpeg['width'], |
| 154 | $backgroundJpeg['height'], |
| 155 | strlen($backgroundJpeg['data']), |
| 156 | $backgroundJpeg['data'], |
| 157 | ); |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | if ($previewSignatureImage !== null) { |
| 162 | $maxSignatureWidth = $renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY |
| 163 | ? (int)round($width) |
| 164 | : (int)round($width / 2.0); |
| 165 | $fit = $this->fitWithinBounds( |
| 166 | width: $previewSignatureImage['width'], |
| 167 | height: $previewSignatureImage['height'], |
| 168 | maxWidth: $maxSignatureWidth, |
| 169 | maxHeight: (int)round($height), |
| 170 | allowUpscale: false, |
| 171 | ); |
| 172 | if ($fit['width'] > 0 && $fit['height'] > 0) { |
| 173 | $stream .= sprintf( |
| 174 | "q\n%d 0 0 %d %d %d cm\n/Im2 Do\nQ\n", |
| 175 | $fit['width'], |
| 176 | $fit['height'], |
| 177 | $fit['x'], |
| 178 | $fit['y'], |
| 179 | ); |
| 180 | $signatureObjectId = $nextObjectId; |
| 181 | $nextObjectId += 1; |
| 182 | |
| 183 | $alphaMaskObjectId = $nextObjectId; |
| 184 | $nextObjectId += 1; |
| 185 | |
| 186 | $xObjectReferences[] = '/Im2 ' . $signatureObjectId . ' 0 R'; |
| 187 | |
| 188 | $alphaData = gzcompress($previewSignatureImage['alphaData']) ?: ''; |
| 189 | $objects[$alphaMaskObjectId] = sprintf( |
| 190 | "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length %d >>\nstream\n%sendstream", |
| 191 | $previewSignatureImage['width'], |
| 192 | $previewSignatureImage['height'], |
| 193 | strlen($alphaData), |
| 194 | $alphaData, |
| 195 | ); |
| 196 | |
| 197 | $rgbData = gzcompress($previewSignatureImage['rgbData']) ?: ''; |
| 198 | $objects[$signatureObjectId] = sprintf( |
| 199 | "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask %d 0 R /Filter /FlateDecode /Length %d >>\nstream\n%sendstream", |
| 200 | $previewSignatureImage['width'], |
| 201 | $previewSignatureImage['height'], |
| 202 | $alphaMaskObjectId, |
| 203 | strlen($rgbData), |
| 204 | $rgbData, |
| 205 | ); |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | $stream .= "q\n" . $contentStream . "Q\n"; |
| 210 | $contentObjectId = $nextObjectId; |
| 211 | |
| 212 | $xObjectDict = $xObjectReferences !== [] ? ' /XObject << ' . implode(' ', $xObjectReferences) . ' >>' : ''; |
| 213 | $objects[3] = sprintf( |
| 214 | '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %s %s] /Resources << /Font << /F1 4 0 R >>%s >> /Contents %d 0 R >>', |
| 215 | $widthFormatted, |
| 216 | $heightFormatted, |
| 217 | $xObjectDict, |
| 218 | $contentObjectId, |
| 219 | ); |
| 220 | |
| 221 | $objects[$contentObjectId] = sprintf( |
| 222 | "<< /Length %d >>\nstream\n%sendstream", |
| 223 | strlen($stream), |
| 224 | $stream, |
| 225 | ); |
| 226 | |
| 227 | return $this->assemblePdf($objects); |
| 228 | } |
| 229 | |
| 230 | /** |
| 231 | * @return array{width:int,height:int,data:string}|null |
| 232 | */ |
| 233 | private function resolveBackgroundJpeg(string $backgroundType): ?array { |
| 234 | if ($backgroundType === 'deleted') { |
| 235 | return null; |
| 236 | } |
| 237 | |
| 238 | $blob = null; |
| 239 | try { |
| 240 | if ($backgroundType === 'default') { |
| 241 | $blob = $this->signatureBackgroundService->getDefaultImageBlob(); |
| 242 | } elseif ($backgroundType === 'custom') { |
| 243 | $blob = $this->signatureBackgroundService->getCustomImageBlob(); |
| 244 | if ($blob === null) { |
| 245 | $blob = $this->signatureBackgroundService->getDefaultImageBlob(); |
| 246 | } |
| 247 | } else { |
| 248 | $blob = $this->signatureBackgroundService->getImage()->getContent(); |
| 249 | } |
| 250 | } catch (NotFoundException|\Throwable) { |
| 251 | return null; |
| 252 | } |
| 253 | |
| 254 | if (!is_string($blob) || $blob === '') { |
| 255 | return null; |
| 256 | } |
| 257 | |
| 258 | return $this->convertImageBlobToJpeg($blob); |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * @return array{width:int,height:int,data:string}|null |
| 263 | */ |
| 264 | private function convertImageBlobToJpeg(string $blob): ?array { |
| 265 | try { |
| 266 | $image = new Imagick(); |
| 267 | $image->readImageBlob($blob); |
| 268 | $image->setImageBackgroundColor('white'); |
| 269 | $image = $image->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN); |
| 270 | $image->setImageFormat('jpeg'); |
| 271 | $image->setImageCompressionQuality(85); |
| 272 | |
| 273 | $width = max(1, (int)$image->getImageWidth()); |
| 274 | $height = max(1, (int)$image->getImageHeight()); |
| 275 | $data = $image->getImageBlob(); |
| 276 | $image->destroy(); |
| 277 | |
| 278 | return [ |
| 279 | 'width' => $width, |
| 280 | 'height' => $height, |
| 281 | 'data' => $data, |
| 282 | ]; |
| 283 | } catch (\Throwable) { |
| 284 | return null; |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * @return array{width:int,height:int,x:int,y:int} |
| 290 | */ |
| 291 | private function fitWithinBounds(int $width, int $height, int $maxWidth, int $maxHeight, bool $allowUpscale = true): array { |
| 292 | if ($width <= 0 || $height <= 0 || $maxWidth <= 0 || $maxHeight <= 0) { |
| 293 | return ['width' => 0, 'height' => 0, 'x' => 0, 'y' => 0]; |
| 294 | } |
| 295 | |
| 296 | $scale = min($maxWidth / $width, $maxHeight / $height); |
| 297 | if (!$allowUpscale) { |
| 298 | $scale = min(1.0, $scale); |
| 299 | } |
| 300 | $fitWidth = max(1, (int)floor($width * $scale)); |
| 301 | $fitHeight = max(1, (int)floor($height * $scale)); |
| 302 | |
| 303 | return [ |
| 304 | 'width' => $fitWidth, |
| 305 | 'height' => $fitHeight, |
| 306 | 'x' => (int)floor(max(0, $maxWidth - $fitWidth) / 2), |
| 307 | 'y' => (int)floor(max(0, $maxHeight - $fitHeight) / 2), |
| 308 | ]; |
| 309 | } |
| 310 | |
| 311 | /** |
| 312 | * @return array{kind:'rgba',width:int,height:int,rgbData:string,alphaData:string}|null |
| 313 | */ |
| 314 | private function convertImageBlobToPdfRgbaImage(string $blob): ?array { |
| 315 | try { |
| 316 | $image = new Imagick(); |
| 317 | $image->readImageBlob($blob); |
| 318 | |
| 319 | $width = max(1, (int)$image->getImageWidth()); |
| 320 | $height = max(1, (int)$image->getImageHeight()); |
| 321 | |
| 322 | $rgbPixels = $image->exportImagePixels(0, 0, $width, $height, 'RGB', Imagick::PIXEL_CHAR); |
| 323 | $alphaPixels = $image->exportImagePixels(0, 0, $width, $height, 'A', Imagick::PIXEL_CHAR); |
| 324 | |
| 325 | if (!is_array($rgbPixels) || !is_array($alphaPixels)) { |
| 326 | $image->destroy(); |
| 327 | return null; |
| 328 | } |
| 329 | |
| 330 | $rgbData = $this->pixelsToBinary($rgbPixels); |
| 331 | $alphaData = $this->pixelsToBinary($alphaPixels); |
| 332 | |
| 333 | $image->destroy(); |
| 334 | |
| 335 | if ($rgbData === '' || $alphaData === '') { |
| 336 | return null; |
| 337 | } |
| 338 | |
| 339 | return [ |
| 340 | 'kind' => 'rgba', |
| 341 | 'width' => $width, |
| 342 | 'height' => $height, |
| 343 | 'rgbData' => $rgbData, |
| 344 | 'alphaData' => $alphaData, |
| 345 | ]; |
| 346 | } catch (\Throwable) { |
| 347 | return null; |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | /** |
| 352 | * @param list<int|float|string> $pixels |
| 353 | */ |
| 354 | private function pixelsToBinary(array $pixels): string { |
| 355 | $data = ''; |
| 356 | $chunk = []; |
| 357 | $chunkSize = 8192; |
| 358 | |
| 359 | foreach ($pixels as $value) { |
| 360 | $chunk[] = (int)$value; |
| 361 | if (count($chunk) >= $chunkSize) { |
| 362 | $data .= pack('C*', ...$chunk); |
| 363 | $chunk = []; |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | if ($chunk !== []) { |
| 368 | $data .= pack('C*', ...$chunk); |
| 369 | } |
| 370 | |
| 371 | return $data; |
| 372 | } |
| 373 | |
| 374 | /** |
| 375 | * @param array<int, string> $objects |
| 376 | */ |
| 377 | private function assemblePdf(array $objects): string { |
| 378 | $pdf = "%PDF-1.4\n"; |
| 379 | $offsets = [0 => 0]; |
| 380 | |
| 381 | $objectCount = count($objects); |
| 382 | for ($i = 1; $i <= $objectCount; $i++) { |
| 383 | $offsets[$i] = strlen($pdf); |
| 384 | $pdf .= sprintf("%d 0 obj\n%s\nendobj\n", $i, $objects[$i]); |
| 385 | } |
| 386 | |
| 387 | $xrefOffset = strlen($pdf); |
| 388 | $pdf .= sprintf("xref\n0 %d\n", $objectCount + 1); |
| 389 | $pdf .= "0000000000 65535 f \n"; |
| 390 | for ($i = 1; $i <= $objectCount; $i++) { |
| 391 | $pdf .= sprintf("%010d 00000 n \n", $offsets[$i]); |
| 392 | } |
| 393 | |
| 394 | $pdf .= sprintf( |
| 395 | "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", |
| 396 | $objectCount + 1, |
| 397 | $xrefOffset, |
| 398 | ); |
| 399 | |
| 400 | return $pdf; |
| 401 | } |
| 402 | } |