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

62 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1# normalizer 

2 

3"""SVG element construction and sanitizing.""" 

4 

5from typing import Optional 

6 

7import re 

8 

9import gws 

10import gws.lib.xmlx as xmlx 

11import gws.lib.mime 

12import gws.lib.image 

13 

14_SVG_NAMESPACE = xmlx.namespace.new('', 'http://www.w3.org/2000/svg') 

15 

16 

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

18 """Wrap an SVG fragment in an ``<svg>`` element. 

19 

20 Elements are sorted by their ``z-index`` attribute, so that labels come after geometries. 

21 

22 Args: 

23 fragment: SVG elements. 

24 atts: Attributes of the ``<svg>`` element. 

25 

26 Returns: 

27 The ``<svg>`` element. 

28 """ 

29 

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

31 return _svg_tag(atts, *fr) 

32 

33 

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

35 """Render an SVG fragment as a raster image. 

36 

37 Args: 

38 fragment: SVG elements. 

39 size: Image size in pixels. 

40 mime_type: Image mime type. 

41 

42 Returns: 

43 The image. 

44 """ 

45 

46 el = fragment_to_element(fragment) 

47 return gws.lib.image.from_svg(el.to_string(), size, mime_type) 

48 

49 

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

51 """Remove unsafe content from an SVG element and normalize tag and attribute names. 

52 

53 Only allowed tags and attributes are kept. Attribute values must match a pattern for that attribute, 

54 and values that look like URLs (``data:``, ``http:``, ``javascript:`` etc.) are removed. 

55 Text content is kept only for text tags. The element itself is turned into an ``<svg>`` element. 

56 

57 Args: 

58 el: An SVG element. 

59 

60 Returns: 

61 A new, normalized ``<svg>`` element. 

62 """ 

63 

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

65 return _svg_tag(_normalize_atts(el.attrib), *children) 

66 

67 

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

69 """Remove unsafe content from an SVG fragment and normalize tag and attribute names. 

70 

71 Elements with tags that are not allowed are removed, together with their children. 

72 

73 Args: 

74 fragment: SVG elements. 

75 

76 Returns: 

77 A list of normalized elements. 

78 """ 

79 

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

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

82 

83 

84## 

85 

86 

87def _svg_tag(*args): 

88 el = xmlx.tag('svg', *args) 

89 el.declare(_SVG_NAMESPACE) 

90 return el 

91 

92 

93_ALLOWED_TAGS = { 

94 'circle', 

95 'clipPath', 

96 'defs', 

97 'ellipse', 

98 'g', 

99 'hatch', 

100 'hatchpath', 

101 'line', 

102 'linearGradient', 

103 'marker', 

104 'mask', 

105 'mesh', 

106 'meshgradient', 

107 'meshpatch', 

108 'meshrow', 

109 'path', 

110 'pattern', 

111 'polygon', 

112 'polyline', 

113 'radialGradient', 

114 'rect', 

115 'solidcolor', 

116 'stop', 

117 'symbol', 

118 'text', 

119 'title', 

120 'tspan', 

121} 

122 

123# tags whose text content is rendered 

124 

125_TEXT_TAGS = { 

126 'text', 

127 'title', 

128 'tspan', 

129} 

130 

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

132 

133# Regex patterns for attribute validation. 

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

135 

136_A = r'a-zA-Z' 

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

138_P = r'+.,%' 

139_W = rf'{_N}_' 

140 

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

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

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

144 

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

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

147_RE_KEYWORD = rf'^{_KW}$' 

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

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

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

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

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

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

154_RE_URL_REF = rf'^{_URL_REF}$' 

155 

156# Dictionary of allowed attributes with their validation patterns 

157 

158_ALLOWED_ATTRIBUTES: dict[str, str] = { 

159 'alignment-baseline': _RE_KEYWORD, 

160 'baseline-shift': _RE_NUMBER, 

161 'class': _RE_NAME_LIST, 

162 'clip': _RE_KEYWORD, 

163 'clip-path': _RE_URL_REF, 

164 'clip-rule': _RE_KEYWORD, 

165 'clipPathUnits': _RE_KEYWORD, 

166 'color': _RE_COLOR, 

167 'color-interpolation': _RE_KEYWORD, 

168 'color-interpolation-filters': _RE_KEYWORD, 

169 'color-profile': _RE_KEYWORD, 

170 'color-rendering': _RE_KEYWORD, 

171 'cursor': _RE_KEYWORD, 

172 'cx': _RE_NUMBER, 

173 'cy': _RE_NUMBER, 

174 'd': _RE_PATH, 

175 'direction': _RE_KEYWORD, 

176 'display': _RE_KEYWORD, 

177 'dominant-baseline': _RE_KEYWORD, 

178 'dx': _RE_NUMBER_LIST, 

179 'dy': _RE_NUMBER_LIST, 

180 'enable-background': _RE_KEYWORD, 

181 'fill': _RE_COLOR, 

182 'fill-opacity': _RE_NUMBER, 

183 'fill-rule': _RE_KEYWORD, 

184 'filter': _RE_URL_REF, 

185 'flood-color': _RE_COLOR, 

186 'flood-opacity': _RE_NUMBER, 

187 'font-family': _RE_FONT_FAMILY, 

188 'font-size': _RE_NUMBER, 

189 'font-size-adjust': _RE_NUMBER, 

190 'font-stretch': _RE_KEYWORD, 

191 'font-style': _RE_KEYWORD, 

192 'font-variant': _RE_KEYWORD, 

193 'font-weight': _RE_NUMBER, 

194 'fr': _RE_NUMBER, 

195 'fx': _RE_NUMBER, 

196 'fy': _RE_NUMBER, 

197 'glyph-orientation-horizontal': _RE_NUMBER, 

198 'glyph-orientation-vertical': _RE_NUMBER, 

199 'gradientTransform': _RE_TRANSFORM, 

200 'gradientUnits': _RE_KEYWORD, 

201 'hatchContentUnits': _RE_KEYWORD, 

202 'hatchUnits': _RE_KEYWORD, 

203 'id': _RE_NAME, 

204 'image-rendering': _RE_KEYWORD, 

205 'kerning': _RE_NUMBER, 

206 'lengthAdjust': _RE_KEYWORD, 

207 'letter-spacing': _RE_NUMBER, 

208 'lighting-color': _RE_COLOR, 

209 'marker-end': _RE_URL_REF, 

210 'marker-mid': _RE_URL_REF, 

211 'marker-start': _RE_URL_REF, 

212 'markerHeight': _RE_NUMBER, 

213 'markerUnits': _RE_KEYWORD, 

214 'markerWidth': _RE_NUMBER, 

215 'mask': _RE_URL_REF, 

216 'maskContentUnits': _RE_KEYWORD, 

217 'maskUnits': _RE_KEYWORD, 

218 'offset': _RE_NUMBER, 

219 'opacity': _RE_NUMBER, 

220 'orient': _RE_NUMBER, 

221 'overflow': _RE_KEYWORD, 

222 'pathLength': _RE_NUMBER, 

223 'patternContentUnits': _RE_KEYWORD, 

224 'patternTransform': _RE_TRANSFORM, 

225 'patternUnits': _RE_KEYWORD, 

226 'pitch': _RE_NUMBER, 

227 'pointer-events': _RE_KEYWORD, 

228 'points': _RE_NUMBER_LIST, 

229 'preserveAspectRatio': _RE_NAME_LIST, 

230 'r': _RE_NUMBER, 

231 'refX': _RE_NUMBER, 

232 'refY': _RE_NUMBER, 

233 'rotate': _RE_NUMBER_LIST, 

234 'rx': _RE_NUMBER, 

235 'ry': _RE_NUMBER, 

236 'shape-rendering': _RE_KEYWORD, 

237 'solid-color': _RE_COLOR, 

238 'solid-opacity': _RE_NUMBER, 

239 'spreadMethod': _RE_KEYWORD, 

240 'stop-color': _RE_COLOR, 

241 'stop-opacity': _RE_NUMBER, 

242 'stroke': _RE_COLOR, 

243 'stroke-dasharray': _RE_NUMBER_LIST, 

244 'stroke-dashoffset': _RE_NUMBER, 

245 'stroke-linecap': _RE_KEYWORD, 

246 'stroke-linejoin': _RE_KEYWORD, 

247 'stroke-miterlimit': _RE_NUMBER, 

248 'stroke-opacity': _RE_NUMBER, 

249 'stroke-width': _RE_NUMBER, 

250 'text-anchor': _RE_KEYWORD, 

251 'text-decoration': _RE_KEYWORD, 

252 'text-rendering': _RE_KEYWORD, 

253 'textLength': _RE_NUMBER, 

254 'transform': _RE_TRANSFORM, 

255 'transform-origin': _RE_NUMBER_LIST, 

256 'unicode-bidi': _RE_KEYWORD, 

257 'vector-effect': _RE_KEYWORD, 

258 'visibility': _RE_KEYWORD, 

259 'word-spacing': _RE_NUMBER, 

260 'writing-mode': _RE_KEYWORD, 

261 'width': _RE_NUMBER, 

262 'height': _RE_NUMBER, 

263 'viewBox': _RE_NUMBER_LIST, 

264 'x': _RE_NUMBER_LIST, 

265 'x1': _RE_NUMBER, 

266 'x2': _RE_NUMBER, 

267 'y': _RE_NUMBER_LIST, 

268 'y1': _RE_NUMBER, 

269 'y2': _RE_NUMBER, 

270} 

271 

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

273 

274_DENIED_VALUE_PREFIXES = ( 

275 'data:', 

276 'file:', 

277 'http:', 

278 'https:', 

279 'javascript:', 

280) 

281 

282 

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

284 name = _CANONICAL_TAGS.get(el.name.lower()) 

285 if name: 

286 return xmlx.tag( 

287 name, 

288 _normalize_atts(el.attrib), 

289 el.text if name in _TEXT_TAGS else None, 

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

291 

292 

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

294 res = {} 

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

296 # Skip if attribute is not in allowed list 

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

298 if not key: 

299 continue 

300 

301 val = str(v).strip() 

302 

303 # Skip URLs that could lead to XSS 

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

305 continue 

306 

307 # Validate attribute value against its regex pattern 

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

309 res[key] = val 

310 

311 return res