Skip to content

Commit 08869d2

Browse files
fix: generate favourite icon without imagick svg support
Signed-off-by: SebastianKrupinski <[email protected]>
1 parent df8d838 commit 08869d2

25 files changed

+470
-187
lines changed

apps/theming/lib/Controller/IconController.php

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use OCP\AppFramework\Http\NotFoundResponse;
2222
use OCP\AppFramework\Http\Response;
2323
use OCP\Files\NotFoundException;
24+
use OCP\IConfig;
2425
use OCP\IRequest;
2526

2627
class IconController extends Controller {
@@ -30,6 +31,7 @@ class IconController extends Controller {
3031
public function __construct(
3132
$appName,
3233
IRequest $request,
34+
private IConfig $config,
3335
private ThemingDefaults $themingDefaults,
3436
private IconBuilder $iconBuilder,
3537
private ImageManager $imageManager,
@@ -79,7 +81,7 @@ public function getThemedIcon(string $app, string $image): Response {
7981
* Return a 32x32 favicon as png
8082
*
8183
* @param string $app ID of the app
82-
* @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
84+
* @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
8385
* @throws \Exception
8486
*
8587
* 200: Favicon returned
@@ -95,12 +97,14 @@ public function getFavicon(string $app = 'core'): Response {
9597

9698
$response = null;
9799
$iconFile = null;
100+
// retrieve instance favorite icon
98101
try {
99102
$iconFile = $this->imageManager->getImage('favicon', false);
100103
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
101104
} catch (NotFoundException $e) {
102105
}
103-
if ($iconFile === null && $this->imageManager->shouldReplaceIcons()) {
106+
// retrieve or generate app specific favorite icon
107+
if (($this->imageManager->canConvert('PNG') || $this->imageManager->canConvert('SVG')) && $this->imageManager->canConvert('ICO')) {
104108
$color = $this->themingDefaults->getColorPrimary();
105109
try {
106110
$iconFile = $this->imageManager->getCachedImage('favIcon-' . $app . $color);
@@ -113,9 +117,10 @@ public function getFavicon(string $app = 'core'): Response {
113117
}
114118
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
115119
}
120+
// fallback to core favorite icon
116121
if ($response === null) {
117122
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon.png';
118-
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
123+
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']);
119124
}
120125
$response->cacheFor(86400);
121126
return $response;
@@ -125,7 +130,7 @@ public function getFavicon(string $app = 'core'): Response {
125130
* Return a 512x512 icon for touch devices
126131
*
127132
* @param string $app ID of the app
128-
* @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/x-icon'|'image/png'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
133+
* @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
129134
* @throws \Exception
130135
*
131136
* 200: Touch icon returned
@@ -140,12 +145,14 @@ public function getTouchIcon(string $app = 'core'): Response {
140145
}
141146

142147
$response = null;
148+
// retrieve instance favorite icon
143149
try {
144150
$iconFile = $this->imageManager->getImage('favicon');
145-
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
151+
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => $iconFile->getMimeType()]);
146152
} catch (NotFoundException $e) {
147153
}
148-
if ($this->imageManager->shouldReplaceIcons()) {
154+
// retrieve or generate app specific touch icon
155+
if ($this->imageManager->canConvert('PNG')) {
149156
$color = $this->themingDefaults->getColorPrimary();
150157
try {
151158
$iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app . $color);
@@ -158,6 +165,7 @@ public function getTouchIcon(string $app = 'core'): Response {
158165
}
159166
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/png']);
160167
}
168+
// fallback to core touch icon
161169
if ($response === null) {
162170
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon-touch.png';
163171
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']);

apps/theming/lib/Controller/ThemingController.php

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ public function undoAll(): DataResponse {
345345
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
346346
public function getImage(string $key, bool $useSvg = true) {
347347
try {
348+
$useSvg = $useSvg && $this->imageManager->canConvert('SVG');
348349
$file = $this->imageManager->getImage($key, $useSvg);
349350
} catch (NotFoundException $e) {
350351
return new NotFoundResponse();
@@ -355,13 +356,8 @@ public function getImage(string $key, bool $useSvg = true) {
355356
$csp->allowInlineStyle();
356357
$response->setContentSecurityPolicy($csp);
357358
$response->cacheFor(3600);
358-
$response->addHeader('Content-Type', $this->config->getAppValue($this->appName, $key . 'Mime', ''));
359+
$response->addHeader('Content-Type', $file->getMimeType());
359360
$response->addHeader('Content-Disposition', 'attachment; filename="' . $key . '"');
360-
if (!$useSvg) {
361-
$response->addHeader('Content-Type', 'image/png');
362-
} else {
363-
$response->addHeader('Content-Type', $this->config->getAppValue($this->appName, $key . 'Mime', ''));
364-
}
365361
return $response;
366362
}
367363

apps/theming/lib/IconBuilder.php

Lines changed: 97 additions & 71 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,17 +31,18 @@ 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('PNG')) {
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
}
43-
$icon->setImageFormat('png32');
42+
$icon->setImageFormat('PNG32');
43+
44+
$favicon = new Imagick();
45+
$favicon->setFormat('ICO');
4446

4547
$clone = clone $icon;
4648
$clone->scaleImage(16, 0);
@@ -96,7 +98,9 @@ public function getTouchIcon($app) {
9698
* @return Imagick|false
9799
*/
98100
public function renderAppIcon($app, $size) {
99-
$appIcon = $this->util->getAppIcon($app);
101+
$supportSvg = $this->imageManager->canConvert('SVG');
102+
// retrieve app icon
103+
$appIcon = $this->util->getAppIcon($app, $supportSvg);
100104
if ($appIcon instanceof ISimpleFile) {
101105
$appIconContent = $appIcon->getContent();
102106
$mime = $appIcon->getMimeType();
@@ -111,79 +115,101 @@ public function renderAppIcon($app, $size) {
111115
return false;
112116
}
113117

114-
$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+
// if source image is svg but svg not supported, abort
122+
if ($appIconIsSvg && !$supportSvg) {
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+
// calculate final image size and position
160+
$padding = 0.85;
161+
$original_w = $appIconFile->getImageWidth();
162+
$original_h = $appIconFile->getImageHeight();
163+
$contentSize = (int)floor($size * $padding);
164+
$scale = min($contentSize / $original_w, $contentSize / $original_h);
165+
$new_w = max(1, (int)floor($original_w * $scale));
166+
$new_h = max(1, (int)floor($original_h * $scale));
167+
$offset_w = (int)floor(($size - $new_w) / 2);
168+
$offset_h = (int)floor(($size - $new_h) / 2);
169+
$cornerRadius = 0.2 * $size;
170+
$color = $this->themingDefaults->getColorPrimary();
171+
// resize original image
172+
$appIconFile->resizeImage($new_w, $new_h, Imagick::FILTER_LANCZOS, 1);
173+
/**
174+
* invert app icons for bright primary colors
175+
* the default nextcloud logo will not be inverted to black
176+
*/
177+
if ($this->util->isBrightColor($color)
178+
&& !$appIcon instanceof ISimpleFile
179+
&& $app !== 'core'
180+
) {
181+
$appIconFile->negateImage(false);
182+
}
183+
// construct final image object
184+
try {
185+
// image background
186+
$finalIconFile = new Imagick();
187+
$finalIconFile->setBackgroundColor(new ImagickPixel('transparent'));
188+
// icon background
189+
$finalIconFile->newImage($size, $size, new ImagickPixel('transparent'));
190+
$draw = new ImagickDraw();
191+
$draw->setFillColor($color);
192+
$draw->roundRectangle(0, 0, $size - 1, $size - 1, $cornerRadius, $cornerRadius);
193+
$finalIconFile->drawImage($draw);
194+
$draw->destroy();
195+
// overlay icon
196+
$finalIconFile->setImageVirtualPixelMethod(Imagick::VIRTUALPIXELMETHOD_TRANSPARENT);
197+
$finalIconFile->setImageArtifact('compose:args', '1,0,-0.5,0.5');
198+
$finalIconFile->compositeImage($appIconFile, Imagick::COMPOSITE_ATOP, $offset_w, $offset_h);
199+
$finalIconFile->setImageFormat('PNG32');
200+
if (defined('Imagick::INTERPOLATE_BICUBIC') === true) {
201+
$filter = Imagick::INTERPOLATE_BICUBIC;
202+
} else {
203+
$filter = Imagick::FILTER_LANCZOS;
204+
}
205+
$finalIconFile->resizeImage($size, $size, $filter, 1, false);
206+
207+
return $finalIconFile;
208+
} finally {
209+
unset($appIconFile);
182210
}
183-
$finalIconFile->resizeImage($size, $size, $filter, 1, false);
184211

185-
$appIconFile->destroy();
186-
return $finalIconFile;
212+
return false;
187213
}
188214

189215
/**

0 commit comments

Comments
 (0)