Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • AlekSIS/libs/django-iconify
1 result
Show changes
Commits on Source (7)
"""App configuration for django-iconify."""
from django.conf import settings
_prefix = "ICONIFY_"
......
......@@ -2,7 +2,7 @@
Documentation: https://docs.iconify.design/types/
"""
from typing import Collection, Dict, List, Optional, Union
from typing import Collection, Dict, List, Optional, Sequence, Union
from typing.io import TextIO
import json
......@@ -10,6 +10,7 @@ from .util import split_css_unit
class IconifyOptional:
"""Mixin containing optional attributes all other types can contain."""
left: Optional[int] = None
top: Optional[int] = None
width: Optional[int] = None
......@@ -48,13 +49,19 @@ class IconifyOptional:
class IconifyIcon(IconifyOptional):
"""Single icon as loaded from Iconify JSON data.
Documentation: https://docs.iconify.design/types/iconify-icon.html
"""
_collection: Optional["IconifyJSON"]
_name: str
body: str
@classmethod
def from_dict(cls, src: dict, collection: Optional["IconifyJSON"] = None) -> "IconifyIcon":
def from_dict(cls, name: str, src: dict, collection: Optional["IconifyJSON"] = None) -> "IconifyIcon":
self = cls()
self.body = src["body"]
self._name = name
self._from_dict_optional(src)
self._collection = collection
return self
......@@ -67,6 +74,11 @@ class IconifyIcon(IconifyOptional):
return res
def get_width(self):
"""Get the width of the icon.
If the icon has an explicit width, it is returned.
If not, the width set in the collection is returned, or the default of 16.
"""
if self.width:
return self.width
elif self._collection and self._collection.width:
......@@ -75,6 +87,11 @@ class IconifyIcon(IconifyOptional):
return 16
def get_height(self):
"""Get the height of the icon.
If the icon has an explicit height, it is returned.
If not, the height set in the collection is returned, or the default of 16.
"""
if self.height:
return self.height
elif self._collection and self._collection.height:
......@@ -82,67 +99,112 @@ class IconifyIcon(IconifyOptional):
else:
return 16
def as_svg(self, color: Optional[str] = None, width: Optional[str] = None, height: Optional[str] = None, rotate: Optional[str] = None, flip: Optional[str] = None, box: bool = False) -> str:
def as_svg(self, color: Optional[str] = None, width: Optional[str] = None, height: Optional[str] = None, rotate: Optional[str] = None, flip: Optional[Union[str, Sequence]] = None, box: bool = False) -> str:
"""Generate a full SVG of this icon.
Some transformations can be applied by passing arguments:
width, height - Scale the icon; if only one is set and the other is not
(or set to 'auto'), the other is calculated, preserving aspect ratio.
Suffixes (i.e. CSS units) are allowed
rotate - Either a degress value with 'deg' suffix, or a number from 0 to 4
expressing the number of 90 degreee rotations
flip - horizontal, vertical, or both values with comma
box - Include a transparent box spanning the whole viewbox
Documentation: https://docs.iconify.design/types/iconify-icon.html
"""
# Original dimensions, the viewbox we use later
orig_width, orig_height = self.get_width(), self.get_height()
if width and (height is None or height.lower() == "auto"):
# Width given, determine height automatically
value, unit = split_css_unit(width)
height = str(value / (orig_width / orig_height)) + unit
elif height and (width is None or width.lower() == "auto"):
# Height given, determine width automatically
value, unit = split_css_unit(height)
width = str(value / (orig_height / orig_width)) + unit
elif width is None and height is None:
# Neither width nor height given, default to browser text size
width, height = "1em", "1em"
# Build attributes to inject into <svg> element
svg_dim_attrs = f'width="{width}" height="{height}"'
head = f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" {svg_dim_attrs} preserveAspectRatio="xMidYMid meet" viewBox="0 0 {orig_width} {orig_height}" style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);">'
# SVG root element (copied bluntly from example output on api.iconify.design)
head = ('<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
f'{svg_dim_attrs} '
'preserveAspectRatio="xMidYMid meet" '
f'viewBox="0 0 {orig_width} {orig_height}" '
'style="-ms-transform: rotate(360deg); -webkit-transform: rotate(360deg); transform: rotate(360deg);">')
foot = "</svg>"
# Build up all transformations, which are added as an SVG group (<g> element)
transform = []
if rotate is not None:
center_x, center_y = int(self.width / 2), int(self.height / 2)
# Rotation will be around center of viewbox
center_x, center_y = int(orig_width / 2), int(orig_height / 2)
if rotate.isnumeric():
# Plain number, calculate degrees in 90deg steps
deg = int(rotate) * 90
elif rotate.endswith("deg"):
deg = int(rotate[:-3])
transform.append(f"rotate({deg} {center_x} {center_y})")
if flip is not None:
flip = flip.split(",")
translate_x, translate_y = 0
if isinstance(flip, str):
# Split flip attribute if passed verbatim from request
flip = flip.split(",")
# Seed with no-op values
translate_x, translate_y = 0, 0
scale_x, scale_y = 1, 1
if "horizontal" in flip:
# Flip around X axis
translate_x = orig_width
scale_x = -1
if "vertical" in flip:
# Flip around Y axis
translate_y = orig_height
scale_y = -1
transform.append(f"translate({translate_x} {translate_z})")
# Build transform functions for <g> attribute
transform.append(f"translate({translate_x} {translate_y})")
transform.append(f"scale({scale_x} {scale_y})")
if transform:
# Generate a <g> attribute if any transformations were generated
transform = " ".join(transform)
g = f'<g transform="{transform}">', "</g>"
else:
# use dummy empty strings to make string building easier further down
g = "", ""
# Body from icon data
body = self.body
if color is not None:
# Color is replaced anywhere it appears as attribute value
# FIXME Find a better way to repalce only color values safely
body = body.replace('"currentColor"', f'"{color}"')
if box:
# Add a transparent box spanning the whole viewbox for browsers that do not support viewbox
box = f'<rect x="0" y="0" width="{orig_width}" height="{orig_height}" fill="rgba(0, 0, 0, 0)" />'
else:
# Dummy empty string for easier string building further down
box = ""
# Construct final SVG data
svg = f"{head}{g[0]}{body}{g[1]}{box}{foot}"
return svg
class IconifyAlias(IconifyOptional):
"""Alias for an icon.
Documentation: https://docs.iconify.design/types/iconify-alias.html
"""
_collection: Optional["IconifyJSON"]
parent: str
def get_icon(self):
"""Get the real icon by retrieving it from the parent collection, if any."""
if self._collection:
return self._collection.get_icon(self.parent)
......@@ -161,14 +223,77 @@ class IconifyAlias(IconifyOptional):
res.update(self._as_dict_optional())
return res
class IconifyInfo(IconifyOptional):
"""Meta information on a colelction.
No documentation; guessed from the JSON data provided by Iconify.
"""
name: str
author: Dict[str, str] # FIXME turn intoreal object
license_: Dict[str, str] # FIXME turn into real object
samples: Optional[List[IconifyIcon]]
category: str
palette: bool
@property
def total(self):
"""Determine icon count from parent collection."""
if self._collection:
return len(self._collection.icons)
@classmethod
def from_dict(cls, src: dict, collection: Optional["IconifyJSON"] = None) -> "IconifyInfo":
self = cls()
self.name = src.get("name", None)
self.category = src.get("category", None)
self.palette = src.get("palette", None)
self.author = src.get("author", None)
self.license_ = src.get("license", None)
self.samples = [collection.get_icon(name) for name in src.get("samples", [])] or None
self._from_dict_optional(src)
self._collection = collection
return self
def as_dict(self) -> dict:
res = {}
if self.name is not None:
res["name"] = self.name
if self.category is not None:
res["category"] = self.category
if self.palette is not None:
res["palette"] = self.palette
if self.author is not None:
res["author"] = self.author
if self.license_ is not None:
res["license"] = self.license_
if self.total is not None:
res["total"] = self.total
if self.samples is not None:
res["samples"] = [icon._name for icon in self.samples if icon is not None]
if self._collection is not None:
res["uncategorized"] = list(self._collection.icons.keys())
res.update(self._as_dict_optional())
return res
class IconifyJSON(IconifyOptional):
"""One collection as a whole.
Documentation: https://docs.iconify.design/types/iconify-json.html
"""
prefix: str
icons: Dict[str, IconifyIcon]
aliases: Optional[Dict[str, IconifyAlias]]
info: Optional[IconifyInfo]
not_found: List[str]
def get_icon(self, name: str):
"""Get an icon by name.
First, tries to find a real icon with the name. If none is found, tries
to resolve the name from aliases.
"""
if name in self.icons.keys():
return self.icons[name]
elif name in self.aliases.keys():
......@@ -176,9 +301,16 @@ class IconifyJSON(IconifyOptional):
@classmethod
def from_dict(cls, collection: Optional[dict] = None, only: Optional[Collection[str]] = None) -> "IconifyJSON":
"""Construct collection from a dictionary (probably from JSON, originally).
If the only parameter is passed a sequence or set, only icons and aliases with
these names are loaded (and real icons for aliases).
"""
if collection is None:
# Load from a dummy empty collection
collection = {}
if only is None:
# Construct a list of all names from source collection
only = set(collection["icons"].keys())
if "aliases" in collection:
only |= set(collection["aliases"].keys())
......@@ -189,25 +321,35 @@ class IconifyJSON(IconifyOptional):
self.icons, self.aliases = {}, {}
self.not_found = []
for name in only:
# Try to find a real icon with the name
icon_dict = collection["icons"].get(name, None)
if icon_dict:
self.icons[name] = IconifyIcon.from_dict(icon_dict, collection=self)
self.icons[name] = IconifyIcon.from_dict(name, icon_dict, collection=self)
continue
# If we got here, try finding an alias with the name
alias_dict = collection["aliases"].get(name, None)
if alias_dict:
self.aliases[name] = IconifyAlias.from_dict(alias_dict, collection=self)
self.icons[alias_dict["parent"]] = IconifyIcon.from_dict(collection["icons"][alias_dict["parent"]], collection=self)
# Make sure we also get the real icon to resolve the alias
self.icons[alias_dict["parent"]] = IconifyIcon.from_dict(alias_dict["parent"], collection["icons"][alias_dict["parent"]], collection=self)
continue
# If we got here, track the we did not find the icon
# Undocumented, but the original API server seems to return this field in its
# response instead of throwing a 404 error or so
self.not_found.append(name)
if "info" in collection:
self.info = IconifyInfo.from_dict(collection["info"], self)
self._from_dict_optional(collection)
return self
@classmethod
def from_file(cls, src_file: Union[str, TextIO] = None, **kwargs) -> "IconifyJSON":
"""Construct collection by reading a JSON file and calling from_dict."""
if isinstance(src_file, str):
with open(src_file, "r") as in_file:
src = json.load(in_file)
......@@ -216,7 +358,7 @@ class IconifyJSON(IconifyOptional):
return cls.from_dict(src, **kwargs)
def as_dict(self) -> dict:
def as_dict(self, include_info: bool = False) -> dict:
res = {
"prefix": self.prefix,
"icons": {name: icon.as_dict() for name, icon in self.icons.items()},
......@@ -224,5 +366,7 @@ class IconifyJSON(IconifyOptional):
}
if self.not_found:
res["not_found"] = self.not_found
if self.info and include_info:
res["info"] = self.info.as_dict()
res.update(self._as_dict_optional())
return res
......@@ -4,6 +4,8 @@ from . import views
urlpatterns = [
path('_config.js', views.ConfigView.as_view(), name='config.js'),
path('collection/', views.CollectionView.as_view(), name='collection'),
path('collections/', views.CollectionsView.as_view(), name='collections'),
re_path(r'^(?P<collection>[A-Za-z0-9-]+)\.(?P<format_>js(on)?)', views.IconifyJSONView.as_view(), name='iconify_json'),
re_path(r'^(?P<collection>[A-Za-z0-9-]+)/(?P<name>[A-Za-z0-9-]+)\.svg', views.IconifySVGView.as_view(), name='iconify_svg'),
]
"""Utility code used by other parts of django-iconify."""
import re
......
"""Iconify API endpoints as views.
Documentation: https://docs.iconify.design/sources/api/queries.html
"""
import json
import os
import re
from django.http import Http404, HttpRequest, HttpResponse
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest
from django.urls import reverse
from django.views.generic import View
......@@ -9,43 +14,109 @@ from .conf import JSON_ROOT
from .types import IconifyJSON
class BaseJSONView(View):
"""Base view that wraps JSON and JSONP responses.
It relies on the following query parameters:
callback - name of the JavaScript callback function to call via JSONP
pretty - 1 or true to pretty-print JSON (default is condensed)
The URL route has to pass an argument called format_ containing js or json
to determine the output format.
"""
default_callback: str = "Console.log"
def get_data(self, request: HttpRequest) -> dict:
"""Generate a dictionary contianing the data to return."""
raise NotImplementedError("You must implement this method in your view.")
def get(self, request: HttpRequest, format_: str = "json", *args, **kwargs) -> HttpResponse:
"""Get the JSON or JSONP response containing the data from the get_data method."""
# For JSONP, the callback name has to be passed
if format_ == "js":
callback = request.GET.get("callback", self.default_callback)
# Client can request pretty-printing of JSON
if request.GET.get("pretty", "0").lower() in ("1", "true"):
indent = 2
else:
indent = None
# Call main function implemented by children
data = self.get_data(request, *args, **kwargs)
# Get result JSON and form response
res = json.dumps(data, indent=indent, sort_keys=True)
if format_ == "js":
# Format is JSONP
res = f"{callback}({res})"
return HttpResponse(res, content_type="application/javascript")
else:
# Format is plain JSON
return HttpResponse(res, content_type="application/json")
class ConfigView(View):
"""Get JavaScript snippet to conifugre Iconify for our API."""
"""Get JavaScript snippet to conifugre Iconify for our API.
This sets the API base URL to the endpoint determined by Django's reverse
URL mapper.
"""
def get(self, request: HttpRequest) -> HttpResponse:
# Guess the base URL by reverse-mapping the URL for a fake icon set
rev = reverse("iconify_json", kwargs={"collection": "prefix", "format_": "js"})
# Iconify SVG Framework expects placeholders {prefix} and {icons} in API URL
api_pattern = rev[:-9] + "{prefix}.js?icons={icons}"
# Put together configuration as dict and output as JSON
config = {
"defaultAPI": api_pattern,
}
config_json = json.dumps(config)
return HttpResponse(f"var IconifyConfig = {config_json}", content_type="text/javascript")
class IconifyJSONView(View):
"""Serve the Iconify icon data retrieval API."""
def get(self, request: HttpRequest, collection: str, format_: str) -> HttpResponse:
"""Retrieve a set of icons using a GET request.
class CollectionView(BaseJSONView):
"""Retrieve the meta-data for a single collection."""
def get_data(self, request: HttpRequest) -> dict:
# Collection name is passed in the prefix query parameter
collection = request.GET.get("prefix", None)
if collection is None or not re.match(r'[A-Za-z0-9-]+', collection):
return HttpResponseBadRequest("You must provide a valid prefix name.")
# Load icon set through Iconify types
collection_file = os.path.join(JSON_ROOT, "json", f"{collection}.json")
try:
icon_set = IconifyJSON.from_file(collection_file)
except FileNotFoundError as exc:
raise Http404(f"Icon collection {collection} not found") from exc
# Return the info member, which holds the data we want
return icon_set.info.as_dict()
The view supports both JSON and JSONP responses.
"""
class CollectionsView(BaseJSONView):
"""Retrieve the available collections with meta-data."""
def get_data(self, request: HttpRequest) -> dict:
# Read the pre-compiled collections list and return it verbatim
# FIXME Consider using type models to generate from sources
collections_path = os.path.join(JSON_ROOT, "collections.json")
with open(collections_path, "r") as collections_file:
data = json.load(collections_file)
return data
class IconifyJSONView(BaseJSONView):
"""Serve the Iconify icon data retrieval API."""
default_callback: str = "SimpleSVG._loaderCallback"
def get_data(self, request: HttpRequest, collection: str) -> dict:
# Icon names are passed as comma-separated list
icons = request.GET.get("icons", None)
if icons is not None:
icons = icons.split(",")
# For JSONP, the callback name has to be passed
if format_ == "js":
callback = request.GET.get("callback", "SimpleSVG._loaderCallback")
# Client can request pretty-printing of JSON
if request.GET.get("pretty", "0").lower() in ("1", "true"):
indent = 2
else:
indent = None
# Load icon set through Iconify types
collection_file = os.path.join(JSON_ROOT, "json", f"{collection}.json")
try:
......@@ -53,19 +124,15 @@ class IconifyJSONView(View):
except FileNotFoundError as exc:
raise Http404(f"Icon collection {collection} not found") from exc
# Get result JSON and form response
res = json.dumps(icon_set.as_dict(), indent=indent, sort_keys=True)
if format_ == "js":
res = f"{callback}({res})"
return HttpResponse(res, content_type="application/javascript")
else:
return HttpResponse(res, content_type="application/json")
return icon_set.as_dict()
class IconifySVGView(View):
"""Serve the Iconify SVG retrieval API."""
"""Serve the Iconify SVG retrieval API.
It serves a single icon as SVG, allowing some transformations.
"""
def get(self, request: HttpRequest, collection: str, name: str) -> HttpResponse:
"""Retrieve a single icon as SVG."""
# General retrieval parameters
download = request.GET.get("download", "0").lower() in ("1", "true")
box = request.GET.get("box", "0").lower() in ("1", "true")
......