fix: generate favourite icon without imagick svg support
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
|
|
@ -21,6 +21,7 @@ use OCP\AppFramework\Http\FileDisplayResponse;
|
||||||
use OCP\AppFramework\Http\NotFoundResponse;
|
use OCP\AppFramework\Http\NotFoundResponse;
|
||||||
use OCP\AppFramework\Http\Response;
|
use OCP\AppFramework\Http\Response;
|
||||||
use OCP\Files\NotFoundException;
|
use OCP\Files\NotFoundException;
|
||||||
|
use OCP\IConfig;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
|
|
||||||
class IconController extends Controller {
|
class IconController extends Controller {
|
||||||
|
|
@ -30,6 +31,7 @@ class IconController extends Controller {
|
||||||
public function __construct(
|
public function __construct(
|
||||||
$appName,
|
$appName,
|
||||||
IRequest $request,
|
IRequest $request,
|
||||||
|
private IConfig $config,
|
||||||
private ThemingDefaults $themingDefaults,
|
private ThemingDefaults $themingDefaults,
|
||||||
private IconBuilder $iconBuilder,
|
private IconBuilder $iconBuilder,
|
||||||
private ImageManager $imageManager,
|
private ImageManager $imageManager,
|
||||||
|
|
@ -79,7 +81,7 @@ class IconController extends Controller {
|
||||||
* Return a 32x32 favicon as png
|
* Return a 32x32 favicon as png
|
||||||
*
|
*
|
||||||
* @param string $app ID of the app
|
* @param string $app ID of the app
|
||||||
* @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{}>
|
* @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{}>
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*
|
*
|
||||||
* 200: Favicon returned
|
* 200: Favicon returned
|
||||||
|
|
@ -95,12 +97,14 @@ class IconController extends Controller {
|
||||||
|
|
||||||
$response = null;
|
$response = null;
|
||||||
$iconFile = null;
|
$iconFile = null;
|
||||||
|
// retrieve instance favicon
|
||||||
try {
|
try {
|
||||||
$iconFile = $this->imageManager->getImage('favicon', false);
|
$iconFile = $this->imageManager->getImage('favicon', false);
|
||||||
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
|
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
|
||||||
} catch (NotFoundException $e) {
|
} catch (NotFoundException $e) {
|
||||||
}
|
}
|
||||||
if ($iconFile === null && $this->imageManager->shouldReplaceIcons()) {
|
// retrieve or generate app specific favicon
|
||||||
|
if (($this->imageManager->canConvert('PNG') || $this->imageManager->canConvert('SVG')) && $this->imageManager->canConvert('ICO')) {
|
||||||
$color = $this->themingDefaults->getColorPrimary();
|
$color = $this->themingDefaults->getColorPrimary();
|
||||||
try {
|
try {
|
||||||
$iconFile = $this->imageManager->getCachedImage('favIcon-' . $app . $color);
|
$iconFile = $this->imageManager->getCachedImage('favIcon-' . $app . $color);
|
||||||
|
|
@ -113,9 +117,10 @@ class IconController extends Controller {
|
||||||
}
|
}
|
||||||
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
|
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
|
||||||
}
|
}
|
||||||
|
// fallback to core favicon
|
||||||
if ($response === null) {
|
if ($response === null) {
|
||||||
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon.png';
|
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon.png';
|
||||||
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
|
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']);
|
||||||
}
|
}
|
||||||
$response->cacheFor(86400);
|
$response->cacheFor(86400);
|
||||||
return $response;
|
return $response;
|
||||||
|
|
@ -125,7 +130,7 @@ class IconController extends Controller {
|
||||||
* Return a 512x512 icon for touch devices
|
* Return a 512x512 icon for touch devices
|
||||||
*
|
*
|
||||||
* @param string $app ID of the app
|
* @param string $app ID of the app
|
||||||
* @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{}>
|
* @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'image/png'}>|FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}>
|
||||||
* @throws \Exception
|
* @throws \Exception
|
||||||
*
|
*
|
||||||
* 200: Touch icon returned
|
* 200: Touch icon returned
|
||||||
|
|
@ -140,12 +145,14 @@ class IconController extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = null;
|
$response = null;
|
||||||
|
// retrieve instance favicon
|
||||||
try {
|
try {
|
||||||
$iconFile = $this->imageManager->getImage('favicon');
|
$iconFile = $this->imageManager->getImage('favicon');
|
||||||
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
|
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => $iconFile->getMimeType()]);
|
||||||
} catch (NotFoundException $e) {
|
} catch (NotFoundException $e) {
|
||||||
}
|
}
|
||||||
if ($this->imageManager->shouldReplaceIcons()) {
|
// retrieve or generate app specific touch icon
|
||||||
|
if ($this->imageManager->canConvert('PNG')) {
|
||||||
$color = $this->themingDefaults->getColorPrimary();
|
$color = $this->themingDefaults->getColorPrimary();
|
||||||
try {
|
try {
|
||||||
$iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app . $color);
|
$iconFile = $this->imageManager->getCachedImage('touchIcon-' . $app . $color);
|
||||||
|
|
@ -158,6 +165,7 @@ class IconController extends Controller {
|
||||||
}
|
}
|
||||||
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/png']);
|
$response = new FileDisplayResponse($iconFile, Http::STATUS_OK, ['Content-Type' => 'image/png']);
|
||||||
}
|
}
|
||||||
|
// fallback to core touch icon
|
||||||
if ($response === null) {
|
if ($response === null) {
|
||||||
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon-touch.png';
|
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon-touch.png';
|
||||||
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']);
|
$response = new DataDisplayResponse($this->fileAccessHelper->file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']);
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,7 @@ class ThemingController extends Controller {
|
||||||
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
|
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)]
|
||||||
public function getImage(string $key, bool $useSvg = true) {
|
public function getImage(string $key, bool $useSvg = true) {
|
||||||
try {
|
try {
|
||||||
|
$useSvg = $useSvg && $this->imageManager->canConvert('SVG');
|
||||||
$file = $this->imageManager->getImage($key, $useSvg);
|
$file = $this->imageManager->getImage($key, $useSvg);
|
||||||
} catch (NotFoundException $e) {
|
} catch (NotFoundException $e) {
|
||||||
return new NotFoundResponse();
|
return new NotFoundResponse();
|
||||||
|
|
@ -376,13 +377,8 @@ class ThemingController extends Controller {
|
||||||
$csp->allowInlineStyle();
|
$csp->allowInlineStyle();
|
||||||
$response->setContentSecurityPolicy($csp);
|
$response->setContentSecurityPolicy($csp);
|
||||||
$response->cacheFor(3600);
|
$response->cacheFor(3600);
|
||||||
$response->addHeader('Content-Type', $this->config->getAppValue($this->appName, $key . 'Mime', ''));
|
$response->addHeader('Content-Type', $file->getMimeType());
|
||||||
$response->addHeader('Content-Disposition', 'attachment; filename="' . $key . '"');
|
$response->addHeader('Content-Disposition', 'attachment; filename="' . $key . '"');
|
||||||
if (!$useSvg) {
|
|
||||||
$response->addHeader('Content-Type', 'image/png');
|
|
||||||
} else {
|
|
||||||
$response->addHeader('Content-Type', $this->config->getAppValue($this->appName, $key . 'Mime', ''));
|
|
||||||
}
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
namespace OCA\Theming;
|
namespace OCA\Theming;
|
||||||
|
|
||||||
use Imagick;
|
use Imagick;
|
||||||
|
use ImagickDraw;
|
||||||
use ImagickPixel;
|
use ImagickPixel;
|
||||||
use OCP\Files\SimpleFS\ISimpleFile;
|
use OCP\Files\SimpleFS\ISimpleFile;
|
||||||
|
|
||||||
|
|
@ -30,17 +31,18 @@ class IconBuilder {
|
||||||
* @return string|false image blob
|
* @return string|false image blob
|
||||||
*/
|
*/
|
||||||
public function getFavicon($app) {
|
public function getFavicon($app) {
|
||||||
if (!$this->imageManager->shouldReplaceIcons()) {
|
if (!$this->imageManager->canConvert('PNG')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$favicon = new Imagick();
|
|
||||||
$favicon->setFormat('ico');
|
|
||||||
$icon = $this->renderAppIcon($app, 128);
|
$icon = $this->renderAppIcon($app, 128);
|
||||||
if ($icon === false) {
|
if ($icon === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$icon->setImageFormat('png32');
|
$icon->setImageFormat('PNG32');
|
||||||
|
|
||||||
|
$favicon = new Imagick();
|
||||||
|
$favicon->setFormat('ICO');
|
||||||
|
|
||||||
$clone = clone $icon;
|
$clone = clone $icon;
|
||||||
$clone->scaleImage(16, 0);
|
$clone->scaleImage(16, 0);
|
||||||
|
|
@ -96,7 +98,9 @@ class IconBuilder {
|
||||||
* @return Imagick|false
|
* @return Imagick|false
|
||||||
*/
|
*/
|
||||||
public function renderAppIcon($app, $size) {
|
public function renderAppIcon($app, $size) {
|
||||||
$appIcon = $this->util->getAppIcon($app);
|
$supportSvg = $this->imageManager->canConvert('SVG');
|
||||||
|
// retrieve app icon
|
||||||
|
$appIcon = $this->util->getAppIcon($app, $supportSvg);
|
||||||
if ($appIcon instanceof ISimpleFile) {
|
if ($appIcon instanceof ISimpleFile) {
|
||||||
$appIconContent = $appIcon->getContent();
|
$appIconContent = $appIcon->getContent();
|
||||||
$mime = $appIcon->getMimeType();
|
$mime = $appIcon->getMimeType();
|
||||||
|
|
@ -111,78 +115,100 @@ class IconBuilder {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$color = $this->themingDefaults->getColorPrimary();
|
$appIconIsSvg = ($mime === 'image/svg+xml' || str_starts_with($appIconContent, '<svg') || str_starts_with($appIconContent, '<?xml'));
|
||||||
|
// if source image is svg but svg not supported, abort.
|
||||||
|
// source images are both user and developer set, and there is guarantees that mime and extension match actual contents type
|
||||||
|
if ($appIconIsSvg && !$supportSvg) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// generate background image with rounded corners
|
// construct original image object
|
||||||
$cornerRadius = 0.2 * $size;
|
try {
|
||||||
$background = '<?xml version="1.0" encoding="UTF-8"?>'
|
$appIconFile = new Imagick();
|
||||||
. '<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">'
|
$appIconFile->setBackgroundColor(new ImagickPixel('transparent'));
|
||||||
. '<rect x="0" y="0" rx="' . $cornerRadius . '" ry="' . $cornerRadius . '" width="' . $size . '" height="' . $size . '" style="fill:' . $color . ';" />'
|
|
||||||
. '</svg>';
|
if ($appIconIsSvg) {
|
||||||
// resize svg magic as this seems broken in Imagemagick
|
// handle SVG images
|
||||||
if ($mime === 'image/svg+xml' || substr($appIconContent, 0, 4) === '<svg') {
|
// ensure proper XML declaration
|
||||||
if (substr($appIconContent, 0, 5) !== '<?xml') {
|
if (!str_starts_with($appIconContent, '<?xml')) {
|
||||||
$svg = '<?xml version="1.0"?>' . $appIconContent;
|
$svg = '<?xml version="1.0"?>' . $appIconContent;
|
||||||
|
} else {
|
||||||
|
$svg = $appIconContent;
|
||||||
|
}
|
||||||
|
// get dimensions for resolution calculation
|
||||||
|
$tmp = new Imagick();
|
||||||
|
$tmp->setBackgroundColor(new ImagickPixel('transparent'));
|
||||||
|
$tmp->setResolution(72, 72);
|
||||||
|
$tmp->readImageBlob($svg);
|
||||||
|
$x = $tmp->getImageWidth();
|
||||||
|
$y = $tmp->getImageHeight();
|
||||||
|
$tmp->destroy();
|
||||||
|
// set resolution for proper scaling
|
||||||
|
$resX = (int)(72 * $size / $x);
|
||||||
|
$resY = (int)(72 * $size / $y);
|
||||||
|
$appIconFile->setResolution($resX, $resY);
|
||||||
|
$appIconFile->readImageBlob($svg);
|
||||||
} else {
|
} else {
|
||||||
$svg = $appIconContent;
|
// handle non-SVG images
|
||||||
|
$appIconFile->readImageBlob($appIconContent);
|
||||||
}
|
}
|
||||||
$tmp = new Imagick();
|
} catch (\ImagickException $e) {
|
||||||
$tmp->setBackgroundColor(new ImagickPixel('transparent'));
|
return false;
|
||||||
$tmp->setResolution(72, 72);
|
}
|
||||||
$tmp->readImageBlob($svg);
|
// calculate final image size and position
|
||||||
$x = $tmp->getImageWidth();
|
$padding = 0.85;
|
||||||
$y = $tmp->getImageHeight();
|
$original_w = $appIconFile->getImageWidth();
|
||||||
$tmp->destroy();
|
$original_h = $appIconFile->getImageHeight();
|
||||||
|
$contentSize = (int)floor($size * $padding);
|
||||||
// convert svg to resized image
|
$scale = min($contentSize / $original_w, $contentSize / $original_h);
|
||||||
$appIconFile = new Imagick();
|
$new_w = max(1, (int)floor($original_w * $scale));
|
||||||
$res = (int)(72 * $size / max($x, $y));
|
$new_h = max(1, (int)floor($original_h * $scale));
|
||||||
$appIconFile->setResolution($res, $res);
|
$offset_w = (int)floor(($size - $new_w) / 2);
|
||||||
$appIconFile->setBackgroundColor(new ImagickPixel('transparent'));
|
$offset_h = (int)floor(($size - $new_h) / 2);
|
||||||
$appIconFile->readImageBlob($svg);
|
$cornerRadius = 0.2 * $size;
|
||||||
|
$color = $this->themingDefaults->getColorPrimary();
|
||||||
/**
|
// resize original image
|
||||||
* invert app icons for bright primary colors
|
$appIconFile->resizeImage($new_w, $new_h, Imagick::FILTER_LANCZOS, 1);
|
||||||
* the default nextcloud logo will not be inverted to black
|
/**
|
||||||
*/
|
* invert app icons for bright primary colors
|
||||||
if ($this->util->isBrightColor($color)
|
* the default nextcloud logo will not be inverted to black
|
||||||
&& !$appIcon instanceof ISimpleFile
|
*/
|
||||||
&& $app !== 'core'
|
if ($this->util->isBrightColor($color)
|
||||||
) {
|
&& !$appIcon instanceof ISimpleFile
|
||||||
$appIconFile->negateImage(false);
|
&& $app !== 'core'
|
||||||
|
) {
|
||||||
|
$appIconFile->negateImage(false);
|
||||||
|
}
|
||||||
|
// construct final image object
|
||||||
|
try {
|
||||||
|
// image background
|
||||||
|
$finalIconFile = new Imagick();
|
||||||
|
$finalIconFile->setBackgroundColor(new ImagickPixel('transparent'));
|
||||||
|
// icon background
|
||||||
|
$finalIconFile->newImage($size, $size, new ImagickPixel('transparent'));
|
||||||
|
$draw = new ImagickDraw();
|
||||||
|
$draw->setFillColor($color);
|
||||||
|
$draw->roundRectangle(0, 0, $size - 1, $size - 1, $cornerRadius, $cornerRadius);
|
||||||
|
$finalIconFile->drawImage($draw);
|
||||||
|
$draw->destroy();
|
||||||
|
// overlay icon
|
||||||
|
$finalIconFile->setImageVirtualPixelMethod(Imagick::VIRTUALPIXELMETHOD_TRANSPARENT);
|
||||||
|
$finalIconFile->setImageArtifact('compose:args', '1,0,-0.5,0.5');
|
||||||
|
$finalIconFile->compositeImage($appIconFile, Imagick::COMPOSITE_ATOP, $offset_w, $offset_h);
|
||||||
|
$finalIconFile->setImageFormat('PNG32');
|
||||||
|
if (defined('Imagick::INTERPOLATE_BICUBIC') === true) {
|
||||||
|
$filter = Imagick::INTERPOLATE_BICUBIC;
|
||||||
|
} else {
|
||||||
|
$filter = Imagick::FILTER_LANCZOS;
|
||||||
}
|
}
|
||||||
} else {
|
$finalIconFile->resizeImage($size, $size, $filter, 1, false);
|
||||||
$appIconFile = new Imagick();
|
|
||||||
$appIconFile->setBackgroundColor(new ImagickPixel('transparent'));
|
|
||||||
$appIconFile->readImageBlob($appIconContent);
|
|
||||||
}
|
|
||||||
// offset for icon positioning
|
|
||||||
$padding = 0.15;
|
|
||||||
$border_w = (int)($appIconFile->getImageWidth() * $padding);
|
|
||||||
$border_h = (int)($appIconFile->getImageHeight() * $padding);
|
|
||||||
$innerWidth = ($appIconFile->getImageWidth() - $border_w * 2);
|
|
||||||
$innerHeight = ($appIconFile->getImageHeight() - $border_h * 2);
|
|
||||||
$appIconFile->adaptiveResizeImage($innerWidth, $innerHeight);
|
|
||||||
// center icon
|
|
||||||
$offset_w = (int)($size / 2 - $innerWidth / 2);
|
|
||||||
$offset_h = (int)($size / 2 - $innerHeight / 2);
|
|
||||||
|
|
||||||
$finalIconFile = new Imagick();
|
return $finalIconFile;
|
||||||
$finalIconFile->setBackgroundColor(new ImagickPixel('transparent'));
|
} finally {
|
||||||
$finalIconFile->readImageBlob($background);
|
unset($appIconFile);
|
||||||
$finalIconFile->setImageVirtualPixelMethod(Imagick::VIRTUALPIXELMETHOD_TRANSPARENT);
|
|
||||||
$finalIconFile->setImageArtifact('compose:args', '1,0,-0.5,0.5');
|
|
||||||
$finalIconFile->compositeImage($appIconFile, Imagick::COMPOSITE_ATOP, $offset_w, $offset_h);
|
|
||||||
$finalIconFile->setImageFormat('png24');
|
|
||||||
if (defined('Imagick::INTERPOLATE_BICUBIC') === true) {
|
|
||||||
$filter = Imagick::INTERPOLATE_BICUBIC;
|
|
||||||
} else {
|
|
||||||
$filter = Imagick::FILTER_LANCZOS;
|
|
||||||
}
|
}
|
||||||
$finalIconFile->resizeImage($size, $size, $filter, 1, false);
|
|
||||||
|
|
||||||
$appIconFile->destroy();
|
return false;
|
||||||
return $finalIconFile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -85,18 +85,37 @@ class ImageManager {
|
||||||
public function getImage(string $key, bool $useSvg = true): ISimpleFile {
|
public function getImage(string $key, bool $useSvg = true): ISimpleFile {
|
||||||
$mime = $this->config->getAppValue('theming', $key . 'Mime', '');
|
$mime = $this->config->getAppValue('theming', $key . 'Mime', '');
|
||||||
$folder = $this->getRootFolder()->getFolder('images');
|
$folder = $this->getRootFolder()->getFolder('images');
|
||||||
|
$useSvg = $useSvg && $this->canConvert('SVG');
|
||||||
|
|
||||||
if ($mime === '' || !$folder->fileExists($key)) {
|
if ($mime === '' || !$folder->fileExists($key)) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
// if SVG was requested and is supported
|
||||||
if (!$useSvg && $this->shouldReplaceIcons()) {
|
if ($useSvg) {
|
||||||
|
if (!$folder->fileExists($key . '.svg')) {
|
||||||
|
try {
|
||||||
|
$finalIconFile = new \Imagick();
|
||||||
|
$finalIconFile->setBackgroundColor('none');
|
||||||
|
$finalIconFile->readImageBlob($folder->getFile($key)->getContent());
|
||||||
|
$finalIconFile->setImageFormat('SVG');
|
||||||
|
$svgFile = $folder->newFile($key . '.svg');
|
||||||
|
$svgFile->putContent($finalIconFile->getImageBlob());
|
||||||
|
return $svgFile;
|
||||||
|
} catch (\ImagickException $e) {
|
||||||
|
$this->logger->info('The image was requested to be no SVG file, but converting it to SVG failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return $folder->getFile($key . '.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if SVG was not requested, but PNG is supported
|
||||||
|
if (!$useSvg && $this->canConvert('PNG')) {
|
||||||
if (!$folder->fileExists($key . '.png')) {
|
if (!$folder->fileExists($key . '.png')) {
|
||||||
try {
|
try {
|
||||||
$finalIconFile = new \Imagick();
|
$finalIconFile = new \Imagick();
|
||||||
$finalIconFile->setBackgroundColor('none');
|
$finalIconFile->setBackgroundColor('none');
|
||||||
$finalIconFile->readImageBlob($folder->getFile($key)->getContent());
|
$finalIconFile->readImageBlob($folder->getFile($key)->getContent());
|
||||||
$finalIconFile->setImageFormat('png32');
|
$finalIconFile->setImageFormat('PNG32');
|
||||||
$pngFile = $folder->newFile($key . '.png');
|
$pngFile = $folder->newFile($key . '.png');
|
||||||
$pngFile->putContent($finalIconFile->getImageBlob());
|
$pngFile->putContent($finalIconFile->getImageBlob());
|
||||||
return $pngFile;
|
return $pngFile;
|
||||||
|
|
@ -107,7 +126,7 @@ class ImageManager {
|
||||||
return $folder->getFile($key . '.png');
|
return $folder->getFile($key . '.png');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// fallback to the original file
|
||||||
return $folder->getFile($key);
|
return $folder->getFile($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,7 +347,7 @@ class ImageManager {
|
||||||
public function getSupportedUploadImageFormats(string $key): array {
|
public function getSupportedUploadImageFormats(string $key): array {
|
||||||
$supportedFormats = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
$supportedFormats = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
if ($key !== 'favicon' || $this->shouldReplaceIcons() === true) {
|
if ($key !== 'favicon' || $this->canConvert('SVG') === true) {
|
||||||
$supportedFormats[] = 'image/svg+xml';
|
$supportedFormats[] = 'image/svg+xml';
|
||||||
$supportedFormats[] = 'image/svg';
|
$supportedFormats[] = 'image/svg';
|
||||||
}
|
}
|
||||||
|
|
@ -364,17 +383,26 @@ class ImageManager {
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function shouldReplaceIcons() {
|
public function shouldReplaceIcons() {
|
||||||
|
return $this->canConvert('SVG');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Imagemagick is enabled and if format is supported
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function canConvert(string $format): bool {
|
||||||
$cache = $this->cacheFactory->createDistributed('theming-' . $this->urlGenerator->getBaseUrl());
|
$cache = $this->cacheFactory->createDistributed('theming-' . $this->urlGenerator->getBaseUrl());
|
||||||
if ($value = $cache->get('shouldReplaceIcons')) {
|
if ($value = $cache->get('convert-' . $format)) {
|
||||||
return (bool)$value;
|
return (bool)$value;
|
||||||
}
|
}
|
||||||
$value = false;
|
$value = false;
|
||||||
if (extension_loaded('imagick')) {
|
if (extension_loaded('imagick')) {
|
||||||
if (count(\Imagick::queryFormats('SVG')) >= 1) {
|
if (count(\Imagick::queryFormats($format)) >= 1) {
|
||||||
$value = true;
|
$value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$cache->set('shouldReplaceIcons', $value);
|
$cache->set('convert-' . $format, $value);
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -379,10 +379,10 @@ class ThemingDefaults extends \OC_Defaults {
|
||||||
}
|
}
|
||||||
|
|
||||||
$route = false;
|
$route = false;
|
||||||
if ($image === 'favicon.ico' && ($this->imageManager->shouldReplaceIcons() || $this->getCustomFavicon() !== null)) {
|
if ($image === 'favicon.ico' && ($this->imageManager->canConvert('ICO') || $this->getCustomFavicon() !== null)) {
|
||||||
$route = $this->urlGenerator->linkToRoute('theming.Icon.getFavicon', ['app' => $app]);
|
$route = $this->urlGenerator->linkToRoute('theming.Icon.getFavicon', ['app' => $app]);
|
||||||
}
|
}
|
||||||
if (($image === 'favicon-touch.png' || $image === 'favicon-fb.png') && ($this->imageManager->shouldReplaceIcons() || $this->getCustomFavicon() !== null)) {
|
if (($image === 'favicon-touch.png' || $image === 'favicon-fb.png') && ($this->imageManager->canConvert('PNG') || $this->getCustomFavicon() !== null)) {
|
||||||
$route = $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', ['app' => $app]);
|
$route = $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', ['app' => $app]);
|
||||||
}
|
}
|
||||||
if ($image === 'manifest.json') {
|
if ($image === 'manifest.json') {
|
||||||
|
|
|
||||||
|
|
@ -206,30 +206,38 @@ class Util {
|
||||||
* @param string $app app name
|
* @param string $app app name
|
||||||
* @return string|ISimpleFile path to app icon / file of logo
|
* @return string|ISimpleFile path to app icon / file of logo
|
||||||
*/
|
*/
|
||||||
public function getAppIcon($app) {
|
public function getAppIcon($app, $useSvg = true) {
|
||||||
$app = $this->appManager->cleanAppId($app);
|
$app = $this->appManager->cleanAppId($app);
|
||||||
try {
|
try {
|
||||||
|
// find app specific icon
|
||||||
$appPath = $this->appManager->getAppPath($app);
|
$appPath = $this->appManager->getAppPath($app);
|
||||||
$icon = $appPath . '/img/' . $app . '.svg';
|
$extension = ($useSvg ? '.svg' : '.png');
|
||||||
|
|
||||||
|
$icon = $appPath . '/img/' . $app . $extension;
|
||||||
if (file_exists($icon)) {
|
if (file_exists($icon)) {
|
||||||
return $icon;
|
return $icon;
|
||||||
}
|
}
|
||||||
$icon = $appPath . '/img/app.svg';
|
|
||||||
|
$icon = $appPath . '/img/app' . $extension;
|
||||||
if (file_exists($icon)) {
|
if (file_exists($icon)) {
|
||||||
return $icon;
|
return $icon;
|
||||||
}
|
}
|
||||||
} catch (AppPathNotFoundException $e) {
|
} catch (AppPathNotFoundException $e) {
|
||||||
}
|
}
|
||||||
|
// fallback to custom instance logo
|
||||||
if ($this->config->getAppValue('theming', 'logoMime', '') !== '') {
|
if ($this->config->getAppValue('theming', 'logoMime', '') !== '') {
|
||||||
$logoFile = null;
|
|
||||||
try {
|
try {
|
||||||
$folder = $this->appData->getFolder('global/images');
|
$folder = $this->appData->getFolder('global/images');
|
||||||
return $folder->getFile('logo');
|
return $folder->getFile('logo');
|
||||||
} catch (NotFoundException $e) {
|
} catch (NotFoundException $e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return \OC::$SERVERROOT . '/core/img/logo/logo.svg';
|
// fallback to core logo
|
||||||
|
if ($useSvg) {
|
||||||
|
return \OC::$SERVERROOT . '/core/img/logo/logo.svg';
|
||||||
|
} else {
|
||||||
|
return \OC::$SERVERROOT . '/core/img/logo/logo.png';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,12 @@
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Favicon returned",
|
"description": "Favicon returned",
|
||||||
"content": {
|
"content": {
|
||||||
|
"image/png": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
},
|
||||||
"image/x-icon": {
|
"image/x-icon": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -506,7 +512,7 @@
|
||||||
"format": "binary"
|
"format": "binary"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"image/x-icon": {
|
"*/*": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "binary"
|
"format": "binary"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ use OCP\AppFramework\Http\FileDisplayResponse;
|
||||||
use OCP\AppFramework\Utility\ITimeFactory;
|
use OCP\AppFramework\Utility\ITimeFactory;
|
||||||
use OCP\Files\File;
|
use OCP\Files\File;
|
||||||
use OCP\Files\NotFoundException;
|
use OCP\Files\NotFoundException;
|
||||||
|
use OCP\IConfig;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use Test\TestCase;
|
use Test\TestCase;
|
||||||
|
|
@ -33,6 +34,7 @@ class IconControllerTest extends TestCase {
|
||||||
private IAppManager&MockObject $appManager;
|
private IAppManager&MockObject $appManager;
|
||||||
private ImageManager&MockObject $imageManager;
|
private ImageManager&MockObject $imageManager;
|
||||||
private IconController $iconController;
|
private IconController $iconController;
|
||||||
|
private IConfig&MockObject $config;
|
||||||
|
|
||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
$this->request = $this->createMock(IRequest::class);
|
$this->request = $this->createMock(IRequest::class);
|
||||||
|
|
@ -41,6 +43,7 @@ class IconControllerTest extends TestCase {
|
||||||
$this->imageManager = $this->createMock(ImageManager::class);
|
$this->imageManager = $this->createMock(ImageManager::class);
|
||||||
$this->fileAccessHelper = $this->createMock(FileAccessHelper::class);
|
$this->fileAccessHelper = $this->createMock(FileAccessHelper::class);
|
||||||
$this->appManager = $this->createMock(IAppManager::class);
|
$this->appManager = $this->createMock(IAppManager::class);
|
||||||
|
$this->config = $this->createMock(IConfig::class);
|
||||||
|
|
||||||
$this->timeFactory = $this->createMock(ITimeFactory::class);
|
$this->timeFactory = $this->createMock(ITimeFactory::class);
|
||||||
$this->timeFactory->expects($this->any())
|
$this->timeFactory->expects($this->any())
|
||||||
|
|
@ -52,6 +55,7 @@ class IconControllerTest extends TestCase {
|
||||||
$this->iconController = new IconController(
|
$this->iconController = new IconController(
|
||||||
'theming',
|
'theming',
|
||||||
$this->request,
|
$this->request,
|
||||||
|
$this->config,
|
||||||
$this->themingDefaults,
|
$this->themingDefaults,
|
||||||
$this->iconBuilder,
|
$this->iconBuilder,
|
||||||
$this->imageManager,
|
$this->imageManager,
|
||||||
|
|
@ -84,7 +88,7 @@ class IconControllerTest extends TestCase {
|
||||||
$this->assertEquals($expected, $this->iconController->getThemedIcon('core', 'filetypes/folder.svg'));
|
$this->assertEquals($expected, $this->iconController->getThemedIcon('core', 'filetypes/folder.svg'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetFaviconDefault(): void {
|
public function testGetFaviconThemed(): void {
|
||||||
if (!extension_loaded('imagick')) {
|
if (!extension_loaded('imagick')) {
|
||||||
$this->markTestSkipped('Imagemagick is required for dynamic icon generation.');
|
$this->markTestSkipped('Imagemagick is required for dynamic icon generation.');
|
||||||
}
|
}
|
||||||
|
|
@ -98,8 +102,12 @@ class IconControllerTest extends TestCase {
|
||||||
->with('favicon')
|
->with('favicon')
|
||||||
->willThrowException(new NotFoundException());
|
->willThrowException(new NotFoundException());
|
||||||
$this->imageManager->expects($this->any())
|
$this->imageManager->expects($this->any())
|
||||||
->method('shouldReplaceIcons')
|
->method('canConvert')
|
||||||
->willReturn(true);
|
->willReturnMap([
|
||||||
|
['SVG', true],
|
||||||
|
['PNG', true],
|
||||||
|
['ICO', true],
|
||||||
|
]);
|
||||||
$this->imageManager->expects($this->once())
|
$this->imageManager->expects($this->once())
|
||||||
->method('getCachedImage')
|
->method('getCachedImage')
|
||||||
->willThrowException(new NotFoundException());
|
->willThrowException(new NotFoundException());
|
||||||
|
|
@ -116,20 +124,24 @@ class IconControllerTest extends TestCase {
|
||||||
$this->assertEquals($expected, $this->iconController->getFavicon());
|
$this->assertEquals($expected, $this->iconController->getFavicon());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetFaviconFail(): void {
|
public function testGetFaviconDefault(): void {
|
||||||
$this->imageManager->expects($this->once())
|
$this->imageManager->expects($this->once())
|
||||||
->method('getImage')
|
->method('getImage')
|
||||||
->with('favicon', false)
|
->with('favicon', false)
|
||||||
->willThrowException(new NotFoundException());
|
->willThrowException(new NotFoundException());
|
||||||
$this->imageManager->expects($this->any())
|
$this->imageManager->expects($this->any())
|
||||||
->method('shouldReplaceIcons')
|
->method('canConvert')
|
||||||
->willReturn(false);
|
->willReturnMap([
|
||||||
|
['SVG', false],
|
||||||
|
['PNG', false],
|
||||||
|
['ICO', false],
|
||||||
|
]);
|
||||||
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon.png';
|
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon.png';
|
||||||
$this->fileAccessHelper->expects($this->once())
|
$this->fileAccessHelper->expects($this->once())
|
||||||
->method('file_get_contents')
|
->method('file_get_contents')
|
||||||
->with($fallbackLogo)
|
->with($fallbackLogo)
|
||||||
->willReturn(file_get_contents($fallbackLogo));
|
->willReturn(file_get_contents($fallbackLogo));
|
||||||
$expected = new DataDisplayResponse(file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/x-icon']);
|
$expected = new DataDisplayResponse(file_get_contents($fallbackLogo), Http::STATUS_OK, ['Content-Type' => 'image/png']);
|
||||||
$expected->cacheFor(86400);
|
$expected->cacheFor(86400);
|
||||||
$this->assertEquals($expected, $this->iconController->getFavicon());
|
$this->assertEquals($expected, $this->iconController->getFavicon());
|
||||||
}
|
}
|
||||||
|
|
@ -147,7 +159,8 @@ class IconControllerTest extends TestCase {
|
||||||
->method('getImage')
|
->method('getImage')
|
||||||
->willThrowException(new NotFoundException());
|
->willThrowException(new NotFoundException());
|
||||||
$this->imageManager->expects($this->any())
|
$this->imageManager->expects($this->any())
|
||||||
->method('shouldReplaceIcons')
|
->method('canConvert')
|
||||||
|
->with('PNG')
|
||||||
->willReturn(true);
|
->willReturn(true);
|
||||||
$this->iconBuilder->expects($this->once())
|
$this->iconBuilder->expects($this->once())
|
||||||
->method('getTouchIcon')
|
->method('getTouchIcon')
|
||||||
|
|
@ -172,7 +185,8 @@ class IconControllerTest extends TestCase {
|
||||||
->with('favicon')
|
->with('favicon')
|
||||||
->willThrowException(new NotFoundException());
|
->willThrowException(new NotFoundException());
|
||||||
$this->imageManager->expects($this->any())
|
$this->imageManager->expects($this->any())
|
||||||
->method('shouldReplaceIcons')
|
->method('canConvert')
|
||||||
|
->with('PNG')
|
||||||
->willReturn(false);
|
->willReturn(false);
|
||||||
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon-touch.png';
|
$fallbackLogo = \OC::$SERVERROOT . '/core/img/favicon-touch.png';
|
||||||
$this->fileAccessHelper->expects($this->once())
|
$this->fileAccessHelper->expects($this->once())
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,7 @@ class ThemingControllerTest extends TestCase {
|
||||||
$file = $this->createMock(ISimpleFile::class);
|
$file = $this->createMock(ISimpleFile::class);
|
||||||
$file->method('getName')->willReturn('logo.svg');
|
$file->method('getName')->willReturn('logo.svg');
|
||||||
$file->method('getMTime')->willReturn(42);
|
$file->method('getMTime')->willReturn(42);
|
||||||
|
$file->method('getMimeType')->willReturn('text/svg');
|
||||||
$this->imageManager->expects($this->once())
|
$this->imageManager->expects($this->once())
|
||||||
->method('getImage')
|
->method('getImage')
|
||||||
->willReturn($file);
|
->willReturn($file);
|
||||||
|
|
@ -664,7 +665,7 @@ class ThemingControllerTest extends TestCase {
|
||||||
$csp = new ContentSecurityPolicy();
|
$csp = new ContentSecurityPolicy();
|
||||||
$csp->allowInlineStyle();
|
$csp->allowInlineStyle();
|
||||||
$expected->setContentSecurityPolicy($csp);
|
$expected->setContentSecurityPolicy($csp);
|
||||||
@$this->assertEquals($expected, $this->themingController->getImage('logo'));
|
@$this->assertEquals($expected, $this->themingController->getImage('logo', true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -680,6 +681,7 @@ class ThemingControllerTest extends TestCase {
|
||||||
$file = $this->createMock(ISimpleFile::class);
|
$file = $this->createMock(ISimpleFile::class);
|
||||||
$file->method('getName')->willReturn('background.png');
|
$file->method('getName')->willReturn('background.png');
|
||||||
$file->method('getMTime')->willReturn(42);
|
$file->method('getMTime')->willReturn(42);
|
||||||
|
$file->method('getMimeType')->willReturn('image/png');
|
||||||
$this->imageManager->expects($this->once())
|
$this->imageManager->expects($this->once())
|
||||||
->method('getImage')
|
->method('getImage')
|
||||||
->willReturn($file);
|
->willReturn($file);
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@ use OCA\Theming\ImageManager;
|
||||||
use OCA\Theming\ThemingDefaults;
|
use OCA\Theming\ThemingDefaults;
|
||||||
use OCA\Theming\Util;
|
use OCA\Theming\Util;
|
||||||
use OCP\App\IAppManager;
|
use OCP\App\IAppManager;
|
||||||
use OCP\Files\NotFoundException;
|
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
use OCP\ServerVersion;
|
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use Test\TestCase;
|
use Test\TestCase;
|
||||||
|
|
||||||
|
|
@ -25,7 +23,7 @@ class IconBuilderTest extends TestCase {
|
||||||
protected ThemingDefaults&MockObject $themingDefaults;
|
protected ThemingDefaults&MockObject $themingDefaults;
|
||||||
protected ImageManager&MockObject $imageManager;
|
protected ImageManager&MockObject $imageManager;
|
||||||
protected IAppManager&MockObject $appManager;
|
protected IAppManager&MockObject $appManager;
|
||||||
protected Util $util;
|
protected Util&MockObject $util;
|
||||||
protected IconBuilder $iconBuilder;
|
protected IconBuilder $iconBuilder;
|
||||||
|
|
||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
|
|
@ -36,123 +34,228 @@ class IconBuilderTest extends TestCase {
|
||||||
$this->themingDefaults = $this->createMock(ThemingDefaults::class);
|
$this->themingDefaults = $this->createMock(ThemingDefaults::class);
|
||||||
$this->appManager = $this->createMock(IAppManager::class);
|
$this->appManager = $this->createMock(IAppManager::class);
|
||||||
$this->imageManager = $this->createMock(ImageManager::class);
|
$this->imageManager = $this->createMock(ImageManager::class);
|
||||||
$this->util = new Util($this->createMock(ServerVersion::class), $this->config, $this->appManager, $this->appData, $this->imageManager);
|
$this->util = $this->createMock(Util::class);
|
||||||
$this->iconBuilder = new IconBuilder($this->themingDefaults, $this->util, $this->imageManager);
|
$this->iconBuilder = new IconBuilder($this->themingDefaults, $this->util, $this->imageManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checkImagick() {
|
/**
|
||||||
|
* Checks if Imagick and the required format are available.
|
||||||
|
* If provider is null, only checks for Imagick extension.
|
||||||
|
*/
|
||||||
|
private function checkImagick(?string $provider = null) {
|
||||||
if (!extension_loaded('imagick')) {
|
if (!extension_loaded('imagick')) {
|
||||||
$this->markTestSkipped('Imagemagick is required for dynamic icon generation.');
|
$this->markTestSkipped('Imagemagick is required for dynamic icon generation.');
|
||||||
}
|
}
|
||||||
$checkImagick = new \Imagick();
|
if ($provider !== null) {
|
||||||
if (count($checkImagick->queryFormats('SVG')) < 1) {
|
$checkImagick = new \Imagick();
|
||||||
$this->markTestSkipped('No SVG provider present.');
|
if (count($checkImagick->queryFormats($provider)) < 1) {
|
||||||
}
|
$this->markTestSkipped('Imagemagick ' . $provider . ' support is required for this icon generation test.');
|
||||||
if (count($checkImagick->queryFormats('PNG')) < 1) {
|
}
|
||||||
$this->markTestSkipped('No PNG provider present.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function dataRenderAppIcon(): array {
|
/**
|
||||||
|
* Data provider for app icon rendering tests (SVG only).
|
||||||
|
*/
|
||||||
|
public static function dataRenderAppIconSvg(): array {
|
||||||
return [
|
return [
|
||||||
['core', '#0082c9', 'touch-original.png'],
|
['logo', '#0082c9', 'logo.svg'],
|
||||||
['core', '#FF0000', 'touch-core-red.png'],
|
['settings', '#FF0000', 'settings.svg'],
|
||||||
['testing', '#FF0000', 'touch-testing-red.png'],
|
|
||||||
['comments', '#0082c9', 'touch-comments.png'],
|
|
||||||
['core', '#0082c9', 'touch-original-png.png'],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataRenderAppIcon')]
|
/**
|
||||||
public function testRenderAppIcon(string $app, string $color, string $file): void {
|
* Data provider for app icon rendering tests (PNG only).
|
||||||
$this->checkImagick();
|
*/
|
||||||
$this->themingDefaults->expects($this->once())
|
public static function dataRenderAppIconPng(): array {
|
||||||
|
return [
|
||||||
|
['logo', '#0082c9', 'logo.png'],
|
||||||
|
['settings', '#FF0000', 'settings.png'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\PHPUnit\Framework\Attributes\DataProvider('dataRenderAppIconSvg')]
|
||||||
|
public function testRenderAppIconSvg(string $app, string $color, string $file): void {
|
||||||
|
$this->checkImagick('SVG');
|
||||||
|
// mock required methods
|
||||||
|
$this->imageManager->expects($this->any())
|
||||||
|
->method('canConvert')
|
||||||
|
->willReturnMap([
|
||||||
|
['SVG', true],
|
||||||
|
['PNG', true]
|
||||||
|
]);
|
||||||
|
$this->util->expects($this->once())
|
||||||
|
->method('getAppIcon')
|
||||||
|
->with($app, true)
|
||||||
|
->willReturn(__DIR__ . '/data/' . $file);
|
||||||
|
$this->themingDefaults->expects($this->any())
|
||||||
->method('getColorPrimary')
|
->method('getColorPrimary')
|
||||||
->willReturn($color);
|
->willReturn($color);
|
||||||
$this->appData->expects($this->once())
|
// generate expected output from source file
|
||||||
->method('getFolder')
|
$expectedIcon = $this->generateTestIcon($file, 'SVG', 512, $color);
|
||||||
->with('global/images')
|
// run test
|
||||||
->willThrowException(new NotFoundException());
|
|
||||||
|
|
||||||
$expectedIcon = new \Imagick(realpath(__DIR__) . '/data/' . $file);
|
|
||||||
$icon = $this->iconBuilder->renderAppIcon($app, 512);
|
$icon = $this->iconBuilder->renderAppIcon($app, 512);
|
||||||
|
|
||||||
$this->assertEquals(true, $icon->valid());
|
$this->assertEquals(true, $icon->valid());
|
||||||
$this->assertEquals(512, $icon->getImageWidth());
|
$this->assertEquals(512, $icon->getImageWidth());
|
||||||
$this->assertEquals(512, $icon->getImageHeight());
|
$this->assertEquals(512, $icon->getImageHeight());
|
||||||
$this->assertEquals($icon, $expectedIcon);
|
$icon->setImageFormat('SVG');
|
||||||
|
$expectedIcon->setImageFormat('SVG');
|
||||||
|
$this->assertEquals($expectedIcon->getImageBlob(), $icon->getImageBlob(), 'Generated icon differs from expected');
|
||||||
$icon->destroy();
|
$icon->destroy();
|
||||||
$expectedIcon->destroy();
|
$expectedIcon->destroy();
|
||||||
// FIXME: We may need some comparison of the generated and the test images
|
|
||||||
// cloud be something like $expectedIcon->compareImages($icon, Imagick::METRIC_MEANABSOLUTEERROR)[1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataRenderAppIcon')]
|
#[\PHPUnit\Framework\Attributes\DataProvider('dataRenderAppIconPng')]
|
||||||
public function testGetTouchIcon(string $app, string $color, string $file): void {
|
public function testRenderAppIconPng(string $app, string $color, string $file): void {
|
||||||
$this->checkImagick();
|
$this->checkImagick('PNG');
|
||||||
$this->themingDefaults->expects($this->once())
|
// mock required methods
|
||||||
|
$this->imageManager->expects($this->any())
|
||||||
|
->method('canConvert')
|
||||||
|
->willReturnMap([
|
||||||
|
['SVG', false],
|
||||||
|
['PNG', true]
|
||||||
|
]);
|
||||||
|
$this->util->expects($this->once())
|
||||||
|
->method('getAppIcon')
|
||||||
|
->with($app, false)
|
||||||
|
->willReturn(__DIR__ . '/data/' . $file);
|
||||||
|
$this->themingDefaults->expects($this->any())
|
||||||
->method('getColorPrimary')
|
->method('getColorPrimary')
|
||||||
->willReturn($color);
|
->willReturn($color);
|
||||||
$this->appData->expects($this->once())
|
// generate expected output from source file
|
||||||
->method('getFolder')
|
$expectedIcon = $this->generateTestIcon($file, 'PNG', 512, $color);
|
||||||
->with('global/images')
|
// run test
|
||||||
->willThrowException(new NotFoundException());
|
$icon = $this->iconBuilder->renderAppIcon($app, 512);
|
||||||
|
|
||||||
$expectedIcon = new \Imagick(realpath(__DIR__) . '/data/' . $file);
|
|
||||||
$icon = new \Imagick();
|
|
||||||
$icon->readImageBlob($this->iconBuilder->getTouchIcon($app));
|
|
||||||
|
|
||||||
$this->assertEquals(true, $icon->valid());
|
$this->assertEquals(true, $icon->valid());
|
||||||
$this->assertEquals(512, $icon->getImageWidth());
|
$this->assertEquals(512, $icon->getImageWidth());
|
||||||
$this->assertEquals(512, $icon->getImageHeight());
|
$this->assertEquals(512, $icon->getImageHeight());
|
||||||
$this->assertEquals($icon, $expectedIcon);
|
$icon->setImageFormat('PNG');
|
||||||
|
$expectedIcon->setImageFormat('PNG');
|
||||||
|
$this->assertEquals($expectedIcon->getImageBlob(), $icon->getImageBlob(), 'Generated icon differs from expected');
|
||||||
$icon->destroy();
|
$icon->destroy();
|
||||||
$expectedIcon->destroy();
|
$expectedIcon->destroy();
|
||||||
// FIXME: We may need some comparison of the generated and the test images
|
|
||||||
// cloud be something like $expectedIcon->compareImages($icon, Imagick::METRIC_MEANABSOLUTEERROR)[1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataRenderAppIcon')]
|
#[\PHPUnit\Framework\Attributes\DataProvider('dataRenderAppIconSvg')]
|
||||||
public function testGetFavicon(string $app, string $color, string $file): void {
|
public function testGetTouchIconSvg(string $app, string $color, string $file): void {
|
||||||
$this->checkImagick();
|
$this->checkImagick('SVG');
|
||||||
$this->imageManager->expects($this->once())
|
// mock required methods
|
||||||
->method('shouldReplaceIcons')
|
$this->imageManager->expects($this->any())
|
||||||
->willReturn(true);
|
->method('canConvert')
|
||||||
$this->themingDefaults->expects($this->once())
|
->willReturnMap([
|
||||||
|
['SVG', true],
|
||||||
|
['PNG', true]
|
||||||
|
]);
|
||||||
|
$this->util->expects($this->once())
|
||||||
|
->method('getAppIcon')
|
||||||
|
->with($app, true)
|
||||||
|
->willReturn(__DIR__ . '/data/' . $file);
|
||||||
|
$this->themingDefaults->expects($this->any())
|
||||||
->method('getColorPrimary')
|
->method('getColorPrimary')
|
||||||
->willReturn($color);
|
->willReturn($color);
|
||||||
$this->appData->expects($this->once())
|
// generate expected output from source file
|
||||||
->method('getFolder')
|
$expectedIcon = $this->generateTestIcon($file, 'SVG', 512, $color);
|
||||||
->with('global/images')
|
$expectedIcon->setImageFormat('PNG32');
|
||||||
->willThrowException(new NotFoundException());
|
// run test
|
||||||
|
$result = $this->iconBuilder->getTouchIcon($app);
|
||||||
$expectedIcon = new \Imagick(realpath(__DIR__) . '/data/' . $file);
|
$this->assertIsString($result, 'Touch icon generation should return a PNG blob');
|
||||||
$actualIcon = $this->iconBuilder->getFavicon($app);
|
$this->assertEquals($expectedIcon->getImageBlob(), $result, 'Generated touch icon differs from expected');
|
||||||
|
$expectedIcon->destroy();
|
||||||
$icon = new \Imagick();
|
}
|
||||||
$icon->setFormat('ico');
|
|
||||||
$icon->readImageBlob($actualIcon);
|
#[\PHPUnit\Framework\Attributes\DataProvider('dataRenderAppIconPng')]
|
||||||
|
public function testGetTouchIconPng(string $app, string $color, string $file): void {
|
||||||
$this->assertEquals(true, $icon->valid());
|
$this->checkImagick('PNG');
|
||||||
$this->assertEquals(128, $icon->getImageWidth());
|
// mock required methods
|
||||||
$this->assertEquals(128, $icon->getImageHeight());
|
$this->imageManager->expects($this->any())
|
||||||
$icon->destroy();
|
->method('canConvert')
|
||||||
|
->willReturnMap([
|
||||||
|
['SVG', false],
|
||||||
|
['PNG', true]
|
||||||
|
]);
|
||||||
|
$this->util->expects($this->once())
|
||||||
|
->method('getAppIcon')
|
||||||
|
->with($app, false)
|
||||||
|
->willReturn(__DIR__ . '/data/' . $file);
|
||||||
|
$this->themingDefaults->expects($this->any())
|
||||||
|
->method('getColorPrimary')
|
||||||
|
->willReturn($color);
|
||||||
|
// generate expected output from source file
|
||||||
|
$expectedIcon = $this->generateTestIcon($file, 'PNG', 512, $color);
|
||||||
|
$expectedIcon->setImageFormat('PNG32');
|
||||||
|
// run test
|
||||||
|
$result = $this->iconBuilder->getTouchIcon($app);
|
||||||
|
$this->assertIsString($result, 'Touch icon generation should return a PNG blob');
|
||||||
|
$this->assertEquals($expectedIcon->getImageBlob(), $result, 'Generated touch icon differs from expected');
|
||||||
|
$expectedIcon->destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\PHPUnit\Framework\Attributes\DataProvider('dataRenderAppIconSvg')]
|
||||||
|
public function testGetFavIconSvg(string $app, string $color, string $file): void {
|
||||||
|
$this->checkImagick('SVG');
|
||||||
|
// mock required methods
|
||||||
|
$this->imageManager->expects($this->any())
|
||||||
|
->method('canConvert')
|
||||||
|
->willReturnMap([
|
||||||
|
['ICO', true],
|
||||||
|
['SVG', true],
|
||||||
|
['PNG', true]
|
||||||
|
]);
|
||||||
|
$this->util->expects($this->once())
|
||||||
|
->method('getAppIcon')
|
||||||
|
->with($app, true)
|
||||||
|
->willReturn(__DIR__ . '/data/' . $file);
|
||||||
|
$this->themingDefaults->expects($this->any())
|
||||||
|
->method('getColorPrimary')
|
||||||
|
->willReturn($color);
|
||||||
|
// generate expected output from source file
|
||||||
|
$expectedIcon = $this->generateTestFavIcon($file, 'SVG', $color);
|
||||||
|
// run test
|
||||||
|
$result = $this->iconBuilder->getFavicon($app);
|
||||||
|
$this->assertIsString($result, 'Favicon generation should return a ICO blob');
|
||||||
|
$this->assertEquals($expectedIcon->getImagesBlob(), $result, 'Generated favicon differs from expected');
|
||||||
|
$expectedIcon->destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\PHPUnit\Framework\Attributes\DataProvider('dataRenderAppIconPng')]
|
||||||
|
public function testGetFaviconPng(string $app, string $color, string $file): void {
|
||||||
|
$this->checkImagick('PNG');
|
||||||
|
// mock required methods
|
||||||
|
$this->imageManager->expects($this->any())
|
||||||
|
->method('canConvert')
|
||||||
|
->willReturnMap([
|
||||||
|
['ICO', true],
|
||||||
|
['SVG', false],
|
||||||
|
['PNG', true]
|
||||||
|
]);
|
||||||
|
$this->util->expects($this->once())
|
||||||
|
->method('getAppIcon')
|
||||||
|
->with($app, false)
|
||||||
|
->willReturn(__DIR__ . '/data/' . $file);
|
||||||
|
$this->themingDefaults->expects($this->any())
|
||||||
|
->method('getColorPrimary')
|
||||||
|
->willReturn($color);
|
||||||
|
// generate expected output from source file
|
||||||
|
$expectedIcon = $this->generateTestFavIcon($file, 'PNG', $color);
|
||||||
|
// run test
|
||||||
|
$result = $this->iconBuilder->getFavicon($app);
|
||||||
|
$this->assertIsString($result, 'Favicon generation should return a ICO blob');
|
||||||
|
$this->assertEquals($expectedIcon->getImagesBlob(), $result, 'Generated favicon differs from expected');
|
||||||
$expectedIcon->destroy();
|
$expectedIcon->destroy();
|
||||||
// FIXME: We may need some comparison of the generated and the test images
|
|
||||||
// cloud be something like $expectedIcon->compareImages($icon, Imagick::METRIC_MEANABSOLUTEERROR)[1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetFaviconNotFound(): void {
|
public function testGetFaviconNotFound(): void {
|
||||||
$this->checkImagick();
|
$this->checkImagick('ICO');
|
||||||
$util = $this->createMock(Util::class);
|
$util = $this->createMock(Util::class);
|
||||||
$iconBuilder = new IconBuilder($this->themingDefaults, $util, $this->imageManager);
|
$iconBuilder = new IconBuilder($this->themingDefaults, $util, $this->imageManager);
|
||||||
$this->imageManager->expects($this->once())
|
$this->imageManager->expects($this->any())
|
||||||
->method('shouldReplaceIcons')
|
->method('canConvert')
|
||||||
->willReturn(true);
|
->willReturn(true);
|
||||||
$util->expects($this->once())
|
$util->expects($this->once())
|
||||||
->method('getAppIcon')
|
->method('getAppIcon')
|
||||||
->willReturn('notexistingfile');
|
->willReturn('notexistingfile');
|
||||||
$this->assertFalse($iconBuilder->getFavicon('noapp'));
|
$result = $iconBuilder->getFavicon('noapp');
|
||||||
|
$this->assertFalse($result, 'Favicon generation should fail for missing file');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGetTouchIconNotFound(): void {
|
public function testGetTouchIconNotFound(): void {
|
||||||
|
|
@ -174,4 +277,85 @@ class IconBuilderTest extends TestCase {
|
||||||
->willReturn('notexistingfile');
|
->willReturn('notexistingfile');
|
||||||
$this->assertFalse($iconBuilder->colorSvg('noapp', 'noimage'));
|
$this->assertFalse($iconBuilder->colorSvg('noapp', 'noimage'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to generate expected icon from source file for tests.
|
||||||
|
*/
|
||||||
|
private function generateTestIcon(string $file, string $format, int $size, string $color): \Imagick {
|
||||||
|
$filePath = realpath(__DIR__ . '/data/' . $file);
|
||||||
|
$appIconFile = new \Imagick();
|
||||||
|
if ($format === 'SVG') {
|
||||||
|
$svgContent = file_get_contents($filePath);
|
||||||
|
if (substr($svgContent, 0, 5) !== '<?xml') {
|
||||||
|
$svgContent = '<?xml version="1.0"?>' . $svgContent;
|
||||||
|
}
|
||||||
|
// get dimensions for resolution calculation
|
||||||
|
$tmp = new \Imagick();
|
||||||
|
$tmp->setBackgroundColor(new \ImagickPixel('transparent'));
|
||||||
|
$tmp->setResolution(72, 72);
|
||||||
|
$tmp->readImageBlob($svgContent);
|
||||||
|
$x = $tmp->getImageWidth();
|
||||||
|
$y = $tmp->getImageHeight();
|
||||||
|
$tmp->destroy();
|
||||||
|
// set resolution for proper scaling
|
||||||
|
$resX = (int)(72 * $size / $x);
|
||||||
|
$resY = (int)(72 * $size / $y);
|
||||||
|
$appIconFile->setBackgroundColor(new \ImagickPixel('transparent'));
|
||||||
|
$appIconFile->setResolution($resX, $resY);
|
||||||
|
$appIconFile->readImageBlob($svgContent);
|
||||||
|
} else {
|
||||||
|
$appIconFile->readImage($filePath);
|
||||||
|
}
|
||||||
|
$padding = 0.85;
|
||||||
|
$original_w = $appIconFile->getImageWidth();
|
||||||
|
$original_h = $appIconFile->getImageHeight();
|
||||||
|
$contentSize = (int)floor($size * $padding);
|
||||||
|
$scale = min($contentSize / $original_w, $contentSize / $original_h);
|
||||||
|
$new_w = max(1, (int)floor($original_w * $scale));
|
||||||
|
$new_h = max(1, (int)floor($original_h * $scale));
|
||||||
|
$offset_w = (int)floor(($size - $new_w) / 2);
|
||||||
|
$offset_h = (int)floor(($size - $new_h) / 2);
|
||||||
|
$cornerRadius = 0.2 * $size;
|
||||||
|
$appIconFile->resizeImage($new_w, $new_h, \Imagick::FILTER_LANCZOS, 1);
|
||||||
|
$finalIconFile = new \Imagick();
|
||||||
|
$finalIconFile->setBackgroundColor(new \ImagickPixel('transparent'));
|
||||||
|
$finalIconFile->newImage($size, $size, new \ImagickPixel('transparent'));
|
||||||
|
$draw = new \ImagickDraw();
|
||||||
|
$draw->setFillColor($color);
|
||||||
|
$draw->roundRectangle(0, 0, $size - 1, $size - 1, $cornerRadius, $cornerRadius);
|
||||||
|
$finalIconFile->drawImage($draw);
|
||||||
|
$draw->destroy();
|
||||||
|
$finalIconFile->setImageVirtualPixelMethod(\Imagick::VIRTUALPIXELMETHOD_TRANSPARENT);
|
||||||
|
$finalIconFile->setImageArtifact('compose:args', '1,0,-0.5,0.5');
|
||||||
|
$finalIconFile->compositeImage($appIconFile, \Imagick::COMPOSITE_ATOP, $offset_w, $offset_h);
|
||||||
|
$finalIconFile->setImageFormat('PNG32');
|
||||||
|
if (defined('Imagick::INTERPOLATE_BICUBIC') === true) {
|
||||||
|
$filter = \Imagick::INTERPOLATE_BICUBIC;
|
||||||
|
} else {
|
||||||
|
$filter = \Imagick::FILTER_LANCZOS;
|
||||||
|
}
|
||||||
|
$finalIconFile->resizeImage($size, $size, $filter, 1, false);
|
||||||
|
$finalIconFile->setImageFormat('png');
|
||||||
|
$appIconFile->destroy();
|
||||||
|
return $finalIconFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to generate expected favicon from source file for tests.
|
||||||
|
*/
|
||||||
|
private function generateTestFavIcon(string $file, string $format, string $color): \Imagick {
|
||||||
|
$baseIcon = $this->generateTestIcon($file, $format, 128, $color);
|
||||||
|
$baseIcon->setImageFormat('PNG32');
|
||||||
|
|
||||||
|
$testIcon = new \Imagick();
|
||||||
|
$testIcon->setFormat('ICO');
|
||||||
|
foreach ([16, 32, 64, 128] as $size) {
|
||||||
|
$clone = clone $baseIcon;
|
||||||
|
$clone->scaleImage($size, 0);
|
||||||
|
$testIcon->addImage($clone);
|
||||||
|
$clone->destroy();
|
||||||
|
}
|
||||||
|
$baseIcon->destroy();
|
||||||
|
return $testIcon;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
BIN
apps/theming/tests/data/logo.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
2
apps/theming/tests/data/logo.png.license
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
1
apps/theming/tests/data/logo.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg width="256" height="128" version="1.1" viewBox="0 0 256 128" xmlns="http://www.w3.org/2000/svg"><path d="m128 7c-25.871 0-47.817 17.485-54.713 41.209-5.9795-12.461-18.642-21.209-33.287-21.209-20.304 0-37 16.696-37 37s16.696 37 37 37c14.645 0 27.308-8.7481 33.287-21.209 6.8957 23.724 28.842 41.209 54.713 41.209s47.817-17.485 54.713-41.209c5.9795 12.461 18.642 21.209 33.287 21.209 20.304 0 37-16.696 37-37s-16.696-37-37-37c-14.645 0-27.308 8.7481-33.287 21.209-6.8957-23.724-28.842-41.209-54.713-41.209zm0 22c19.46 0 35 15.54 35 35s-15.54 35-35 35-35-15.54-35-35 15.54-35 35-35zm-88 20c8.4146 0 15 6.5854 15 15s-6.5854 15-15 15-15-6.5854-15-15 6.5854-15 15-15zm176 0c8.4146 0 15 6.5854 15 15s-6.5854 15-15 15-15-6.5854-15-15 6.5854-15 15-15z" color="#000000" fill="#fff" style="-inkscape-stroke:none"/></svg>
|
||||||
|
After Width: | Height: | Size: 815 B |
2
apps/theming/tests/data/logo.svg.license
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
BIN
apps/theming/tests/data/settings.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
2
apps/theming/tests/data/settings.png.license
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
1
apps/theming/tests/data/settings.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3,17V19H9V17H3M3,5V7H13V5H3M13,21V19H21V17H13V15H11V21H13M7,9V11H3V13H7V15H9V9H7M21,13V11H11V13H21M15,9H17V7H21V5H17V3H15V9Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 204 B |
2
apps/theming/tests/data/settings.svg.license
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
Before Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
|
@ -34558,6 +34558,12 @@
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Favicon returned",
|
"description": "Favicon returned",
|
||||||
"content": {
|
"content": {
|
||||||
|
"image/png": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
},
|
||||||
"image/x-icon": {
|
"image/x-icon": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -34627,7 +34633,7 @@
|
||||||
"format": "binary"
|
"format": "binary"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"image/x-icon": {
|
"*/*": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "binary"
|
"format": "binary"
|
||||||
|
|
|
||||||