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

1"""SVG icon parser.""" 

2 

3from typing import Optional 

4 

5import base64 

6import re 

7 

8import gws 

9import gws.lib.net 

10import gws.lib.svg 

11import gws.lib.osx 

12import gws.lib.xmlx as xmlx 

13 

14 

15class Error(gws.Error): 

16 """Raised when an icon cannot be loaded or parsed.""" 

17 

18 pass 

19 

20 

21class ParsedIcon(gws.Data): 

22 """A parsed icon.""" 

23 

24 svg: gws.XmlElement 

25 """The normalized SVG element.""" 

26 

27 

28def to_data_url(icon: ParsedIcon) -> str: 

29 """Convert a parsed icon to a base64 data URL. 

30 

31 Args: 

32 icon: Parsed icon. 

33 

34 Returns: 

35 A ``data:image/svg+xml;base64,...`` URL, or an empty string if the icon has no SVG. 

36 """ 

37 

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

42 

43 

44def parse(val: str, opts) -> Optional[ParsedIcon]: 

45 """Load and parse an SVG icon. 

46 

47 The value can be wrapped in CSS ``url(...)`` and quotes. It is resolved as follows: 

48 

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. 

52 

53 The SVG is normalized (unsafe elements and attributes are removed) and must have a width and a height. 

54 

55 Args: 

56 val: Data URL, URL or path. 

57 opts: Parser options (``gws.lib.style.parser.Options``). 

58 

59 Returns: 

60 The parsed icon, or ``None`` if the value is empty or does not contain an SVG. 

61 

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 

67 

68 val = str(val).strip() 

69 m = re.match(r'^url\((.+?)\)$', val) 

70 if m: 

71 val = m.group(1) 

72 

73 val = val.strip('\'\"') 

74 

75 bs = _get_bytes(val, opts) 

76 if not bs: 

77 return 

78 

79 if bs.startswith(b'<'): 

80 svg = _parse_svg(bs.decode('utf8')) 

81 if svg: 

82 return ParsedIcon(svg=svg) 

83 

84 # @TODO other icon formats? 

85 

86 

87## 

88 

89 

90def _get_bytes(val, opts) -> Optional[bytes]: 

91 if val.startswith('data:'): 

92 return _decode_data_url(val, opts) 

93 

94 # if not trusted, looks in provided public dirs 

95 

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) 

100 

101 # network and aribtrary files only in the trusted mode 

102 

103 if not opts.get('trusted'): 

104 raise Error('untrusted value', val) 

105 

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 

111 

112 try: 

113 return gws.u.read_file_b(val) 

114 except Exception as exc: 

115 raise Error('file error', val) from exc 

116 

117 

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] 

124 

125 

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 

137 

138 

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 

144 

145 el_clean = gws.lib.svg.normalize_element(el) 

146 

147 w = el_clean.get('width') 

148 h = el_clean.get('height') 

149 

150 if not w or not h: 

151 raise Error('missing width or height', val) 

152 

153 return el_clean