Coverage for gws-app/gws/lib/svg/element.py: 97%

58 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 12:46 +0200

1# normalizer 

2 

3from typing import Optional 

4 

5import re 

6 

7import gws 

8import gws.lib.xmlx as xmlx 

9import gws.lib.mime 

10import gws.lib.image 

11 

12_SVG_TAG_ATTS = { 

13 'xmlns': 'http://www.w3.org/2000/svg', 

14} 

15 

16 

17def fragment_to_element(fragment: list[gws.XmlElement], atts: dict = None) -> gws.XmlElement: 

18 """Convert an SVG fragment to an SVG element.""" 

19 

20 fr = sorted(fragment, key=lambda el: el.attrib.get('z-index', 0)) 

21 return xmlx.tag('svg', _SVG_TAG_ATTS, atts, *fr) 

22 

23 

24def fragment_to_image(fragment: list[gws.XmlElement], size: gws.Size, mime=gws.lib.mime.PNG) -> gws.lib.image.Image: 

25 """Convert an SVG fragment to a raster image.""" 

26 

27 el = fragment_to_element(fragment) 

28 return gws.lib.image.from_svg(el.to_string(), size, mime) 

29 

30 

31def normalize_element(el: gws.XmlElement) -> gws.XmlElement: 

32 """Remove unsafe stuff from an SVG element and normalize tag and attribute names.""" 

33 

34 children = gws.u.compact(_normalize(c) for c in el) 

35 return xmlx.tag('svg', _SVG_TAG_ATTS, _normalize_atts(el.attrib), *children) 

36 

37 

38def normalize_fragment(fragment: list[gws.XmlElement]) -> list[gws.XmlElement]: 

39 """Remove unsafe stuff from an SVG fragment and normalize tag and attribute names.""" 

40 

41 els = [_normalize(el) for el in fragment] 

42 return [el for el in els if el is not None] 

43 

44 

45## 

46 

47_ALLOWED_TAGS = { 

48 'circle', 

49 'clipPath', 

50 'defs', 

51 'ellipse', 

52 'g', 

53 'hatch', 

54 'hatchpath', 

55 'line', 

56 'linearGradient', 

57 'marker', 

58 'mask', 

59 'mesh', 

60 'meshgradient', 

61 'meshpatch', 

62 'meshrow', 

63 'path', 

64 'pattern', 

65 'polygon', 

66 'polyline', 

67 'radialGradient', 

68 'rect', 

69 'solidcolor', 

70 'stop', 

71 'symbol', 

72 'text', 

73 'title', 

74 'tspan', 

75} 

76 

77# tags whose text content is rendered 

78 

79_TEXT_TAGS = { 

80 'text', 

81 'title', 

82 'tspan', 

83} 

84 

85_CANONICAL_TAGS = {s.lower(): s for s in _ALLOWED_TAGS} 

86 

87# Regex patterns for attribute validation. 

88# Only the syntax of a value is validated, not its semantics. 

89 

90_A = r'a-zA-Z' 

91_N = r'a-zA-Z0-9' 

92_P = r'+.,%' 

93_W = rf'{_N}_' 

94 

95_ARGS = rf'[0-9{_P}\s-]+' 

96_KW = rf'[{_A}-]+' 

97_URL_REF = rf'url\(#[{_W}-]+\)' 

98 

99_RE_COLOR = rf'^(#[{_N}]+|{_URL_REF}|{_KW}\({_ARGS}\)|{_KW})$' 

100_RE_FONT_FAMILY = rf'^[{_W}.,\s-]+$' 

101_RE_KEYWORD = rf'^{_KW}$' 

102_RE_NAME = rf'^[{_W}-]+$' 

103_RE_NAME_LIST = rf'^[{_W}\s-]*$' 

104_RE_NUMBER = rf'^[{_N}{_P}-]+$' 

105_RE_NUMBER_LIST = rf'^[{_N}{_P}\s-]+$' 

106_RE_PATH = rf'^[{_N},.\s-]+$' 

107_RE_TRANSFORM = rf'^{_KW}\({_ARGS}\)(\s{_KW}\({_ARGS}\))*$' 

108_RE_URL_REF = rf'^{_URL_REF}$' 

109 

110# Dictionary of allowed attributes with their validation patterns 

111 

112_ALLOWED_ATTRIBUTES: dict[str, str] = { 

113 'alignment-baseline': _RE_KEYWORD, 

114 'baseline-shift': _RE_NUMBER, 

115 'class': _RE_NAME_LIST, 

116 'clip': _RE_KEYWORD, 

117 'clip-path': _RE_URL_REF, 

118 'clip-rule': _RE_KEYWORD, 

119 'clipPathUnits': _RE_KEYWORD, 

120 'color': _RE_COLOR, 

121 'color-interpolation': _RE_KEYWORD, 

122 'color-interpolation-filters': _RE_KEYWORD, 

123 'color-profile': _RE_KEYWORD, 

124 'color-rendering': _RE_KEYWORD, 

125 'cursor': _RE_KEYWORD, 

126 'cx': _RE_NUMBER, 

127 'cy': _RE_NUMBER, 

128 'd': _RE_PATH, 

129 'direction': _RE_KEYWORD, 

130 'display': _RE_KEYWORD, 

131 'dominant-baseline': _RE_KEYWORD, 

132 'dx': _RE_NUMBER_LIST, 

133 'dy': _RE_NUMBER_LIST, 

134 'enable-background': _RE_KEYWORD, 

135 'fill': _RE_COLOR, 

136 'fill-opacity': _RE_NUMBER, 

137 'fill-rule': _RE_KEYWORD, 

138 'filter': _RE_URL_REF, 

139 'flood-color': _RE_COLOR, 

140 'flood-opacity': _RE_NUMBER, 

141 'font-family': _RE_FONT_FAMILY, 

142 'font-size': _RE_NUMBER, 

143 'font-size-adjust': _RE_NUMBER, 

144 'font-stretch': _RE_KEYWORD, 

145 'font-style': _RE_KEYWORD, 

146 'font-variant': _RE_KEYWORD, 

147 'font-weight': _RE_NUMBER, 

148 'fr': _RE_NUMBER, 

149 'fx': _RE_NUMBER, 

150 'fy': _RE_NUMBER, 

151 'glyph-orientation-horizontal': _RE_NUMBER, 

152 'glyph-orientation-vertical': _RE_NUMBER, 

153 'gradientTransform': _RE_TRANSFORM, 

154 'gradientUnits': _RE_KEYWORD, 

155 'hatchContentUnits': _RE_KEYWORD, 

156 'hatchUnits': _RE_KEYWORD, 

157 'id': _RE_NAME, 

158 'image-rendering': _RE_KEYWORD, 

159 'kerning': _RE_NUMBER, 

160 'lengthAdjust': _RE_KEYWORD, 

161 'letter-spacing': _RE_NUMBER, 

162 'lighting-color': _RE_COLOR, 

163 'marker-end': _RE_URL_REF, 

164 'marker-mid': _RE_URL_REF, 

165 'marker-start': _RE_URL_REF, 

166 'markerHeight': _RE_NUMBER, 

167 'markerUnits': _RE_KEYWORD, 

168 'markerWidth': _RE_NUMBER, 

169 'mask': _RE_URL_REF, 

170 'maskContentUnits': _RE_KEYWORD, 

171 'maskUnits': _RE_KEYWORD, 

172 'offset': _RE_NUMBER, 

173 'opacity': _RE_NUMBER, 

174 'orient': _RE_NUMBER, 

175 'overflow': _RE_KEYWORD, 

176 'pathLength': _RE_NUMBER, 

177 'patternContentUnits': _RE_KEYWORD, 

178 'patternTransform': _RE_TRANSFORM, 

179 'patternUnits': _RE_KEYWORD, 

180 'pitch': _RE_NUMBER, 

181 'pointer-events': _RE_KEYWORD, 

182 'points': _RE_NUMBER_LIST, 

183 'preserveAspectRatio': _RE_NAME_LIST, 

184 'r': _RE_NUMBER, 

185 'refX': _RE_NUMBER, 

186 'refY': _RE_NUMBER, 

187 'rotate': _RE_NUMBER_LIST, 

188 'rx': _RE_NUMBER, 

189 'ry': _RE_NUMBER, 

190 'shape-rendering': _RE_KEYWORD, 

191 'solid-color': _RE_COLOR, 

192 'solid-opacity': _RE_NUMBER, 

193 'spreadMethod': _RE_KEYWORD, 

194 'stop-color': _RE_COLOR, 

195 'stop-opacity': _RE_NUMBER, 

196 'stroke': _RE_COLOR, 

197 'stroke-dasharray': _RE_NUMBER_LIST, 

198 'stroke-dashoffset': _RE_NUMBER, 

199 'stroke-linecap': _RE_KEYWORD, 

200 'stroke-linejoin': _RE_KEYWORD, 

201 'stroke-miterlimit': _RE_NUMBER, 

202 'stroke-opacity': _RE_NUMBER, 

203 'stroke-width': _RE_NUMBER, 

204 'text-anchor': _RE_KEYWORD, 

205 'text-decoration': _RE_KEYWORD, 

206 'text-rendering': _RE_KEYWORD, 

207 'textLength': _RE_NUMBER, 

208 'transform': _RE_TRANSFORM, 

209 'transform-origin': _RE_NUMBER_LIST, 

210 'unicode-bidi': _RE_KEYWORD, 

211 'vector-effect': _RE_KEYWORD, 

212 'visibility': _RE_KEYWORD, 

213 'word-spacing': _RE_NUMBER, 

214 'writing-mode': _RE_KEYWORD, 

215 'width': _RE_NUMBER, 

216 'height': _RE_NUMBER, 

217 'viewBox': _RE_NUMBER_LIST, 

218 'x': _RE_NUMBER_LIST, 

219 'x1': _RE_NUMBER, 

220 'x2': _RE_NUMBER, 

221 'y': _RE_NUMBER_LIST, 

222 'y1': _RE_NUMBER, 

223 'y2': _RE_NUMBER, 

224} 

225 

226_CANONICAL_ATTRIBUTES = {s.lower(): s for s in _ALLOWED_ATTRIBUTES} 

227 

228_DENIED_VALUE_PREFIXES = ( 

229 'data:', 

230 'file:', 

231 'http:', 

232 'https:', 

233 'javascript:', 

234) 

235 

236 

237def _normalize(el: gws.XmlElement) -> Optional[gws.XmlElement]: 

238 name = _CANONICAL_TAGS.get(el.lcName) 

239 if name: 

240 return xmlx.tag( 

241 name, 

242 _normalize_atts(el.attrib), 

243 el.text if name in _TEXT_TAGS else None, 

244 gws.u.compact(_normalize(c) for c in el.children())) 

245 

246 

247def _normalize_atts(atts: dict) -> dict: 

248 res = {} 

249 for k, v in atts.items(): 

250 # Skip if attribute is not in allowed list 

251 key = _CANONICAL_ATTRIBUTES.get(k.lower()) 

252 if not key: 

253 continue 

254 

255 val = str(v).strip() 

256 

257 # Skip URLs that could lead to XSS 

258 if val.lower().startswith(_DENIED_VALUE_PREFIXES): 

259 continue 

260 

261 # Validate attribute value against its regex pattern 

262 if re.match(_ALLOWED_ATTRIBUTES[key], val): 

263 res[key] = val 

264 

265 return res