Coverage for gws-app/gws/lib/style/icon.py: 76%

63 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""SVG icon parser.""" 

2 

3from typing import Optional 

4 

5import re 

6 

7import gws 

8import gws.lib.mime 

9import gws.lib.net 

10import gws.lib.svg 

11import gws.lib.osx 

12import gws.lib.text 

13import gws.lib.xmlx as xmlx 

14 

15 

16class Error(gws.Error): 

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

18 

19 pass 

20 

21 

22def to_data_url(svg: gws.XmlElement) -> str: 

23 """Convert an SVG icon to a base64 data URL. 

24 

25 Args: 

26 svg: SVG element. 

27 

28 Returns: 

29 A ``data:image/svg+xml;base64,...`` URL. 

30 """ 

31 

32 xml = svg.to_string() 

33 return gws.lib.text.to_data_url(xml, gws.lib.mime.SVG) 

34 

35 

36def parse(val: str, opts) -> Optional[gws.XmlElement]: 

37 """Load and parse an SVG icon. 

38 

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

40 

41 - a ``data:`` URL is decoded, 

42 - a path is looked up in the image directories (``opts.imageDirs``), 

43 - in the trusted mode only, an ``http(s)`` URL is fetched, or any other value is read as a file path. 

44 

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

46 

47 Args: 

48 val: Data URL, URL or path. 

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

50 

51 Returns: 

52 The normalized SVG element, or ``None`` if the value is empty or does not contain an SVG. 

53 

54 Raises: 

55 ``Error``: If the value is untrusted, cannot be loaded or decoded, or the SVG is invalid. 

56 """ 

57 if not val: 

58 return 

59 

60 val = str(val).strip() 

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

62 if m: 

63 val = m.group(1) 

64 

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

66 

67 bs = _get_bytes(val, opts) 

68 if not bs: 

69 return 

70 

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

72 return _parse_svg(bs.decode('utf8')) 

73 

74 # @TODO other icon formats? 

75 

76 

77## 

78 

79 

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

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

82 return _decode_data_url(val) 

83 

84 # if not trusted, looks in provided public dirs 

85 

86 for img_dir in opts.get('imageDirs', []): 

87 path = gws.lib.osx.abs_web_path(val, img_dir) 

88 if path: 

89 return gws.u.read_file_b(path) 

90 

91 # network and aribtrary files only in the trusted mode 

92 

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

94 raise Error('untrusted value', val) 

95 

96 if re.match(r'^https?:', val): 

97 try: 

98 return gws.lib.net.http_request(val).content 

99 except Exception as exc: 

100 raise Error('network error', val) from exc 

101 

102 try: 

103 return gws.u.read_file_b(val) 

104 except Exception as exc: 

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

106 

107 

108def _decode_data_url(val) -> Optional[bytes]: 

109 try: 

110 mime, content = gws.lib.text.parse_data_url(val) 

111 except gws.lib.text.Error as exc: 

112 raise Error('decode error', val) from exc 

113 if gws.lib.mime.get_base(mime) == gws.lib.mime.SVG: 

114 return content 

115 

116 

117def _parse_svg(val): 

118 try: 

119 el = xmlx.from_string(val, gws.XmlOptions(removeNamespaces=True)) 

120 except Exception as exc: 

121 raise Error('parse error', val) from exc 

122 

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

124 

125 w = el_clean.get('width') 

126 h = el_clean.get('height') 

127 

128 if not w or not h: 

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

130 

131 return el_clean