Coverage for gws-app/gws/lib/style/icon.py: 75%
72 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1"""SVG icon parser."""
3from typing import Optional
5import base64
6import re
8import gws
9import gws.lib.net
10import gws.lib.svg
11import gws.lib.osx
12import gws.lib.xmlx as xmlx
15class Error(gws.Error):
16 """Raised when an icon cannot be loaded or parsed."""
18 pass
21class ParsedIcon(gws.Data):
22 """A parsed icon."""
24 svg: gws.XmlElement
25 """The normalized SVG element."""
28def to_data_url(icon: ParsedIcon) -> str:
29 """Convert a parsed icon to a base64 data URL.
31 Args:
32 icon: Parsed icon.
34 Returns:
35 A ``data:image/svg+xml;base64,...`` URL, or an empty string if the icon has no SVG.
36 """
38 if icon.svg:
39 xml = icon.svg.to_string()
40 return 'data:image/svg+xml;base64,' + base64.standard_b64encode(xml.encode('utf8')).decode('utf8')
41 return ''
44def parse(val: str, opts) -> Optional[ParsedIcon]:
45 """Load and parse an SVG icon.
47 The value can be wrapped in CSS ``url(...)`` and quotes. It is resolved as follows:
49 - a ``data:`` URL is decoded,
50 - a path is looked up in the image directories (``opts.imageDirs``),
51 - in the trusted mode only, an ``http(s)`` URL is fetched, or any other value is read as a file path.
53 The SVG is normalized (unsafe elements and attributes are removed) and must have a width and a height.
55 Args:
56 val: Data URL, URL or path.
57 opts: Parser options (``gws.lib.style.parser.Options``).
59 Returns:
60 The parsed icon, or ``None`` if the value is empty or does not contain an SVG.
62 Raises:
63 ``Error``: If the value is untrusted, cannot be loaded or decoded, or the SVG is invalid.
64 """
65 if not val:
66 return
68 val = str(val).strip()
69 m = re.match(r'^url\((.+?)\)$', val)
70 if m:
71 val = m.group(1)
73 val = val.strip('\'\"')
75 bs = _get_bytes(val, opts)
76 if not bs:
77 return
79 if bs.startswith(b'<'):
80 svg = _parse_svg(bs.decode('utf8'))
81 if svg:
82 return ParsedIcon(svg=svg)
84 # @TODO other icon formats?
87##
90def _get_bytes(val, opts) -> Optional[bytes]:
91 if val.startswith('data:'):
92 return _decode_data_url(val, opts)
94 # if not trusted, looks in provided public dirs
96 for img_dir in opts.get('imageDirs', []):
97 path = gws.lib.osx.abs_web_path(val, img_dir)
98 if path:
99 return gws.u.read_file_b(path)
101 # network and aribtrary files only in the trusted mode
103 if not opts.get('trusted'):
104 raise Error('untrusted value', val)
106 if re.match(r'^https?:', val):
107 try:
108 return gws.lib.net.http_request(val).content
109 except Exception as exc:
110 raise Error('network error', val) from exc
112 try:
113 return gws.u.read_file_b(val)
114 except Exception as exc:
115 raise Error('file error', val) from exc
118_PREFIXES = [
119 'data:image/svg+xml;base64,',
120 'data:image/svg+xml;utf8,',
121 'data:image/svg;base64,',
122 'data:image/svg;utf8,',
123]
126def _decode_data_url(val, trusted) -> Optional[bytes]:
127 for pfx in _PREFIXES:
128 if val.startswith(pfx):
129 s = val[len(pfx):]
130 try:
131 if 'base64' in pfx:
132 return base64.standard_b64decode(s)
133 else:
134 return s.encode('utf8')
135 except Exception as exc:
136 raise Error('decode error', val) from exc
139def _parse_svg(val):
140 try:
141 el = xmlx.from_string(val, gws.XmlOptions(removeNamespaces=True))
142 except Exception as exc:
143 raise Error('parse error', val) from exc
145 el_clean = gws.lib.svg.normalize_element(el)
147 w = el_clean.get('width')
148 h = el_clean.get('height')
150 if not w or not h:
151 raise Error('missing width or height', val)
153 return el_clean