nextcloud/apps/theming/lib/IconBuilder.php
SebastianKrupinski e2c4db1541 fix: generate favourite icon without imagick svg support
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
2026-01-26 13:57:22 -05:00

233 lines
6.5 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Theming;
use Imagick;
use ImagickDraw;
use ImagickPixel;
use OCP\Files\SimpleFS\ISimpleFile;
class IconBuilder {
/**
* IconBuilder constructor.
*
* @param ThemingDefaults $themingDefaults
* @param Util $util
* @param ImageManager $imageManager
*/
public function __construct(
private ThemingDefaults $themingDefaults,
private Util $util,
private ImageManager $imageManager,
) {
}
/**
* @param $app string app name
* @return string|false image blob
*/
public function getFavicon($app) {
if (!$this->imageManager->canConvert('PNG')) {
return false;
}
try {
$icon = $this->renderAppIcon($app, 128);
if ($icon === false) {
return false;
}
$icon->setImageFormat('PNG32');
$favicon = new Imagick();
$favicon->setFormat('ICO');
$clone = clone $icon;
$clone->scaleImage(16, 0);
$favicon->addImage($clone);
$clone = clone $icon;
$clone->scaleImage(32, 0);
$favicon->addImage($clone);
$clone = clone $icon;
$clone->scaleImage(64, 0);
$favicon->addImage($clone);
$clone = clone $icon;
$clone->scaleImage(128, 0);
$favicon->addImage($clone);
$data = $favicon->getImagesBlob();
$favicon->destroy();
$icon->destroy();
$clone->destroy();
return $data;
} catch (\ImagickException $e) {
return false;
}
}
/**
* @param $app string app name
* @return string|false image blob
*/
public function getTouchIcon($app) {
try {
$icon = $this->renderAppIcon($app, 512);
if ($icon === false) {
return false;
}
$icon->setImageFormat('png32');
$data = $icon->getImageBlob();
$icon->destroy();
return $data;
} catch (\ImagickException $e) {
return false;
}
}
/**
* Render app icon on themed background color
* fallback to logo
*
* @param string $app app name
* @param int $size size of the icon in px
* @return Imagick|false
*/
public function renderAppIcon($app, $size) {
$supportSvg = $this->imageManager->canConvert('SVG');
// retrieve app icon
$appIcon = $this->util->getAppIcon($app, $supportSvg);
if ($appIcon instanceof ISimpleFile) {
$appIconContent = $appIcon->getContent();
$mime = $appIcon->getMimeType();
} elseif (!file_exists($appIcon)) {
return false;
} else {
$appIconContent = file_get_contents($appIcon);
$mime = mime_content_type($appIcon);
}
if ($appIconContent === false || $appIconContent === '') {
return false;
}
$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;
}
// construct original image object
try {
$appIconFile = new Imagick();
$appIconFile->setBackgroundColor(new ImagickPixel('transparent'));
if ($appIconIsSvg) {
// handle SVG images
// ensure proper XML declaration
if (!str_starts_with($appIconContent, '<?xml')) {
$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 {
// handle non-SVG images
$appIconFile->readImageBlob($appIconContent);
}
} catch (\ImagickException $e) {
return false;
}
// calculate final image size and position
$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;
$color = $this->themingDefaults->getColorPrimary();
// resize original image
$appIconFile->resizeImage($new_w, $new_h, Imagick::FILTER_LANCZOS, 1);
/**
* invert app icons for bright primary colors
* the default nextcloud logo will not be inverted to black
*/
if ($this->util->isBrightColor($color)
&& !$appIcon instanceof ISimpleFile
&& $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;
}
$finalIconFile->resizeImage($size, $size, $filter, 1, false);
return $finalIconFile;
} finally {
unset($appIconFile);
}
return false;
}
/**
* @param string $app app name
* @param string $image relative path to svg file in app directory
* @return string|false content of a colorized svg file
*/
public function colorSvg($app, $image) {
$imageFile = $this->util->getAppImage($app, $image);
if ($imageFile === false || $imageFile === '' || !file_exists($imageFile)) {
return false;
}
$svg = file_get_contents($imageFile);
if ($svg !== false && $svg !== '') {
$color = $this->util->elementColor($this->themingDefaults->getColorPrimary());
$svg = $this->util->colorizeSvg($svg, $color);
return $svg;
} else {
return false;
}
}
}