diff --git a/dj_iconify/types.py b/dj_iconify/types.py index 0793421690bb1a0c997e5cc79c30bba070eb8a5d..5938203d85e0224d49239bf84dd5081c8f76ec38 100644 --- a/dj_iconify/types.py +++ b/dj_iconify/types.py @@ -6,6 +6,8 @@ from typing import Collection, Dict, List, Optional, Union from typing.io import TextIO import json +from .util import split_css_unit + class IconifyOptional: left: Optional[int] = None @@ -46,13 +48,15 @@ class IconifyOptional: class IconifyIcon(IconifyOptional): + _collection: Optional["IconifyJSON"] body: str @classmethod - def from_dict(cls, src: dict) -> "IconifyIcon": + def from_dict(cls, src: dict, collection: Optional["IconifyJSON"] = None) -> "IconifyIcon": self = cls() self.body = src["body"] self._from_dict_optional(src) + self._collection = collection return self def as_dict(self) -> dict: @@ -62,15 +66,92 @@ class IconifyIcon(IconifyOptional): res.update(self._as_dict_optional()) return res + def get_width(self): + if self.width: + return self.width + elif self._collection and self._collection.width: + return self._collection.height + else: + return 16 + + def get_height(self): + if self.height: + return self.height + elif self._collection and self._collection.height: + return self._collection.height + 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: + orig_width, orig_height = self.get_width(), self.get_height() + + if width and (height is None or height.lower() == "auto"): + value, unit = split_css_unit(width) + height = str(value / (orig_width / orig_height)) + unit + elif height and (width is None or width.lower() == "auto"): + value, unit = split_css_unit(height) + width = str(value / (orig_height / orig_width)) + unit + elif width is None and height is None: + width, height = "1em", "1em" + 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);">' + foot = "</svg>" + + transform = [] + if rotate is not None: + center_x, center_y = int(self.width / 2), int(self.height / 2) + if rotate.isnumeric(): + 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 + scale_x, scale_y = 1, 1 + if "horizontal" in flip: + translate_x = orig_width + scale_x = -1 + if "vertical" in flip: + translate_y = orig_height + scale_y = -1 + transform.append(f"translate({translate_x} {translate_z})") + transform.append(f"scale({scale_x} {scale_y})") + if transform: + transform = " ".join(transform) + g = f'<g transform="{transform}">', "</g>" + else: + g = "", "" + + body = self.body + if color is not None: + body = body.replace('"currentColor"', f'"{color}"') + + if box: + box = f'<rect x="0" y="0" width="{orig_width}" height="{orig_height}" fill="rgba(0, 0, 0, 0)" />' + else: + box = "" + + svg = f"{head}{g[0]}{body}{g[1]}{box}{foot}" + + return svg + class IconifyAlias(IconifyOptional): + _collection: Optional["IconifyJSON"] parent: str + def get_icon(self): + if self._collection: + return self._collection.get_icon(self.parent) + @classmethod - def from_dict(cls, src: dict) -> "IconifyAlias": + def from_dict(cls, src: dict, collection: Optional["IconifyJSON"] = None) -> "IconifyAlias": self = cls() self.parent = src["parent"] self._from_dict_optional(src) + self._collection = collection return self def as_dict(self) -> dict: @@ -87,6 +168,12 @@ class IconifyJSON(IconifyOptional): aliases: Optional[Dict[str, IconifyAlias]] not_found: List[str] + def get_icon(self, name: str): + if name in self.icons.keys(): + return self.icons[name] + elif name in self.aliases.keys(): + return self.aliases[name].get_icon() + @classmethod def from_dict(cls, collection: Optional[dict] = None, only: Optional[Collection[str]] = None) -> "IconifyJSON": if collection is None: @@ -104,13 +191,13 @@ class IconifyJSON(IconifyOptional): for name in only: icon_dict = collection["icons"].get(name, None) if icon_dict: - self.icons[name] = IconifyIcon.from_dict(icon_dict) + self.icons[name] = IconifyIcon.from_dict(icon_dict, collection=self) continue alias_dict = collection["aliases"].get(name, None) if alias_dict: - self.aliases[name] = IconifyAlias.from_dict(alias_dict) - self.icons[alias_dict["parent"]] = IconifyIcon.from_dict(collection["icons"][alias_dict["parent"]]) + 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) continue self.not_found.append(name) diff --git a/dj_iconify/urls.py b/dj_iconify/urls.py index 3f097f2d0b6b050e40a591b47590b7cb7e573f1c..111b32ef1dea7591abeef04219a0a1ca0b231056 100644 --- a/dj_iconify/urls.py +++ b/dj_iconify/urls.py @@ -5,4 +5,5 @@ from . import views urlpatterns = [ path('_config.js', views.ConfigView.as_view(), name='config.js'), 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'), ] diff --git a/dj_iconify/views.py b/dj_iconify/views.py index 70f3e72d6cc88ef22d63206f8e5aee1988c88170..710c7bd37cb152fe7b88aadcd8dfee754eb92546 100644 --- a/dj_iconify/views.py +++ b/dj_iconify/views.py @@ -60,3 +60,36 @@ class IconifyJSONView(View): return HttpResponse(res, content_type="application/javascript") else: return HttpResponse(res, content_type="application/json") + + +class IconifySVGView(View): + """Serve the Iconify SVG retrieval API.""" + 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") + + # SVG manipulation parameters + color = request.GET.get("color", None) + width = request.GET.get("width", None) + height = request.GET.get("height", None) + rotate = request.GET.get("rotate", None) + flip = request.GET.get("flip", None) + + # 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, only=[name]) + except FileNotFoundError as exc: + raise Http404(f"Icon collection {collection} not found") from exc + + # Generate SVG from icon + icon = icon_set.icons[name] + icon_svg = icon.as_svg(color=color, width=width, height=height, rotate=rotate, flip=flip, box=box) + + # Form response + res = HttpResponse(icon_svg, content_type="image/svg+xml") + if download: + res["Content-Disposition"] = f"attachment; filename={name}.svg" + return res