Coverage for gws-app/gws/lib/style/parser.py: 77%

139 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +0200

1"""CSS style parser.""" 

2 

3import re 

4 

5import gws 

6from . import icon 

7 

8 

9class Options(gws.Data): 

10 """Style parser options.""" 

11 

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.""" 

18 

19 

20def parse_dict(d: dict, opts: Options) -> dict: 

21 """Parse a dict of CSS properties into style values. 

22 

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. 

25 

26 Args: 

27 d: A dict of CSS property names and values. 

28 opts: Parser options. 

29 

30 Returns: 

31 A dict of style values. 

32 

33 Raises: 

34 ``gws.Error``: If a property or value is invalid and ``opts.strict`` is set. 

35 """ 

36 res = dict(_DEFAULTS) 

37 

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:] 

44 

45 fn = gws.u.get(_ParseFunctions, k) 

46 

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 

54 

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) 

65 

66 return res 

67 

68 

69# @TODO use a real CSS parser 

70 

71def parse_text(text: str, opts: Options) -> dict: 

72 """Parse CSS text into style values. 

73 

74 Args: 

75 text: CSS declarations like ``'a: b; c: d; ...'``, without a selector and braces. 

76 opts: Parser options. 

77 

78 Returns: 

79 A dict of style values. 

80 

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) 

92 

93 

94## 

95 

96 

97_DEFAULTS: dict = dict( 

98 fill=None, 

99 

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, 

107 

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, 

115 

116 with_geometry='all', 

117 with_label='none', 

118 

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, 

136 

137 point_size=10, 

138 icon=None, 

139 

140 offset_x=0, 

141 offset_y=0, 

142) 

143 

144_ENUMS = dict( 

145 stroke_linecap=['butt', 'round', 'square'], 

146 stroke_linejoin=['bevel', 'round', 'miter'], 

147 marker=['circle', 'square', 'arrow', 'cross'], 

148 with_geometry=['all', 'none'], 

149 with_label=['all', 'none'], 

150 label_align=['left', 'right', 'center'], 

151 label_font_style=['normal', 'italic'], 

152 label_font_weight=['normal', 'bold'], 

153 label_placement=['start', 'end', 'middle'], 

154) 

155 

156_COLOR_PATTERNS = ( 

157 r'^#[0-9a-fA-F]{3}$', 

158 r'^#[0-9a-fA-F]{6}$', 

159 r'^#[0-9a-fA-F]{8}$', 

160 r'^rgb\(\d{1,3},\d{1,3},\d{1,3}\)$', 

161 r'^rgba\(\d{1,3},\d{1,3},\d{1,3},\d?(\.\d{1,3})?\)$', 

162 r'^[a-z]{3,50}$', 

163) 

164 

165 

166## 

167 

168def _parse_color(val, opts): 

169 val = re.sub(r'\s+', '', str(val)) 

170 if any(re.match(p, val) for p in _COLOR_PATTERNS): 

171 return val 

172 raise ValueError 

173 

174 

175def _parse_intlist(val, opts): 

176 return [int(x) for x in _make_list(val)] 

177 

178 

179def _parse_icon(val, opts): 

180 return icon.parse(val, opts) 

181 

182 

183def _parse_unitint(val, opts): 

184 return _unitint(val) 

185 

186 

187def _parse_unitintquad(val, opts): 

188 val = [_unitint(x) for x in _make_list(val)] 

189 if any(x is None for x in val): 

190 return None 

191 if len(val) == 4: 

192 return val 

193 if len(val) == 2: 

194 return [val[0], val[1], val[0], val[1]] 

195 if len(val) == 1: 

196 return [val[0], val[0], val[0], val[0]] 

197 raise ValueError 

198 

199 

200def _parse_enum_fn(cls): 

201 def _check(val, opts): 

202 vals = _ENUMS.get(cls) 

203 if vals and val in vals: 

204 return val 

205 

206 return _check 

207 

208 

209def _parse_int(val, opts): 

210 return int(val) 

211 

212 

213def _parse_str(val, opts): 

214 val = str(val).strip() 

215 return val or None 

216 

217 

218## 

219 

220def _unitint(val): 

221 if isinstance(val, int): 

222 return val 

223 

224 if val.isdigit(): 

225 # unitless = assume pixels 

226 return int(val) 

227 

228 m = re.match(r'^(-?\d+)([a-z]*)', str(val)) 

229 if not m: 

230 return 

231 

232 # @TODO support other units (need to pass dpi here) 

233 if m.group(2) != 'px': 

234 return 

235 

236 return int(m.group(1)) 

237 

238 

239def _make_list(val): 

240 if isinstance(val, (list, tuple)): 

241 return val 

242 return re.split(r'[,\s]+', str(val)) 

243 

244 

245## 

246 

247 

248class _ParseFunctions: 

249 fill = _parse_color 

250 

251 stroke = _parse_color 

252 stroke_dasharray = _parse_intlist 

253 stroke_dashoffset = _parse_unitint 

254 stroke_linecap = _parse_enum_fn('stroke_linecap') 

255 stroke_linejoin = _parse_enum_fn('stroke_linejoin') 

256 stroke_miterlimit = _parse_unitint 

257 stroke_width = _parse_unitint 

258 

259 marker = _parse_enum_fn('marker') 

260 marker_type = _parse_enum_fn('marker') 

261 marker_fill = _parse_color 

262 marker_size = _parse_unitint 

263 marker_stroke = _parse_color 

264 marker_stroke_dasharray = _parse_intlist 

265 marker_stroke_dashoffset = _parse_unitint 

266 marker_stroke_linecap = _parse_enum_fn('stroke_linecap') 

267 marker_stroke_linejoin = _parse_enum_fn('stroke_linejoin') 

268 marker_stroke_miterlimit = _parse_unitint 

269 marker_stroke_width = _parse_unitint 

270 

271 with_geometry = _parse_enum_fn('with_geometry') 

272 with_label = _parse_enum_fn('with_label') 

273 

274 label_align = _parse_enum_fn('label_align') 

275 label_background = _parse_color 

276 label_fill = _parse_color 

277 label_font_family = _parse_str 

278 label_font_size = _parse_unitint 

279 label_font_style = _parse_enum_fn('label_font_style') 

280 label_font_weight = _parse_enum_fn('label_font_weight') 

281 label_line_height = _parse_int 

282 label_max_scale = _parse_int 

283 label_min_scale = _parse_int 

284 label_offset_x = _parse_unitint 

285 label_offset_y = _parse_unitint 

286 label_padding = _parse_unitintquad 

287 label_placement = _parse_enum_fn('label_placement') 

288 label_stroke = _parse_color 

289 label_stroke_dasharray = _parse_intlist 

290 label_stroke_dashoffset = _parse_unitint 

291 label_stroke_linecap = _parse_enum_fn('stroke_linecap') 

292 label_stroke_linejoin = _parse_enum_fn('stroke_linejoin') 

293 label_stroke_miterlimit = _parse_unitint 

294 label_stroke_width = _parse_unitint 

295 

296 point_size = _parse_unitint 

297 icon = _parse_icon 

298 

299 offset_x = _parse_unitint 

300 offset_y = _parse_unitint