Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.04% covered (success)
93.04%
214 / 230
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureStampPreviewNativeService
93.04% covered (success)
93.04%
214 / 230
54.55% covered (warning)
54.55%
6 / 11
49.81
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
 renderPreviewPdf
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 buildPreviewContext
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getPreviewSignatureImage
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 buildSinglePagePdf
100.00% covered (success)
100.00%
92 / 92
100.00% covered (success)
100.00%
1 / 1
11
 resolveBackgroundJpeg
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
12.10
 convertImageBlobToJpeg
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
2.01
 fitWithinBounds
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 convertImageBlobToPdfRgbaImage
78.26% covered (warning)
78.26%
18 / 23
0.00% covered (danger)
0.00%
0 / 1
6.37
 pixelsToBinary
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 assemblePdf
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
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\Service\SignatureStampPreview;
10
11use Imagick;
12use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue;
13use OCA\Libresign\Service\SignatureBackgroundService;
14use OCA\Libresign\Service\SignatureTextService;
15use OCA\Libresign\Service\SignerElementsService;
16use OCP\Files\NotFoundException;
17
18class 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}