File: types.py

package info (click to toggle)
django-iconify 0.4.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 168 kB
  • sloc: python: 462; makefile: 9
file content (392 lines) | stat: -rw-r--r-- 14,235 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
"""Iconify data types used in API.

Documentation: https://docs.iconify.design/types/
"""
from typing import Collection, Dict, List, Optional, Sequence, TextIO, Union
import json

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
    height: Optional[int] = None

    rotate: Optional[int] = None
    h_flip: Optional[bool] = None
    v_flip: Optional[bool] = None

    def _as_dict_optional(self) -> dict:
        res = {}
        if self.left is not None:
            res["left"] = self.left
        if self.top is not None:
            res["top"] = self.top
        if self.width is not None:
            res["width"] = self.width
        if self.height is not None:
            res["height"] = self.height
        if self.rotate is not None:
            res["rotate"] = self.rotate
        if self.h_flip is not None:
            res["hFlip"] = self.h_flip
        if self.v_flip is not None:
            res["vFlip"] = self.v_flip
        return res

    def _from_dict_optional(self, src: dict) -> None:
        self.left = src.get("left", None)
        self.top = src.get("top", None)
        self.width = src.get("width", None)
        self.height = src.get("height", None)
        self.rotate = src.get("rotate", None)
        self.h_flip = src.get("hFlip", None)
        self.v_flip = src.get("vFlip", None)


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, 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

    def as_dict(self) -> dict:
        res = {
            "body": self.body,
        }
        res.update(self._as_dict_optional())
        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:
            return self._collection.height
        else:
            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:
            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[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}"'

        # 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:
            # 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:
            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
            # 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)

    @classmethod
    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:
        res = {
            "parent": self.parent,
        }
        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():
            return self.aliases[name].get_icon()

    @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())

        self = cls()

        self.prefix = collection["prefix"]
        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(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)
                # 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)
        else:
            src = json.load(src_file)

        return cls.from_dict(src, **kwargs)

    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()},
            "aliases": {name: alias.as_dict() for name, alias in self.aliases.items()},
        }
        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