Coverage for src/genbadge/utils_badge.py: 81%

116 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2025-11-24 14:51 +0000

1# Authors: Sylvain MARIE <sylvain.marie@se.com> 

2# + All contributors to <https://github.com/smarie/python-genbadge> 

3# 

4# License: 3-clause BSD, <https://github.com/smarie/python-genbadge/blob/master/LICENSE> 

5import os 

6import sys 

7 

8from PIL import ImageFont 

9 

10try: 

11 from pathlib import Path 

12except ImportError: # pragma: no cover 

13 from pathlib2 import Path # python 2 

14 

15try: 

16 from typing import Union 

17except ImportError: # pragma: no cover 

18 pass 

19 

20# Migration from pkg_resources to importlib.resources 

21try: 

22 # Python 3.9+ 

23 from importlib.resources import files, as_file 

24except ImportError: # pragma: no cover 

25 try: 

26 # Python 3.7-3.8 (importlib_resources backport) 

27 from importlib_resources import files, as_file 

28 except ImportError: # pragma: no cover 

29 files = None 

30 as_file = None 

31 

32 

33COLORS = { 

34 'brightgreen': '#4c1', 

35 'green': '#97ca00', 

36 'yellowgreen': '#a4a61d', 

37 'yellow': '#dfb317', 

38 'orange': '#fe7d37', 

39 'red': '#e05d44', 

40 'lightgrey': '#9f9f9f', 

41} 

42 

43 

44class Badge: 

45 """ 

46 A small utility class for badges 

47 """ 

48 def __init__(self, 

49 left_txt, # type: str 

50 right_txt, # type: str 

51 color, # type: str 

52 ): 

53 self.left_txt = left_txt 

54 self.right_txt = right_txt 

55 self.color = color 

56 

57 def __repr__(self): 

58 return "[ %s | %s ] color: %s" % (self.left_txt, self.right_txt, self.color) 

59 

60 def as_svg(self, 

61 use_shields=False # type: bool 

62 ): 

63 """Return a string containing the SVG representation of this badge 

64 

65 :param use_shields: 

66 :return: 

67 """ 

68 if not use_shields: 

69 # generate from our local file template 

70 return get_svg_badge(label_txt=self.left_txt, msg_txt=self.right_txt, color=self.color) 

71 else: 

72 # download from requests 

73 import requests 

74 # url encode test 

75 safe_left_txt = requests.utils.quote(self.left_txt, safe='') 

76 safe_right_txt = requests.utils.quote(self.right_txt, safe='') 

77 safe_color_txt = requests.utils.quote(self.color, safe='') 

78 url = 'https://img.shields.io/badge/%s-%s-%s.svg' % (safe_left_txt, safe_right_txt, safe_color_txt) 

79 response = requests.get(url, stream=True) 

80 return response.text 

81 

82 def write_to(self, 

83 path_or_stream, # type: Union[TextIO, str, Path] 

84 use_shields=False, # type: bool 

85 clear_left_txt=False # type: bool  

86 ): 

87 """Write the SVG representation of this badge to the given file 

88 

89 :param path_or_stream: 

90 :param use_shields: 

91 :param clear_left_txt: 

92 :return: 

93 """ 

94 # convert to a Path 

95 if isinstance(path_or_stream, str): 

96 path_or_stream = Path(path_or_stream) 

97 

98 svg = self.as_svg(use_shields=use_shields) 

99 if clear_left_txt: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true

100 svg = svg.replace(">" + self.left_txt + "<", "><") 

101 

102 # create parent dirs if needed 

103 if isinstance(path_or_stream, Path): 

104 path_or_stream.parent.mkdir(parents=True, exist_ok=True) 

105 

106 # finally write to 

107 with open(str(path_or_stream), mode="wb") as f: 

108 f.write(svg.encode("utf-8")) 

109 else: 

110 path_or_stream.write(svg) 

111 

112 

113def get_svg_badge( 

114 label_txt, # type: str 

115 msg_txt, # type: str 

116 color, # type: str 

117 label_color=None 

118): 

119 # type: (...) -> str 

120 """ 

121 Reads the SVG template from the package, 

122 fills the various information from args and returns the svg string 

123 """ 

124 all_text = "%s: %s" % (label_txt, msg_txt) if label_txt else ("%s" % msg_txt) 

125 

126 # Same principle as in shields.io 

127 template = get_local_badge_template() 

128 

129 horiz_padding = 5 

130 vertical_margin = 0 

131 

132 has_logo = False # TODO when a logo is inserted 

133 total_logo_width = 0 

134 

135 has_label = len(label_txt) > 0 or label_color 

136 label_color = label_color or '#555' 

137 label_margin = total_logo_width + 1 

138 

139 def process_text(left_margin, content): 

140 """From renderText() 

141 https://github.com/badges/shields/blob/4415d07e8b5bf794e6675cea052cc644d0c81bb5/badge-maker/lib/badge-renderers.js#L113 

142 """ 

143 text_length = preferred_width_of(content, font_size=11, font_name="Verdana") 

144 # todo content = escape_xml(content) 

145 shadow_margin = 150 + vertical_margin 

146 text_margin = 140 + vertical_margin 

147 out_text_length = 10 * text_length 

148 x = 10 * (left_margin + 0.5 * text_length + horiz_padding) 

149 return x, shadow_margin, text_margin, text_length, out_text_length 

150 

151 label_x, label_shadow_margin, label_text_margin, label_width, label_text_length = \ 

152 process_text(label_margin, content=label_txt) 

153 

154 left_width = (label_width + 2 * horiz_padding + total_logo_width) if has_label else 0 

155 

156 msg_margin = left_width - (1 if len(msg_txt) > 0 else 0) 

157 if not has_label: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 if has_logo: 

159 msg_margin = msg_margin + total_logo_width + horiz_padding 

160 else: 

161 msg_margin = msg_margin + 1 

162 

163 msg_x, msg_shadow_margin, msg_text_margin, msg_width, msg_text_length = \ 

164 process_text(msg_margin, content=msg_txt) 

165 

166 right_width = (msg_width + 2 * horiz_padding) 

167 if (has_logo and not has_label): 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 right_width += total_logo_width + horiz_padding - 1 

169 

170 total_width = left_width + right_width 

171 

172 to_replace = { 

173 "title": all_text, 

174 "label_color": get_color(label_color), 

175 "color": get_color(color), 

176 "total_width": total_width, 

177 "left_width": left_width, 

178 "right_width": right_width, 

179 # label text 

180 "left_x": label_x, 

181 "left_shadow_margin": label_shadow_margin, 

182 "left_text_margin": label_text_margin, 

183 "left_out_text_length": label_text_length, 

184 "left_text": label_txt, 

185 # msg text 

186 "right_x": msg_x, 

187 "right_shadow_margin": msg_shadow_margin, 

188 "right_text_margin": msg_text_margin, 

189 "right_out_text_length": msg_text_length, 

190 "right_text": msg_txt 

191 } 

192 for k, v in to_replace.items(): 

193 template = template.replace("{{ %s }}" % k, str(v)) 

194 

195 return template 

196 

197 

198def _resource_string(package, resource_name): 

199 """Fallback for importlib.resources in older Python versions.""" 

200 if files and as_file: 200 ↛ 205line 200 didn't jump to line 205 because the condition on line 200 was always true

201 with as_file(files(package) / resource_name) as f: 

202 return f.read_bytes() 

203 else: 

204 # Fallback to pkg_resources for older Python versions 

205 from pkg_resources import resource_string 

206 

207 return resource_string(package, resource_name) 

208 

209def _resource_filename(package, resource_name): 

210 """Fallback for importlib.resources in older Python versions.""" 

211 if files and as_file: 211 ↛ 216line 211 didn't jump to line 216 because the condition on line 211 was always true

212 with as_file(files(package) / resource_name) as f: 

213 return str(f) 

214 else: 

215 # Fallback to pkg_resources for older Python versions 

216 from pkg_resources import resource_filename 

217 

218 return resource_filename(package, resource_name) 

219 

220def get_local_badge_template(): 

221 """Reads the SVG file template fgrom the package resources""" 

222 template_path = "badge-template.svg" 

223 try: 

224 template = _resource_string("genbadge", template_path).decode('utf8') 

225 except IOError: 

226 # error when running on python 2 inside the CliInvoker from click with a change of os.cwd. 

227 import genbadge 

228 reload(genbadge) # noqa 

229 template = _resource_string("genbadge", template_path).decode('utf8') 

230 

231 return template 

232 

233 

234def get_color(color_str): 

235 try: 

236 color_hexa = COLORS[color_str] 

237 except KeyError: 

238 # assume custom hexa string already 

239 color_hexa = color_str 

240 

241 return color_hexa 

242 

243 

244def round_up_to_odd(val): 

245 return (val + 1) if (val % 2 == 0) else val 

246 

247 

248def preferred_width_of(txt, font_name, font_size): 

249 # Increase chances of pixel grid alignment. 

250 font_file = "%s.ttf" % font_name.lower() 

251 try: 

252 # Try from name only - this works if the font is known by the OS 

253 font = ImageFont.truetype(font=font_file, size=font_size) 

254 except (IOError if sys.version_info < (3,) else OSError): 

255 # Font not found: use the embedded font file from the package 

256 font_path = _resource_filename("genbadge", font_file) 

257 if not os.path.exists(font_path): 257 ↛ 259line 257 didn't jump to line 259 because the condition on line 257 was never true

258 # error when running on python 2 inside the CliInvoker from click with a change of os.cwd. 

259 import genbadge 

260 reload(genbadge) # noqa 

261 font_path = _resource_filename("genbadge", font_file) 

262 

263 font = ImageFont.truetype(font=font_path, size=font_size) 

264 

265 # PLI.FreeTypeFont does not have a getsize() method, however, the FreeTypeFont class is not part of PLI's API. 

266 # Thus, we can not use isinstance(font, FreeTypeFont) here. 

267 getsize = getattr(font, "getsize", None) 

268 if callable(getsize): 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 width = font.getsize(txt)[0] 

270 else: 

271 width = font.getbbox(txt)[2] # exists for FreeTypeFont in PLI >= v10.0.0 

272 return round_up_to_odd(width)