<?php /** * Icon class is used to create icon objects which can be rendered as * svg. Output will be html. Optionally, the icon can be rendered * as a css background. * * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> * @copyright Stud.IP Core Group * @license GPL2 or any later version * @since 3.2 */ class Icon { const SVG = 1; const CSS_BACKGROUND = 4; const INPUT = 256; const RENDERING_MODE_IMG = 'img'; const RENDERING_MODE_EMBED = 'embed'; const RENDERING_MODE_USE = 'use'; const DEFAULT_SIZE = 16; const DEFAULT_COLOR = 'blue'; const DEFAULT_ROLE = 'clickable'; const ROLE_INFO = 'info'; const ROLE_CLICKABLE = 'clickable'; const ROLE_ACCEPT = 'accept'; const ROLE_STATUS_GREEN = 'status-green'; const ROLE_INACTIVE = 'inactive'; const ROLE_NAVIGATION = 'navigation'; const ROLE_NEW = 'new'; const ROLE_ATTENTION = 'attention'; const ROLE_STATUS_RED = 'status-red'; const ROLE_INFO_ALT = 'info_alt'; const ROLE_SORT = 'sort'; const ROLE_STATUS_YELLOW = 'status-yellow'; /** * This is the magical Role to Color mapping. */ const ROLES_TO_COLORS = [ self::ROLE_INFO => 'black', self::ROLE_CLICKABLE => 'blue', self::ROLE_ACCEPT => 'green', self::ROLE_STATUS_GREEN => 'green', self::ROLE_INACTIVE => 'grey', self::ROLE_NAVIGATION => 'blue', self::ROLE_NEW => 'red', self::ROLE_ATTENTION => 'red', self::ROLE_STATUS_RED => 'red', self::ROLE_INFO_ALT => 'white', self::ROLE_SORT => 'blue', self::ROLE_STATUS_YELLOW => 'yellow' ]; protected $shape; protected $role; protected $attributes = []; // return the color associated to a role public static function roleToColor($role) { if (!isset(self::ROLES_TO_COLORS[$role])) { throw new \InvalidArgumentException('Unknown role: "' . $role . '"'); } return self::ROLES_TO_COLORS[$role]; } // return the roles! associated to a color private static function colorToRoles($color) { static $colors_to_roles; if (!$colors_to_roles) { foreach (self::ROLES_TO_COLORS as $r => $c) { $colors_to_roles[$c][] = $r; } } if (!isset($colors_to_roles[$color])) { throw new \InvalidArgumentException('Unknown color: "' . $color . '"'); } return $colors_to_roles[$color]; } /** * Create a new Icon object. * * This is just a factory method. You could easily just call the * constructor instead. * * @param String $shape Shape of the icon, may contain a mixed definition * like 'seminar' * @param String $role Role of the icon, defaults to Icon::DEFAULT_ROLE * @param Array $attributes Additional attributes like 'title'; * only use semantic ones describing * this icon regardless of its later * rendering in a view * @return Icon object */ public static function create($shape, $role = Icon::DEFAULT_ROLE, $attributes = []) { // $role may be omitted if (is_array($role)) { $attributes = $role; $role = Icon::DEFAULT_ROLE; } return new self($shape, $role, $attributes); } protected static $rendering_mode = self::RENDERING_MODE_USE; protected static $used_shapes = []; protected static $used_extras = []; public static function setRenderingMode($mode) { self::$rendering_mode = $mode; } public static function renderUsedIcons() { if (count(self::$used_shapes) === 0) { return ''; } $icons = []; foreach (self::$used_shapes as $shape) { $icons[] = sprintf( '<symbol id="shape-%s">%s</symbol>', $shape, file_get_contents($GLOBALS['STUDIP_BASE_PATH'] . '/resources/icons/' . $shape . '.svg') ); } foreach (self::$used_extras as $extra) { $icons[] = sprintf( '<mask id="mask-%s">%s</mask>', $extra, file_get_contents($GLOBALS['STUDIP_BASE_PATH'] . '/resources/icons/extras/' . $extra . '-mask.xml') ); $icons[] = sprintf( '<symbol id="extra-%s">%s</symbol>', $extra, file_get_contents($GLOBALS['STUDIP_BASE_PATH'] . '/resources/icons/extras/' . $extra . '-path.xml') ); } return sprintf( '<svg shape-rendering="geometricPrecision" style="position: absolute; pointer-events: none; right: 0; bottom: 0;"><defs>%s</defs></svg>', implode('', $icons) ); } /** * Constructor of the object. * * @param String $shape Shape of the icon, may contain a mixed definition * like 'seminar' * @param String $role Role of the icon, defaults to Icon::DEFAULT_ROLE * @param Array $attributes Additional attributes like 'title'; * only use semantic ones describing * this icon regardless of its later * rendering in a view */ public function __construct($shape, $role = Icon::DEFAULT_ROLE, array $attributes = []) { // only defined roles if (!isset(self::ROLES_TO_COLORS[$role])) { throw new \InvalidArgumentException('Creating an Icon without proper role: "' . $role . '"'); } // only semantic attributes if ($non_semantic = array_filter(array_keys($attributes), function ($attr) { return !in_array($attr, ['title']); })) { // DEPRECATED // TODO starting with the v3.6 the following line should // be enabled to prevent non-semantic attributes in this position # throw new \InvalidArgumentException('Creating an Icon with non-semantic attributes:' . json_encode($non_semantic)); } $this->shape = $shape; $this->role = $role; $this->attributes = $attributes; } /** * Returns the `shape` -- the string describing the shape of this instance. * @return String the shape of this Icon */ public function getShape() { return $this->shapeToPath($this->shape); } /** * Returns the `role` -- the string describing the role of this instance. * @return String the role of this Icon */ public function getRole() { return $this->role; } /** * Returns the semantic `attributes` of this instance, e.g. the title of this Icon * @return Array the semantic attribiutes of the Icon */ public function getAttributes() { return $this->attributes; } /** * Returns whether this icon intends to signal attention. * * @todo This is currently just a heuristic based on the associated icon * role. Although this is sufficient for the current requirements, * it could probably in a better, more suitable way. * * @return bool * @since Stud.IP 5.0 */ public function signalsAttention() { return self::roleToColor($this->role) === 'red'; } /** * Function to be called whenever the object is converted to * string. Internally the same as calling Icon::asImg * * @return String representation */ public function __toString() { return $this->asImg(); } /** * Renders the icon inside an img html tag. * * @param int $size Optional; Defines the dimension in px of the rendered icon; FALSE prevents any * width or height attributes * @param Array $view_attributes Optional; Additional attributes to pass * into the rendered output * @return String containing the html representation for the icon. */ public function asImg($size = null, $view_attributes = []) { if (is_array($size)) { [$view_attributes, $size] = [$size, null]; } if (self::isStatic($this->shape) || self::$rendering_mode === self::RENDERING_MODE_IMG) { return sprintf( '<img %s>', arrayToHtmlAttributes( $this->prepareHTMLAttributes($size, $view_attributes) ) ); } if (self::$rendering_mode === self::RENDERING_MODE_USE) { $shape = $this->shape; $extra = false; if (strpos($shape, '+') !== false) { list($shape, $extra) = explode('+', $shape, 2); if (!in_array($extra, self::$used_extras)) { self::$used_extras[] = $extra; } } if (!in_array($shape, self::$used_shapes)) { self::$used_shapes[] = $shape; } $attributes = $this->prepareHTMLAttributes($size, $view_attributes); $attributes['viewBox'] = "0 0 64 64"; $title = $attributes['title'] ?? false; unset($attributes['src'], $attributes['title']); if ($extra === false) { return sprintf( '<svg %s>%s<use href="#shape-%s"/></svg>', arrayToHtmlAttributes($attributes), $title ? '<title>' . $title . '</title>' : '', $shape ); } return sprintf( '<svg %s>%s<use href="#extra-%s"/><use mask="url(#mask-%s)" href="#shape-%s"/></svg>', arrayToHtmlAttributes($attributes), $title ? '<title>' . $title . '</title>' : '', $extra, $extra, $shape ); } if (self::$rendering_mode === self::RENDERING_MODE_EMBED) { $attributes = $this->prepareHTMLAttributes($size, $view_attributes); unset($attributes['src']); return sprintf( '<svg %s><g>%s</g></svg>', arrayToHtmlAttributes($attributes), file_get_contents($this->get_asset_svg()) ); } throw new Exception('Invalid rendering mode'); } /** * Renders the icon inside an input html tag. * * @param int $size Optional; Defines the dimension in px of the rendered icon; FALSE prevents any * width or height attributes * @param Array $view_attributes Optional; Additional attributes to pass * into the rendered output * @return String containing the html representation for the icon. */ public function asInput($size = null, $view_attributes = []) { if (is_array($size)) { [$view_attributes, $size] = [$size, null]; } return sprintf( '<input type="image" %s>', arrayToHtmlAttributes( $this->prepareHTMLAttributes($size, $view_attributes) ) ); } /** * Renders the icon as a set of css background rules. * * @param int $size Optional; Defines the size in px of the rendered icon * @return String containing the html representation for css backgrounds */ public function asCSS($size = null) { if (self::isStatic($this->shape)) { return sprintf( 'background-image:url(%1$s);background-size:%2$upx %2$upx;', $this->shapeToPath($this->shape), $this->get_size($size) ); } return sprintf( 'background-image:url(%1$s);background-size:%2$upx %2$upx;', $this->get_icon_url(), $this->get_size($size) ); } /** * Returns a path to the SVG matching the icon. * * @return String containing the html representation for css backgrounds */ public function asImagePath() { return $this->prepareHTMLAttributes(false, [])['src']; } /** * Returns a new Icon with a changed shape * @param mixed $shape New value of `shape` * @return Icon A new Icon with a new `shape` */ public function copyWithShape($shape) { $clone = clone $this; $clone->shape = $shape; return $clone; } /** * Returns a new Icon with a changed role * @param mixed $role New value of `role` * @return Icon A new Icon with a new `role` */ public function copyWithRole($role) { $clone = clone $this; $clone->role = $role; return $clone; } /** * Returns a new Icon with new attributes * @param mixed $attributes New value of `attributes` * @return Icon A new Icon with a new `attributes` */ public function copyWithAttributes($attributes) { $clone = clone $this; $clone->attributes = $attributes; return $clone; } /** * Prepares the html attributes for use assembling HTML attributes * from given shape, role, size, semantic and view attributes * * @param int $size Size of the icon * @param array $attributes Additional attributes * @return Array containing the merged attributes */ private function prepareHTMLAttributes($size, $attributes) { $dimensions = []; if ($size !== false) { $size = $this->get_size($size); $dimensions = ['width' => $size, 'height' => $size]; } $result = array_merge($this->attributes, $attributes, $dimensions, [ 'src' => self::isStatic($this->shape) ? $this->shape : $this->get_icon_url(), ]); if (!isset($result['alt']) && !isset($result['title'])) { //Add an empty alt attribute to prevent screen readers from //reading the URL of the icon: $result['alt'] = ''; } $classNames = 'studipicon icon-role-' . str_replace('_', '-', $this->role); if (!self::isStatic($this->shape)) { $classNames .= ' icon-shape-' . $this->shape; } $result['class'] = isset($result['class']) ? $result['class'] . ' ' . $classNames : $classNames; return $result; } protected function get_icon_url() { return implode('/', [ rtrim($GLOBALS['ABSOLUTE_URI_STUDIP'], '/'), 'icon.php', self::roleToColor($this->role), $this->shapeToPath($this->shape), ]); } /** * Get the correct asset for an SVG icon. * * @return String containing the url of the corresponding asset */ protected function get_asset_svg() { return Assets::url('images/icons/' . self::roleToColor($this->role) . '/' . $this->shapeToPath($this->shape) . '.svg'); } /** * Get the size of the icon. If a size was passed as a parameter and * inside the attributes array during icon construction, the size from * the attributes will be used. * * @param int $size size of the icon * @return int Size of the icon in pixels */ protected function get_size($size) { $size = $size ?: Icon::DEFAULT_SIZE; if (isset($this->attributes['size'])) { $parts = explode('@', $this->attributes['size'], 2); $size = $parts[0]; $temp = $parts[1] ?? null; unset($this->attributes['size']); } return (int)$size; } // an icon is static if it starts with 'http' private static function isStatic($shape) { return mb_strpos($shape, 'http') === 0; } // transforms a shape w/ possible additions (`shape`) to a path `(addition/)?shape` private function shapeToPath() { return self::isStatic($this->shape) ? $this->shape : join('/', array_reverse(explode('+', preg_replace('/\.svg$/', '', $this->shape)))); } }