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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 12:46 +0200
1# normalizer
3from typing import Optional
5import re
7import gws
8import gws.lib.xmlx as xmlx
9import gws.lib.mime
10import gws.lib.image
12_SVG_TAG_ATTS = {
13 'xmlns': 'http://www.w3.org/2000/svg',
14}
17def fragment_to_element(fragment: list[gws.XmlElement], atts: dict = None) -> gws.XmlElement:
18 """Convert an SVG fragment to an SVG element."""
20 fr = sorted(fragment, key=lambda el: el.attrib.get('z-index', 0))
21 return xmlx.tag('svg', _SVG_TAG_ATTS, atts, *fr)
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."""
27 el = fragment_to_element(fragment)
28 return gws.lib.image.from_svg(el.to_string(), size, mime)
31def normalize_element(el: gws.XmlElement) -> gws.XmlElement:
32 """Remove unsafe stuff from an SVG element and normalize tag and attribute names."""
34 children = gws.u.compact(_normalize(c) for c in el)
35 return xmlx.tag('svg', _SVG_TAG_ATTS, _normalize_atts(el.attrib), *children)
38def normalize_fragment(fragment: list[gws.XmlElement]) -> list[gws.XmlElement]:
39 """Remove unsafe stuff from an SVG fragment and normalize tag and attribute names."""
41 els = [_normalize(el) for el in fragment]
42 return [el for el in els if el is not None]
45##
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}
77# tags whose text content is rendered
79_TEXT_TAGS = {
80 'text',
81 'title',
82 'tspan',
83}
85_CANONICAL_TAGS = {s.lower(): s for s in _ALLOWED_TAGS}
87# Regex patterns for attribute validation.
88# Only the syntax of a value is validated, not its semantics.
90_A = r'a-zA-Z'
91_N = r'a-zA-Z0-9'
92_P = r'+.,%'
93_W = rf'{_N}_'
95_ARGS = rf'[0-9{_P}\s-]+'
96_KW = rf'[{_A}-]+'
97_URL_REF = rf'url\(#[{_W}-]+\)'
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}$'
110# Dictionary of allowed attributes with their validation patterns
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}
226_CANONICAL_ATTRIBUTES = {s.lower(): s for s in _ALLOWED_ATTRIBUTES}
228_DENIED_VALUE_PREFIXES = (
229 'data:',
230 'file:',
231 'http:',
232 'https:',
233 'javascript:',
234)
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()))
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
255 val = str(v).strip()
257 # Skip URLs that could lead to XSS
258 if val.lower().startswith(_DENIED_VALUE_PREFIXES):
259 continue
261 # Validate attribute value against its regex pattern
262 if re.match(_ALLOWED_ATTRIBUTES[key], val):
263 res[key] = val
265 return res