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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +0200
1# normalizer
3"""SVG element construction and sanitizing."""
5from typing import Optional
7import re
9import gws
10import gws.lib.xmlx as xmlx
11import gws.lib.mime
12import gws.lib.image
14_SVG_NAMESPACE = xmlx.namespace.new('', 'http://www.w3.org/2000/svg')
17def fragment_to_element(fragment: list[gws.XmlElement], atts: dict = None) -> gws.XmlElement:
18 """Wrap an SVG fragment in an ``<svg>`` element.
20 Elements are sorted by their ``z-index`` attribute, so that labels come after geometries.
22 Args:
23 fragment: SVG elements.
24 atts: Attributes of the ``<svg>`` element.
26 Returns:
27 The ``<svg>`` element.
28 """
30 fr = sorted(fragment, key=lambda el: el.attrib.get('z-index', 0))
31 return _svg_tag(atts, *fr)
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.
37 Args:
38 fragment: SVG elements.
39 size: Image size in pixels.
40 mime_type: Image mime type.
42 Returns:
43 The image.
44 """
46 el = fragment_to_element(fragment)
47 return gws.lib.image.from_svg(el.to_string(), size, mime_type)
50def normalize_element(el: gws.XmlElement) -> gws.XmlElement:
51 """Remove unsafe content from an SVG element and normalize tag and attribute names.
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.
57 Args:
58 el: An SVG element.
60 Returns:
61 A new, normalized ``<svg>`` element.
62 """
64 children = gws.u.compact(_normalize(c) for c in el)
65 return _svg_tag(_normalize_atts(el.attrib), *children)
68def normalize_fragment(fragment: list[gws.XmlElement]) -> list[gws.XmlElement]:
69 """Remove unsafe content from an SVG fragment and normalize tag and attribute names.
71 Elements with tags that are not allowed are removed, together with their children.
73 Args:
74 fragment: SVG elements.
76 Returns:
77 A list of normalized elements.
78 """
80 els = [_normalize(el) for el in fragment]
81 return [el for el in els if el is not None]
84##
87def _svg_tag(*args):
88 el = xmlx.tag('svg', *args)
89 el.declare(_SVG_NAMESPACE)
90 return el
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}
123# tags whose text content is rendered
125_TEXT_TAGS = {
126 'text',
127 'title',
128 'tspan',
129}
131_CANONICAL_TAGS = {s.lower(): s for s in _ALLOWED_TAGS}
133# Regex patterns for attribute validation.
134# Only the syntax of a value is validated, not its semantics.
136_A = r'a-zA-Z'
137_N = r'a-zA-Z0-9'
138_P = r'+.,%'
139_W = rf'{_N}_'
141_ARGS = rf'[0-9{_P}\s-]+'
142_KW = rf'[{_A}-]+'
143_URL_REF = rf'url\(#[{_W}-]+\)'
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}$'
156# Dictionary of allowed attributes with their validation patterns
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}
272_CANONICAL_ATTRIBUTES = {s.lower(): s for s in _ALLOWED_ATTRIBUTES}
274_DENIED_VALUE_PREFIXES = (
275 'data:',
276 'file:',
277 'http:',
278 'https:',
279 'javascript:',
280)
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()))
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
301 val = str(v).strip()
303 # Skip URLs that could lead to XSS
304 if val.lower().startswith(_DENIED_VALUE_PREFIXES):
305 continue
307 # Validate attribute value against its regex pattern
308 if re.match(_ALLOWED_ATTRIBUTES[key], val):
309 res[key] = val
311 return res