Skip to content

Commit e08e5d4

Browse files
fix: generate favourite icon without imagick svg support
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
1 parent c374886 commit e08e5d4

File tree

4 files changed

+117
-77
lines changed

4 files changed

+117
-77
lines changed

apps/theming/lib/Controller/IconController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public function getFavicon(string $app = 'core'): Response {
100100
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
101101
} catch (NotFoundException $e) {
102102
}
103-
if ($iconFile === null && $this->imageManager->shouldReplaceIcons()) {
103+
if ($iconFile === null && $this->imageManager->canConvert('PNG')) {
104104
$color = $this->themingDefaults->getColorPrimary();
105105
try {
106106
$iconFile = $this->imageManager->getCachedImage('favIcon-' . $app . $color);
@@ -145,7 +145,7 @@ public function getTouchIcon(string $app = 'core'): Response {
145145
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
146146
} catch (NotFoundException $e) {
147147
}
148-
if ($this->imageManager->shouldReplaceIcons()) {
148+
if ($this->imageManager->canConvert('PNG')) {
149149
$color = $this->themingDefaults->getColorPrimary();
150150
try {
151151
$iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app . $color);

apps/theming/lib/IconBuilder.php

Lines changed: 99 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
namespace OCA\Theming;
88

99
use Imagick;
10+
use ImagickDraw;
1011
use ImagickPixel;
1112
use OCP\Files\SimpleFS\ISimpleFile;
1213

@@ -30,18 +31,19 @@ public function __construct(
3031
* @return string|false image blob
3132
*/
3233
public function getFavicon($app) {
33-
if (!$this->imageManager->shouldReplaceIcons()) {
34+
if (!$this->imageManager->canConvert('ICO')) {
3435
return false;
3536
}
3637
try {
37-
$favicon = new Imagick();
38-
$favicon->setFormat('ico');
3938
$icon = $this->renderAppIcon($app, 128);
4039
if ($icon === false) {
4140
return false;
4241
}
4342
$icon->setImageFormat('png32');
4443

44+
$favicon = new Imagick();
45+
$favicon->setFormat('ico');
46+
4547
$clone = clone $icon;
4648
$clone->scaleImage(16, 0);
4749
$favicon->addImage($clone);
@@ -111,79 +113,108 @@ public function renderAppIcon($app, $size) {
111113
return false;
112114
}
113115

116+
$padding = 0.15;
114117
$color = $this->themingDefaults->getColorPrimary();
118+
$appIconFile = null;
119+
$appIconIsSvg = ($mime === 'image/svg+xml' || substr($appIconContent, 0, 4) === '<svg');
115120

116-
// generate background image with rounded corners
117-
$cornerRadius = 0.2 * $size;
118-
$background = '<?xml version="1.0" encoding="UTF-8"?>'
119-
. '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:cc="http://creativecommons.org/ns#" width="' . $size . '" height="' . $size . '" xmlns:xlink="http://www.w3.org/1999/xlink">'
120-
. '<rect x="0" y="0" rx="' . $cornerRadius . '" ry="' . $cornerRadius . '" width="' . $size . '" height="' . $size . '" style="fill:' . $color . ';" />'
121-
. '</svg>';
122-
// resize svg magic as this seems broken in Imagemagick
123-
if ($mime === 'image/svg+xml' || substr($appIconContent, 0, 4) === '<svg') {
124-
if (substr($appIconContent, 0, 5) !== '<?xml') {
125-
$svg = '<?xml version="1.0"?>' . $appIconContent;
126-
} else {
127-
$svg = $appIconContent;
128-
}
129-
$tmp = new Imagick();
130-
$tmp->setBackgroundColor(new ImagickPixel('transparent'));
131-
$tmp->setResolution(72, 72);
132-
$tmp->readImageBlob($svg);
133-
$x = $tmp->getImageWidth();
134-
$y = $tmp->getImageHeight();
135-
$tmp->destroy();
136-
137-
// convert svg to resized image
121+
// determine if SVG support is available
122+
if ($appIconIsSvg && !$this->imageManager->canConvert('SVG')) {
123+
return false;
124+
}
125+
126+
try {
127+
// construct original image object
138128
$appIconFile = new Imagick();
139-
$resX = (int)(72 * $size / $x);
140-
$resY = (int)(72 * $size / $y);
141-
$appIconFile->setResolution($resX, $resY);
142129
$appIconFile->setBackgroundColor(new ImagickPixel('transparent'));
143-
$appIconFile->readImageBlob($svg);
144-
145-
/**
146-
* invert app icons for bright primary colors
147-
* the default nextcloud logo will not be inverted to black
148-
*/
149-
if ($this->util->isBrightColor($color)
150-
&& !$appIcon instanceof ISimpleFile
151-
&& $app !== 'core'
152-
) {
153-
$appIconFile->negateImage(false);
130+
131+
if ($appIconIsSvg) {
132+
// handle SVG images
133+
// ensure proper XML declaration
134+
if (substr($appIconContent, 0, 5) !== '<?xml') {
135+
$svg = '<?xml version="1.0"?>' . $appIconContent;
136+
} else {
137+
$svg = $appIconContent;
138+
}
139+
// get dimensions for resolution calculation
140+
$tmp = new Imagick();
141+
$tmp->setBackgroundColor(new ImagickPixel('transparent'));
142+
$tmp->setResolution(72, 72);
143+
$tmp->readImageBlob($svg);
144+
$x = $tmp->getImageWidth();
145+
$y = $tmp->getImageHeight();
146+
$tmp->destroy();
147+
// set resolution for proper scaling
148+
$resX = (int)(72 * $size / $x);
149+
$resY = (int)(72 * $size / $y);
150+
$appIconFile->setResolution($resX, $resY);
151+
$appIconFile->readImageBlob($svg);
152+
} else {
153+
// handle non-SVG images
154+
$appIconFile->readImageBlob($appIconContent);
154155
}
155-
} else {
156-
$appIconFile = new Imagick();
157-
$appIconFile->setBackgroundColor(new ImagickPixel('transparent'));
158-
$appIconFile->readImageBlob($appIconContent);
156+
} catch (\ImagickException $e) {
157+
return false;
159158
}
160-
// offset for icon positioning
161-
$padding = 0.15;
162-
$border_w = (int)($appIconFile->getImageWidth() * $padding);
163-
$border_h = (int)($appIconFile->getImageHeight() * $padding);
164-
$innerWidth = ($appIconFile->getImageWidth() - $border_w * 2);
165-
$innerHeight = ($appIconFile->getImageHeight() - $border_h * 2);
166-
$appIconFile->adaptiveResizeImage($innerWidth, $innerHeight);
167-
// center icon
168-
$offset_w = (int)($size / 2 - $innerWidth / 2);
169-
$offset_h = (int)($size / 2 - $innerHeight / 2);
170-
171-
$finalIconFile = new Imagick();
172-
$finalIconFile->setBackgroundColor(new ImagickPixel('transparent'));
173-
$finalIconFile->readImageBlob($background);
174-
$finalIconFile->setImageVirtualPixelMethod(Imagick::VIRTUALPIXELMETHOD_TRANSPARENT);
175-
$finalIconFile->setImageArtifact('compose:args', '1,0,-0.5,0.5');
176-
$finalIconFile->compositeImage($appIconFile, Imagick::COMPOSITE_ATOP, $offset_w, $offset_h);
177-
$finalIconFile->setImageFormat('png24');
178-
if (defined('Imagick::INTERPOLATE_BICUBIC') === true) {
179-
$filter = Imagick::INTERPOLATE_BICUBIC;
180-
} else {
181-
$filter = Imagick::FILTER_LANCZOS;
159+
/**
160+
* invert app icons for bright primary colors
161+
* the default nextcloud logo will not be inverted to black
162+
*/
163+
if ($this->util->isBrightColor($color)
164+
&& !$appIcon instanceof ISimpleFile
165+
&& $app !== 'core'
166+
) {
167+
$appIconFile->negateImage(false);
168+
}
169+
170+
// calculate final image size and position
171+
$original_w = $appIconFile->getImageWidth();
172+
$original_h = $appIconFile->getImageHeight();
173+
$contentBox = (int)floor($size * (1 - 2 * $padding));
174+
if ($contentBox < 1) {
175+
$contentBox = $size; // fallback safety
176+
}
177+
$scaleX = $contentBox / $original_w;
178+
$scaleY = $contentBox / $original_h;
179+
$scale = min($scaleX, $scaleY);
180+
$new_w = max(1, (int)floor($original_w * $scale));
181+
$new_h = max(1, (int)floor($original_h * $scale));
182+
$offset_w = (int)floor(($size - $new_w) / 2);
183+
$offset_h = (int)floor(($size - $new_h) / 2);
184+
// resize original image
185+
$appIconFile->resizeImage($new_w, $new_h, Imagick::FILTER_LANCZOS, 1);
186+
// construct final image object
187+
try {
188+
// image background
189+
$finalIconFile = new Imagick();
190+
$finalIconFile->setBackgroundColor(new ImagickPixel('transparent'));
191+
// icon background
192+
$finalIconFile->newImage($size, $size, new ImagickPixel('transparent'));
193+
$finalIconFile->setImageFormat('PNG32');
194+
$draw = new ImagickDraw();
195+
$draw->setFillColor($color);
196+
$cornerRadius = 0.2 * $size;
197+
$draw->roundRectangle(0, 0, $size - 1, $size - 1, $cornerRadius, $cornerRadius);
198+
$finalIconFile->drawImage($draw);
199+
$draw->destroy();
200+
// overlay icon
201+
$finalIconFile->setImageVirtualPixelMethod(Imagick::VIRTUALPIXELMETHOD_TRANSPARENT);
202+
$finalIconFile->setImageArtifact('compose:args', '1,0,-0.5,0.5');
203+
$finalIconFile->compositeImage($appIconFile, Imagick::COMPOSITE_ATOP, $offset_w, $offset_h);
204+
$finalIconFile->setImageFormat('PNG32');
205+
if (defined('Imagick::INTERPOLATE_BICUBIC') === true) {
206+
$filter = Imagick::INTERPOLATE_BICUBIC;
207+
} else {
208+
$filter = Imagick::FILTER_LANCZOS;
209+
}
210+
$finalIconFile->resizeImage($size, $size, $filter, 1, false);
211+
212+
return $finalIconFile;
213+
} finally {
214+
unset($appIconFile);
182215
}
183-
$finalIconFile->resizeImage($size, $size, $filter, 1, false);
184216

185-
$appIconFile->destroy();
186-
return $finalIconFile;
217+
return false;
187218
}
188219

189220
/**

apps/theming/lib/ImageManager.php

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ public function getImage(string $key, bool $useSvg = true): ISimpleFile {
9090
throw new NotFoundException();
9191
}
9292

93-
if (!$useSvg && $this->shouldReplaceIcons()) {
93+
if (!$useSvg && $this->canConvert('PNG')) {
9494
if (!$folder->fileExists($key . '.png')) {
9595
try {
9696
$finalIconFile = new \Imagick();
@@ -328,7 +328,7 @@ private function shouldOptimizeBackgroundImage(string $mimeType, int $contentSiz
328328
public function getSupportedUploadImageFormats(string $key): array {
329329
$supportedFormats = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
330330

331-
if ($key !== 'favicon' || $this->shouldReplaceIcons() === true) {
331+
if ($key !== 'favicon' || $this->canConvert('SVG') === true) {
332332
$supportedFormats[] = 'image/svg+xml';
333333
$supportedFormats[] = 'image/svg';
334334
}
@@ -364,17 +364,26 @@ public function cleanup() {
364364
* @return bool
365365
*/
366366
public function shouldReplaceIcons() {
367+
return $this->canConvert('SVG');
368+
}
369+
370+
/**
371+
* Check if Imagemagick is enabled and if format is supported
372+
*
373+
* @return bool
374+
*/
375+
public function canConvert(string $format = 'SVG'): bool {
367376
$cache = $this->cacheFactory->createDistributed('theming-' . $this->urlGenerator->getBaseUrl());
368-
if ($value = $cache->get('shouldReplaceIcons')) {
377+
if ($value = $cache->get('convert-' . $format)) {
369378
return (bool)$value;
370379
}
371380
$value = false;
372381
if (extension_loaded('imagick')) {
373-
if (count(\Imagick::queryFormats('SVG')) >= 1) {
382+
if (count(\Imagick::queryFormats($format)) >= 1) {
374383
$value = true;
375384
}
376385
}
377-
$cache->set('shouldReplaceIcons', $value);
386+
$cache->set('convert-' . $format, $value);
378387
return $value;
379388
}
380389

apps/theming/lib/ThemingDefaults.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,10 +381,10 @@ public function replaceImagePath($app, $image) {
381381
$cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0');
382382

383383
$route = false;
384-
if ($image === 'favicon.ico' && ($this->imageManager->shouldReplaceIcons() || $this->getCustomFavicon() !== null)) {
384+
if ($image === 'favicon.ico' && ($this->imageManager->canConvert('ICO') || $this->getCustomFavicon() !== null)) {
385385
$route = $this->urlGenerator->linkToRoute('theming.Icon.getFavicon', ['app' => $app]);
386386
}
387-
if (($image === 'favicon-touch.png' || $image === 'favicon-fb.png') && ($this->imageManager->shouldReplaceIcons() || $this->getCustomFavicon() !== null)) {
387+
if (($image === 'favicon-touch.png' || $image === 'favicon-fb.png') && ($this->imageManager->canConvert('PNG') || $this->getCustomFavicon() !== null)) {
388388
$route = $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', ['app' => $app]);
389389
}
390390
if ($image === 'manifest.json') {

0 commit comments

Comments
 (0)