Coverage for gws-app/gws/lib/style/parser.py: 77%
139 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"""CSS style parser."""
3import re
5import gws
6from . import icon
9class Options(gws.Data):
10 """Style parser options."""
12 trusted: bool
13 """The style comes from a trusted source (the configuration). Only trusted styles can load icons from URLs and arbitrary files."""
14 strict: bool
15 """Raise an error on invalid properties and values, instead of logging and skipping them."""
16 imageDirs: list[str]
17 """Directories from which icons can be loaded in the untrusted mode."""
20def parse_dict(d: dict, opts: Options) -> dict:
21 """Parse a dict of CSS properties into style values.
23 Property names are normalized: dashes become underscores and a leading ``__`` (from ``--``) is removed.
24 ``None`` values are skipped. The result contains defaults for all values not given.
26 Args:
27 d: A dict of CSS property names and values.
28 opts: Parser options.
30 Returns:
31 A dict of style values.
33 Raises:
34 ``gws.Error``: If a property or value is invalid and ``opts.strict`` is set.
35 """
36 res = dict(_DEFAULTS)
38 for key, val in d.items():
39 if val is None:
40 continue
41 k = key.replace('-', '_')
42 if k.startswith('__'):
43 k = k[2:]
45 fn = gws.u.get(_ParseFunctions, k)
47 if not fn:
48 err = f'style: invalid css property {key!r}'
49 if opts.strict:
50 raise gws.Error(err)
51 else:
52 gws.log.error(err)
53 continue
55 try:
56 v = fn(val, opts)
57 if v is not None:
58 res[k] = v
59 except Exception as exc:
60 err = f'style: invalid css value for {key!r}: {val!r}'
61 if opts.strict:
62 raise gws.Error(err) from exc
63 else:
64 gws.log.error(err)
66 return res
69# @TODO use a real CSS parser
71def parse_text(text: str, opts: Options) -> dict:
72 """Parse CSS text into style values.
74 Args:
75 text: CSS declarations like ``'a: b; c: d; ...'``, without a selector and braces.
76 opts: Parser options.
78 Returns:
79 A dict of style values.
81 Raises:
82 ``gws.Error``: If a property or value is invalid and ``opts.strict`` is set.
83 """
84 d = {}
85 for r in text.split(';'):
86 r = r.strip()
87 if not r:
88 continue
89 a, _, b = r.partition(':')
90 d[a.strip()] = b.strip()
91 return parse_dict(d, opts)
94##
97_DEFAULTS: dict = dict(
98 fill=None,
100 stroke=None,
101 stroke_dasharray=[],
102 stroke_dashoffset=0,
103 stroke_linecap='butt',
104 stroke_linejoin='miter',
105 stroke_miterlimit=0,
106 stroke_width=0,
108 marker_size=0,
109 marker_stroke_dasharray=[],
110 marker_stroke_dashoffset=0,
111 marker_stroke_linecap='butt',
112 marker_stroke_linejoin='miter',
113 marker_stroke_miterlimit=0,
114 marker_stroke_width=0,
116 with_geometry='all',
117 with_label='all',
119 label_align='center',
120 label_font_family='sans-serif',
121 label_font_size=12,
122 label_font_style='normal',
123 label_font_weight='normal',
124 label_line_height=1,
125 label_max_scale=1000000000,
126 label_min_scale=0,
127 label_offset_x=None,
128 label_offset_y=None,
129 label_placement='middle',
130 label_stroke_dasharray=[],
131 label_stroke_dashoffset=0,
132 label_stroke_linecap='butt',
133 label_stroke_linejoin='miter',
134 label_stroke_miterlimit=0,
135 label_stroke_width=0,
137 point_size=10,
138 icon=None,
139 parsed_icon=None,
141 offset_x=0,
142 offset_y=0,
143)
145_ENUMS = dict(
146 stroke_linecap=['butt', 'round', 'square'],
147 stroke_linejoin=['bevel', 'round', 'miter'],
148 marker=['circle', 'square', 'arrow', 'cross'],
149 with_geometry=['all', 'none'],
150 with_label=['all', 'none'],
151 label_align=['left', 'right', 'center'],
152 label_font_style=['normal', 'italic'],
153 label_font_weight=['normal', 'bold'],
154 label_padding=[int],
155 label_placement=['start', 'end', 'middle'],
156 label_stroke_dasharray=[int],
157)
159_COLOR_PATTERNS = (
160 r'^#[0-9a-fA-F]{3}$',
161 r'^#[0-9a-fA-F]{6}$',
162 r'^#[0-9a-fA-F]{8}$',
163 r'^rgb\(\d{1,3},\d{1,3},\d{1,3}\)$',
164 r'^rgba\(\d{1,3},\d{1,3},\d{1,3},\d?(\.\d{1,3})?\)$',
165 r'^[a-z]{3,50}$',
166)
169##
171def _parse_color(val, opts):
172 val = re.sub(r'\s+', '', str(val))
173 if any(re.match(p, val) for p in _COLOR_PATTERNS):
174 return val
175 raise ValueError
178def _parse_intlist(val, opts):
179 return [int(x) for x in _make_list(val)]
182def _parse_icon(val, opts):
183 return icon.parse(val, opts)
186def _parse_unitint(val, opts):
187 return _unitint(val)
190def _parse_unitintquad(val, opts):
191 val = [_unitint(x) for x in _make_list(val)]
192 if any(x is None for x in val):
193 return None
194 if len(val) == 4:
195 return val
196 if len(val) == 2:
197 return [val[0], val[1], val[0], val[1]]
198 if len(val) == 1:
199 return [val[0], val[0], val[0], val[0]]
200 raise ValueError
203def _parse_enum_fn(cls):
204 def _check(val, opts):
205 vals = _ENUMS.get(cls)
206 if vals and val in vals:
207 return val
209 return _check
212def _parse_int(val, opts):
213 return int(val)
216def _parse_str(val, opts):
217 val = str(val).strip()
218 return val or None
221##
223def _unitint(val):
224 if isinstance(val, int):
225 return val
227 if val.isdigit():
228 # unitless = assume pixels
229 return int(val)
231 m = re.match(r'^(-?\d+)([a-z]*)', str(val))
232 if not m:
233 return
235 # @TODO support other units (need to pass dpi here)
236 if m.group(2) != 'px':
237 return
239 return int(m.group(1))
242def _make_list(val):
243 if isinstance(val, (list, tuple)):
244 return val
245 return re.split(r'[,\s]+', str(val))
248##
251class _ParseFunctions:
252 fill = _parse_color
254 stroke = _parse_color
255 stroke_dasharray = _parse_intlist
256 stroke_dashoffset = _parse_unitint
257 stroke_linecap = _parse_enum_fn('stroke_linecap')
258 stroke_linejoin = _parse_enum_fn('stroke_linejoin')
259 stroke_miterlimit = _parse_unitint
260 stroke_width = _parse_unitint
262 marker = _parse_enum_fn('marker')
263 marker_type = _parse_enum_fn('marker')
264 marker_fill = _parse_color
265 marker_size = _parse_unitint
266 marker_stroke = _parse_color
267 marker_stroke_dasharray = _parse_intlist
268 marker_stroke_dashoffset = _parse_unitint
269 marker_stroke_linecap = _parse_enum_fn('stroke_linecap')
270 marker_stroke_linejoin = _parse_enum_fn('stroke_linejoin')
271 marker_stroke_miterlimit = _parse_unitint
272 marker_stroke_width = _parse_unitint
274 with_geometry = _parse_enum_fn('with_geometry')
275 with_label = _parse_enum_fn('with_label')
277 label_align = _parse_enum_fn('label_align')
278 label_background = _parse_color
279 label_fill = _parse_color
280 label_font_family = _parse_str
281 label_font_size = _parse_unitint
282 label_font_style = _parse_enum_fn('label_font_style')
283 label_font_weight = _parse_enum_fn('label_font_weight')
284 label_line_height = _parse_int
285 label_max_scale = _parse_int
286 label_min_scale = _parse_int
287 label_offset_x = _parse_unitint
288 label_offset_y = _parse_unitint
289 label_padding = _parse_unitintquad
290 label_placement = _parse_enum_fn('label_placement')
291 label_stroke = _parse_color
292 label_stroke_dasharray = _parse_intlist
293 label_stroke_dashoffset = _parse_unitint
294 label_stroke_linecap = _parse_enum_fn('stroke_linecap')
295 label_stroke_linejoin = _parse_enum_fn('stroke_linejoin')
296 label_stroke_miterlimit = _parse_unitint
297 label_stroke_width = _parse_unitint
299 point_size = _parse_unitint
300 icon = _parse_icon
302 offset_x = _parse_unitint
303 offset_y = _parse_unitint