Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.00% covered (danger)
32.00%
32 / 100
20.00% covered (danger)
20.00%
3 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureBackgroundService
32.00% covered (danger)
32.00%
32 / 100
20.00% covered (danger)
20.00%
3 / 15
420.18
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
 getRootFolder
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 updateImage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getSignatureBackgroundType
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 optmizeImage
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 scaleDimensions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 fitWithinBounds
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 delete
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 reset
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 saveSystemBackgroundType
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getImage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultImageBlob
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCustomImageBlob
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getImagePath
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service;
10
11use Exception;
12use Imagick;
13use ImagickPixel;
14use OCA\Libresign\Files\TSimpleFile;
15use OCA\Libresign\Service\Policy\PolicyService;
16use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicy;
17use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue;
18use OCP\Files\IAppData;
19use OCP\Files\NotFoundException;
20use OCP\Files\NotPermittedException;
21use OCP\Files\SimpleFS\InMemoryFile;
22use OCP\Files\SimpleFS\ISimpleFile;
23use OCP\Files\SimpleFS\ISimpleFolder;
24use OCP\IAppConfig;
25use OCP\IConfig;
26use OCP\ITempManager;
27
28class SignatureBackgroundService {
29    use TSimpleFile;
30    public const SCALE_FACTOR = 3;
31
32    public function __construct(
33        private IAppData $appData,
34        private IAppConfig $appConfig,
35        private IConfig $config,
36        private ITempManager $tempManager,
37        private SignatureTextService $signatureTextService,
38        private PolicyService $policyService,
39    ) {
40    }
41
42    private function getRootFolder(): ISimpleFolder {
43        try {
44            return $this->appData->getFolder('signature');
45        } catch (NotFoundException) {
46            return $this->appData->newFolder('signature');
47        }
48    }
49
50    public function updateImage(string $tmpFile): void {
51        $folder = $this->getRootFolder();
52        $detectedMimeType = mime_content_type($tmpFile);
53        if (!in_array($detectedMimeType, ['image/png'], true)) {
54            throw new \Exception('Unsupported image type: ' . $detectedMimeType);
55        }
56
57        $content = $this->optmizeImage(file_get_contents($tmpFile));
58
59        $this->saveSystemBackgroundType('custom');
60        $target = $folder->newFile('background.png');
61        $target->putContent($content);
62    }
63
64    public function getSignatureBackgroundType(): string {
65        $stampValue = SignatureTextPolicyValue::normalize(
66            $this->policyService->resolve(SignatureTextPolicy::KEY)->getEffectiveValue(),
67        );
68        $value = $stampValue['background_type'] ?? 'default';
69        if (!is_string($value)) {
70            return 'default';
71        }
72
73        $normalized = trim(strtolower($value));
74        if (!in_array($normalized, ['default', 'custom', 'deleted'], true)) {
75            return 'default';
76        }
77
78        return $normalized;
79    }
80
81    public function isEnabled(): bool {
82        return $this->getSignatureBackgroundType() !== 'deleted';
83    }
84
85    private function optmizeImage(string $content, float $opacity = 1): string {
86        if (!extension_loaded('imagick')) {
87            throw new Exception('Extension imagick is not loaded.');
88        }
89        $image = new Imagick();
90        $image->setBackgroundColor(new ImagickPixel('transparent'));
91        $image->readImageBlob($content);
92        $width = $image->getImageWidth();
93        $height = $image->getImageHeight();
94        $dimensions = $this->scaleDimensions($width, $height);
95        if ($dimensions['width'] === $width && $dimensions['height'] === $height && $image->readImageBlob($content) === 'PNG') {
96            return $content;
97        }
98        $image->setImageResolution(300, 300);
99        $image->resampleImage(300, 300, Imagick::FILTER_LANCZOS, 1);
100        $image->setImageAlphaChannel(Imagick::ALPHACHANNEL_ACTIVATE);
101        $image->evaluateImage(Imagick::EVALUATE_MULTIPLY, $opacity, Imagick::CHANNEL_ALPHA);
102        $image->setImageFormat('png');
103        $image->resizeImage($dimensions['width'], $dimensions['height'], Imagick::FILTER_LANCZOS, 1);
104        return $image->getImageBlob();
105    }
106
107    private function scaleDimensions(int $width, int $height): array {
108        $signatureWidth = $this->signatureTextService->getFullSignatureWidth();
109        $signatureHeight = $this->signatureTextService->getFullSignatureHeight();
110
111        $maxWidth = $signatureWidth * self::SCALE_FACTOR;
112        $maxHeight = $signatureHeight * self::SCALE_FACTOR;
113
114        if ($width <= $maxWidth && $height <= $maxHeight) {
115            return ['width' => $width, 'height' => $height];
116        }
117
118        $widthRatio = $maxWidth / $width;
119        $heightRatio = $maxHeight / $height;
120        $scale = min($widthRatio, $heightRatio);
121
122        $returnWidth = (int)floor($width * $scale);
123        $returnHeight = (int)floor($height * $scale);
124
125        return ['width' => $returnWidth, 'height' => $returnHeight];
126    }
127
128    /**
129     * @return array{width: int, height: int, x: int, y: int}
130     */
131    private function fitWithinBounds(int $width, int $height, int $maxWidth, int $maxHeight): array {
132        if ($width <= 0 || $height <= 0 || $maxWidth <= 0 || $maxHeight <= 0) {
133            return ['width' => 0, 'height' => 0, 'x' => 0, 'y' => 0];
134        }
135
136        $scale = min($maxWidth / $width, $maxHeight / $height);
137        $fitWidth = max(1, (int)floor($width * $scale));
138        $fitHeight = max(1, (int)floor($height * $scale));
139
140        return [
141            'width' => $fitWidth,
142            'height' => $fitHeight,
143            'x' => (int)floor(max(0, $maxWidth - $fitWidth) / 2),
144            'y' => (int)floor(max(0, $maxHeight - $fitHeight) / 2),
145        ];
146    }
147
148    public function delete(): void {
149        try {
150            $this->saveSystemBackgroundType('deleted');
151            $file = $this->getRootFolder()->getFile('background.png');
152            $file->delete();
153        } catch (NotFoundException|NotPermittedException) {
154        }
155    }
156
157    public function reset(): void {
158        try {
159            $this->saveSystemBackgroundType('default');
160            $file = $this->getRootFolder()->getFile('background.png');
161            $file->delete();
162        } catch (NotFoundException|NotPermittedException) {
163        }
164    }
165
166    private function saveSystemBackgroundType(string $value): void {
167        $stampValue = SignatureTextPolicyValue::normalize(
168            $this->policyService->resolve(SignatureTextPolicy::KEY)->getEffectiveValue(),
169        );
170        $stampValue['background_type'] = $value;
171
172        $allowChildOverride = $this->policyService->getSystemPolicy(SignatureTextPolicy::KEY)?->isAllowChildOverride() ?? false;
173        $this->policyService->saveSystem(
174            SignatureTextPolicy::KEY,
175            SignatureTextPolicyValue::encode($stampValue),
176            $allowChildOverride,
177        );
178    }
179
180    public function getImage(): ISimpleFile {
181        try {
182            $file = $this->getRootFolder()->getFile('background.png');
183        } catch (NotFoundException) {
184            $content = $this->getDefaultImageBlob();
185            $file = new InMemoryFile('background.png', $content);
186        }
187        return $file;
188    }
189
190    public function getDefaultImageBlob(): string {
191        return $this->optmizeImage(file_get_contents(__DIR__ . '/../../img/logo-gray.svg'), 0.15);
192    }
193
194    public function getCustomImageBlob(): ?string {
195        try {
196            return $this->getRootFolder()->getFile('background.png')->getContent();
197        } catch (NotFoundException) {
198            return null;
199        }
200    }
201
202    public function getImagePath(): string {
203        try {
204            $filePath = $this->getRootFolder()->getFile('background.png');
205            $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
206            return $dataDir . '/' . $this->getInternalPathOfFile($filePath);
207        } catch (NotFoundException) {
208            $content = $this->optmizeImage(file_get_contents(__DIR__ . '/../../img/logo-gray.svg'), 0.3);
209            $filePath = $this->tempManager->getTemporaryFile('.png');
210            if (!$filePath) {
211                throw new Exception('Imposible to write temporary file at temporary folder');
212            }
213            file_put_contents($filePath, $content);
214        }
215        return $filePath;
216    }
217}