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

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='all', 

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 parsed_icon=None, 

140 

141 offset_x=0, 

142 offset_y=0, 

143) 

144 

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) 

158 

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) 

167 

168 

169## 

170 

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 

176 

177 

178def _parse_intlist(val, opts): 

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

180 

181 

182def _parse_icon(val, opts): 

183 return icon.parse(val, opts) 

184 

185 

186def _parse_unitint(val, opts): 

187 return _unitint(val) 

188 

189 

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 

201 

202 

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 

208 

209 return _check 

210 

211 

212def _parse_int(val, opts): 

213 return int(val) 

214 

215 

216def _parse_str(val, opts): 

217 val = str(val).strip() 

218 return val or None 

219 

220 

221## 

222 

223def _unitint(val): 

224 if isinstance(val, int): 

225 return val 

226 

227 if val.isdigit(): 

228 # unitless = assume pixels 

229 return int(val) 

230 

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

232 if not m: 

233 return 

234 

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

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

237 return 

238 

239 return int(m.group(1)) 

240 

241 

242def _make_list(val): 

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

244 return val 

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

246 

247 

248## 

249 

250 

251class _ParseFunctions: 

252 fill = _parse_color 

253 

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 

261 

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 

273 

274 with_geometry = _parse_enum_fn('with_geometry') 

275 with_label = _parse_enum_fn('with_label') 

276 

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 

298 

299 point_size = _parse_unitint 

300 icon = _parse_icon 

301 

302 offset_x = _parse_unitint 

303 offset_y = _parse_unitint