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

142 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-17 01:37 +0200

1"""Style parser.""" 

2 

3import re 

4 

5import gws 

6from . import icon 

7 

8 

9class Options(gws.Data): 

10 """Options about an icon object""" 

11 trusted: bool 

12 """Indicates whether icon is created by us or someone else.""" 

13 strict: bool 

14 """Indicates whether Exceptions should be raised.""" 

15 imageDirs: list[str] 

16 """Paths to directories in which parsing is allowed""" 

17 

18 

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

20 """Adds a dictionary describing style features to a default dictionary. 

21 

22 Args: 

23 d: A dictionary with new features. 

24 opts: Dictionary options. 

25 

26 Returns: 

27 New dictionary of features. 

28 

29 Raises: 

30 ``Exception``: If an invalid css property or value is used. 

31 

32 """ 

33 res = dict(_DEFAULTS) 

34 

35 for key, val in d.items(): 

36 if val is None: 

37 continue 

38 k = key.replace('-', '_') 

39 if k.startswith('__'): 

40 k = k[2:] 

41 

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

43 

44 if not fn: 

45 err = f'style: invalid css property {key!r}' 

46 if opts.strict: 

47 raise gws.Error(err) 

48 else: 

49 gws.log.error(err) 

50 continue 

51 

52 try: 

53 v = fn(val, opts) 

54 if v is not None: 

55 res[k] = v 

56 except Exception as exc: 

57 err = f'style: invalid css value for {key!r}: {val!r}' 

58 if opts.strict: 

59 raise gws.Error(err) from exc 

60 else: 

61 gws.log.error(err) 

62 

63 return res 

64 

65 

66# @TODO use a real CSS parser 

67 

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

69 """Parses a text of features to a dict containing the new features. 

70 

71 Args: 

72 text: Options String formatted like ``'a:b;c:d;...'`` 

73 opts: Text options. 

74 

75 Returns: 

76 New dictionary of features. 

77 

78 """ 

79 d = {} 

80 for r in text.split(';'): 

81 r = r.strip() 

82 if not r: 

83 continue 

84 a, _, b = r.partition(':') 

85 d[a.strip()] = b.strip() 

86 return parse_dict(d, opts) 

87 

88 

89## 

90 

91 

92_DEFAULTS: dict = dict( 

93 fill=None, 

94 

95 stroke=None, 

96 stroke_dasharray=[], 

97 stroke_dashoffset=0, 

98 stroke_linecap='butt', 

99 stroke_linejoin='miter', 

100 stroke_miterlimit=0, 

101 stroke_width=0, 

102 

103 marker_size=0, 

104 marker_stroke_dasharray=[], 

105 marker_stroke_dashoffset=0, 

106 marker_stroke_linecap='butt', 

107 marker_stroke_linejoin='miter', 

108 marker_stroke_miterlimit=0, 

109 marker_stroke_width=0, 

110 

111 with_geometry='all', 

112 with_label='all', 

113 

114 label_align='center', 

115 label_font_family='sans-serif', 

116 label_font_size=12, 

117 label_font_style='normal', 

118 label_font_weight='normal', 

119 label_line_height=1, 

120 label_max_scale=1000000000, 

121 label_min_scale=0, 

122 label_offset_x=None, 

123 label_offset_y=None, 

124 label_placement='middle', 

125 label_stroke_dasharray=[], 

126 label_stroke_dashoffset=0, 

127 label_stroke_linecap='butt', 

128 label_stroke_linejoin='miter', 

129 label_stroke_miterlimit=0, 

130 label_stroke_width=0, 

131 

132 point_size=10, 

133 icon=None, 

134 parsed_icon=None, 

135 

136 offset_x=0, 

137 offset_y=0, 

138) 

139 

140_ENUMS = dict( 

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

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

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

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

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

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

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

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

149 label_padding=[int], 

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

151 label_stroke_dasharray=[int], 

152) 

153 

154_COLOR_PATTERNS = ( 

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

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

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

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

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

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

161) 

162 

163 

164## 

165 

166def _parse_color(val, opts): 

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

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

169 return val 

170 raise ValueError 

171 

172 

173def _parse_intlist(val, opts): 

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

175 

176 

177def _parse_icon(val, opts): 

178 return icon.parse(val, opts) 

179 

180 

181def _parse_unitint(val, opts): 

182 return _unitint(val) 

183 

184 

185def _parse_unitintquad(val, opts): 

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

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

188 return None 

189 if len(val) == 4: 

190 return val 

191 if len(val) == 2: 

192 return [val[0], val[1], val[0], val[1]] 

193 if len(val) == 1: 

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

195 raise ValueError 

196 

197 

198def _parse_enum_fn(cls): 

199 def _check(val, opts): 

200 vals = _ENUMS.get(cls) 

201 if vals and val in vals: 

202 return val 

203 

204 return _check 

205 

206 

207def _parse_int(val, opts): 

208 return int(val) 

209 

210 

211def _parse_str(val, opts): 

212 val = str(val).strip() 

213 return val or None 

214 

215 

216## 

217 

218def _unitint(val): 

219 if isinstance(val, int): 

220 return val 

221 

222 if val.isdigit(): 

223 # unitless = assume pixels 

224 return int(val) 

225 

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

227 if not m: 

228 return 

229 

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

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

232 return 

233 

234 return int(m.group(1)) 

235 

236 

237def _make_list(val): 

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

239 return val 

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

241 

242 

243## 

244 

245 

246class _ParseFunctions: 

247 fill = _parse_color 

248 

249 stroke = _parse_color 

250 stroke_dasharray = _parse_intlist 

251 stroke_dashoffset = _parse_unitint 

252 stroke_linecap = _parse_enum_fn('stroke_linecap') 

253 stroke_linejoin = _parse_enum_fn('stroke_linejoin') 

254 stroke_miterlimit = _parse_unitint 

255 stroke_width = _parse_unitint 

256 

257 marker = _parse_enum_fn('marker') 

258 marker_type = _parse_enum_fn('marker') 

259 marker_fill = _parse_color 

260 marker_size = _parse_unitint 

261 marker_stroke = _parse_color 

262 marker_stroke_dasharray = _parse_intlist 

263 marker_stroke_dashoffset = _parse_unitint 

264 marker_stroke_linecap = _parse_enum_fn('stroke_linecap') 

265 marker_stroke_linejoin = _parse_enum_fn('stroke_linejoin') 

266 marker_stroke_miterlimit = _parse_unitint 

267 marker_stroke_width = _parse_unitint 

268 

269 with_geometry = _parse_enum_fn('with_geometry') 

270 with_label = _parse_enum_fn('with_label') 

271 

272 label_align = _parse_enum_fn('label_align') 

273 label_background = _parse_color 

274 label_fill = _parse_color 

275 label_font_family = _parse_str 

276 label_font_size = _parse_unitint 

277 label_font_style = _parse_enum_fn('label_font_style') 

278 label_font_weight = _parse_enum_fn('label_font_weight') 

279 label_line_height = _parse_int 

280 label_max_scale = _parse_int 

281 label_min_scale = _parse_int 

282 label_offset_x = _parse_unitint 

283 label_offset_y = _parse_unitint 

284 label_padding = _parse_unitintquad 

285 label_placement = _parse_enum_fn('label_placement') 

286 label_stroke = _parse_color 

287 label_stroke_dasharray = _parse_intlist 

288 label_stroke_dashoffset = _parse_unitint 

289 label_stroke_linecap = _parse_enum_fn('stroke_linecap') 

290 label_stroke_linejoin = _parse_enum_fn('stroke_linejoin') 

291 label_stroke_miterlimit = _parse_unitint 

292 label_stroke_width = _parse_unitint 

293 

294 point_size = _parse_unitint 

295 icon = _parse_icon 

296 

297 offset_x = _parse_unitint 

298 offset_y = _parse_unitint