]> de.git.xonotic.org Git - xonotic/xonstat.git/blob - xonstat/batch/badges/skin.py
Further adjust font sizes and positions in badges
[xonotic/xonstat.git] / xonstat / batch / badges / skin.py
1 import math
2 import re
3 import zlib, struct
4 import cairo as C
5 from colorsys import rgb_to_hls, hls_to_rgb
6 from xonstat.util import strip_colors, qfont_decode, _all_colors
7
8 # similar to html_colors() from util.py
9 _contrast_threshold = 0.5
10
11 # standard colorset (^0 ... ^9)
12 _dec_colors = [ (0.5,0.5,0.5),
13                 (1.0,0.0,0.0),
14                 (0.2,1.0,0.0),
15                 (1.0,1.0,0.0),
16                 (0.2,0.4,1.0),
17                 (0.2,1.0,1.0),
18                 (1.0,0.2,0.4),
19                 (1.0,1.0,1.0),
20                 (0.6,0.6,0.6),
21                 (0.5,0.5,0.5)
22             ]
23
24
25 # function to write compressed PNG (using zlib)
26 def write_png(filename, buf, width, height):
27     width_byte_4 = width * 4
28     # fix color ordering (BGRA -> RGBA)
29     for byte in xrange(width*height):
30         pos = byte * 4
31         buf[pos:pos+4] = buf[pos+2] + buf[pos+1] + buf[pos+0] + buf[pos+3]
32     raw_data = b"".join(b'\x00' + buf[span:span + width_byte_4] for span in range(0, (height-1) * width * 4 + 1, width_byte_4))
33     def png_pack(png_tag, data):
34         chunk_head = png_tag + data
35         return struct.pack("!I", len(data)) + chunk_head + struct.pack("!I", 0xFFFFFFFF & zlib.crc32(chunk_head))
36     data = b"".join([
37         b'\x89PNG\r\n\x1a\n',
38         png_pack(b'IHDR', struct.pack("!2I5B", width, height, 8, 6, 0, 0, 0)),
39         png_pack(b'IDAT', zlib.compress(raw_data, 9)),
40         png_pack(b'IEND', b'')])
41     f = open(filename, "wb")
42     try:
43         f.write(data)
44     finally:
45         f.close()
46
47
48 class Skin:
49
50     # skin parameters, can be overriden by init
51     params = {}
52
53     # skin name
54     name = ""
55
56     # render context
57     ctx = None
58
59     def __init__(self, name, **params):
60         # default parameters
61         self.name = name
62         self.params = {
63             'bg':               None,           # None - plain; otherwise use given texture
64             'bgcolor':          None,           # transparent bg when bgcolor==None
65             'overlay':          None,           # add overlay graphic on top of bg
66             'font':             "Xolonium",
67             'width':            560,
68             'height':           70,
69             'nick_fontsize':    22,
70             'nick_pos':         (53,20),
71             'nick_maxwidth':    270,
72             'gametype_fontsize':10,
73             'gametype_pos':     (101,33),
74             'gametype_width':   94,
75             'gametype_height':  0,
76             'gametype_color':   (0.9, 0.9, 0.9),
77             'gametype_text':    "%s",
78             'gametype_align':   0,
79             'gametype_upper':   True,
80             'num_gametypes':    3,
81             'nostats_fontsize': 12,
82             'nostats_pos':      (101,59),
83             'nostats_color':    (0.8, 0.2, 0.1),
84             'nostats_angle':    -10,
85             'nostats_text':     "no stats yet!",
86             'nostats_align':    0,
87             'elo_pos':          (101,47),
88             'elo_fontsize':     10,
89             'elo_color':        (1.0, 1.0, 0.5),
90             'elo_text':         "Elo %.0f",
91             'elo_align':        0,
92             'rank_fontsize':    8,
93             'rank_pos':         (101,58),
94             'rank_color':       (0.8, 0.8, 1.0),
95             'rank_text':        "Rank %d of %d",
96             'rank_align':       0,
97             'wintext_fontsize': 10,
98             'wintext_pos':      (508,3),
99             'wintext_color':    (0.8, 0.8, 0.8),
100             'wintext_text':     "Win Percentage",
101             'wintext_align':    0,
102             'winp_fontsize':    15,
103             'winp_pos':         (509,18),
104             'winp_colortop':    (0.2, 1.0, 1.0),
105             'winp_colormid':    (0.4, 0.8, 0.4),
106             'winp_colorbot':    (1.0, 1.0, 0.2),
107             'winp_align':       0,
108             'wins_fontsize':    9,
109             'wins_pos':         (508,33),
110             'wins_color':       (0.6, 0.8, 0.8),
111             'wins_align':       0,
112             'loss_fontsize':    9,
113             'loss_pos':         (508,44),
114             'loss_color':       (0.8, 0.8, 0.6),
115             'loss_align':       0,
116             'kdtext_fontsize':  10,
117             'kdtext_pos':       (390,3),
118             'kdtext_width':     102,
119             'kdtext_color':     (0.8, 0.8, 0.8),
120             'kdtext_bg':        (0.8, 0.8, 0.8, 0.1),
121             'kdtext_text':      "Kill Ratio",
122             'kdtext_align':     0,
123             'kdr_fontsize':     15,
124             'kdr_pos':          (392,18),
125             'kdr_colortop':     (0.2, 1.0, 0.2),
126             'kdr_colormid':     (0.8, 0.8, 0.4),
127             'kdr_colorbot':     (1.0, 0.2, 0.2),
128             'kdr_align':        0,
129             'kills_fontsize':   9,
130             'kills_pos':        (392,33),
131             'kills_color':      (0.6, 0.8, 0.6),
132             'kills_align':      0,
133             'deaths_fontsize':  9,
134             'deaths_pos':       (392,44),
135             'deaths_color':     (0.8, 0.6, 0.6),
136             'deaths_align':     0,
137             'ptime_fontsize':   10,
138             'ptime_pos':        (451,59),
139             'ptime_color':      (0.1, 0.1, 0.1),
140             'ptime_text':       "Playing Time: %s",
141             'ptime_align':      0,
142         }
143
144         for k,v in params.items():
145             if self.params.has_key(k):
146                 self.params[k] = v
147
148     def __str__(self):
149         return self.name
150
151     def __getattr__(self, key):
152         if self.params.has_key(key):
153             return self.params[key]
154         return None
155
156     def show_text(self, txt, pos, align=0, angle=None, offset=(0,0)):
157         ctx = self.ctx
158
159         xoff, yoff, tw, th = ctx.text_extents(txt)[:4]
160         if align > 0:
161             ctx.move_to(pos[0]+offset[0]-xoff,      pos[1]+offset[1]-yoff)
162         elif align < 0:
163             ctx.move_to(pos[0]+offset[0]-xoff-tw,   pos[1]+offset[1]-yoff)
164         else:
165             ctx.move_to(pos[0]+offset[0]-xoff-tw/2, pos[1]+offset[1]-yoff)
166         ctx.save()
167         if angle:
168             ctx.rotate(math.radians(angle))
169         ctx.show_text(txt)
170         ctx.restore()
171
172     def set_font(self, fontsize, color, bold=False, italic=False):
173         ctx    = self.ctx
174         font   = self.font
175         slant  = C.FONT_SLANT_ITALIC if italic else C.FONT_SLANT_NORMAL
176         weight = C.FONT_WEIGHT_BOLD  if bold   else C.FONT_WEIGHT_NORMAL
177
178         ctx.select_font_face(font, slant, weight)
179         ctx.set_font_size(fontsize)
180         if len(color) == 1:
181             ctx.set_source_rgb(color[0], color[0], color[0])
182         elif len(color) == 3:
183             ctx.set_source_rgb(color[0], color[1], color[2])
184         elif len(color) == 4:
185             ctx.set_source_rgba(color[0], color[1], color[2], color[3])
186         else:
187             ctx.set_source_rgb(1, 1, 1)
188
189     def render_image(self, data, output_filename):
190         """Render an image for the given player id."""
191
192         # setup variables
193
194         player                  = data['player']
195         elos                    = data['elos']
196         ranks                   = data['ranks']
197         games_played            = data['games_played']['overall']
198         overall_stats           = data['overall_stats']['overall']
199
200         wins, losses, win_pct   = games_played.wins, games_played.losses, games_played.win_pct
201         games                   = games_played.games
202         kills, deaths, kd_ratio = overall_stats.total_kills, overall_stats.total_deaths, overall_stats.k_d_ratio
203         alivetime               = overall_stats.total_playing_time
204
205         # make sorted list of gametypes
206         game_types = []
207         for gt in data['games_played'].keys():
208             if gt == 'overall':
209                 continue
210             if elos.has_key(gt):
211                 game_types.append(gt)  # only uses gametypes with elo values (needed later on)
212
213         ## make sure gametypes list if sorted correctly (number of games, descending)
214         ##game_types = sorted(game_types, key=lambda x: data['games_played'][x].games, reverse=True)
215         # make sure gametypes list if sorted correctly (total playing time per game type, descending)
216         game_types = sorted(game_types, key=lambda x: data['overall_stats'][x].total_playing_time, reverse=True)
217
218
219         # build image
220
221         surf = C.ImageSurface(C.FORMAT_ARGB32, self.width, self.height)
222         ctx = C.Context(surf)
223         self.ctx = ctx
224         ctx.set_antialias(C.ANTIALIAS_GRAY)
225
226         # set font hinting options
227         fo = C.FontOptions()
228         fo.set_antialias(C.ANTIALIAS_GRAY)
229         fo.set_hint_style(C.HINT_STYLE_FULL)
230         fo.set_hint_metrics(C.HINT_METRICS_ON)
231         ctx.set_font_options(fo)
232
233         # draw background
234         if self.bg == None:
235             if self.bgcolor != None:
236                 # plain fillcolor, full transparency possible with (1,1,1,0)
237                 ctx.save()
238                 ctx.set_operator(C.OPERATOR_SOURCE)
239                 ctx.rectangle(0, 0, self.width, self.height)
240                 ctx.set_source_rgba(self.bgcolor[0], self.bgcolor[1], self.bgcolor[2], self.bgcolor[3])
241                 ctx.fill()
242                 ctx.restore()
243         else:
244             try:
245                 # background texture
246                 bg = C.ImageSurface.create_from_png("img/%s.png" % self.bg)
247
248                 # tile image
249                 if bg:
250                     bg_w, bg_h = bg.get_width(), bg.get_height()
251                     bg_xoff = 0
252                     while bg_xoff < self.width:
253                         bg_yoff = 0
254                         while bg_yoff < self.height:
255                             ctx.set_source_surface(bg, bg_xoff, bg_yoff)
256                             #ctx.mask_surface(bg)
257                             ctx.paint()
258                             bg_yoff += bg_h
259                         bg_xoff += bg_w
260             except:
261                 #print "Error: Can't load background texture: %s" % self.bg
262                 pass
263
264         # draw overlay graphic
265         if self.overlay != None:
266             try:
267                 overlay = C.ImageSurface.create_from_png("img/%s.png" % self.overlay)
268                 ctx.set_source_surface(overlay, 0, 0)
269                 #ctx.mask_surface(overlay)
270                 ctx.paint()
271             except:
272                 #print "Error: Can't load overlay texture: %s" % self.overlay
273                 pass
274
275
276         ## draw player's nickname with fancy colors
277
278         # deocde nick, strip all weird-looking characters
279         qstr = qfont_decode(qstr=player.nick, glyph_translation=True).\
280                 replace('^^', '^').\
281                 replace(u'\x00', '')
282         #chars = []
283         #for c in qstr:
284         #    # replace weird characters that make problems - TODO
285         #    if ord(c) < 128:
286         #        chars.append(c)
287         #qstr = ''.join(chars)
288         stripped_nick = strip_colors(qstr.replace(' ', '_'))
289
290         # fontsize is reduced if width gets too large
291         ctx.select_font_face(self.font, C.FONT_SLANT_NORMAL, C.FONT_WEIGHT_NORMAL)
292         shrinknick = 0
293         while shrinknick < 0.6 * self.nick_fontsize:
294             ctx.set_font_size(self.nick_fontsize - shrinknick)
295             xoff, yoff, tw, th = ctx.text_extents(stripped_nick)[:4]
296             if tw > self.nick_maxwidth:
297                 shrinknick += 1
298                 continue
299             break
300
301         # determine width of single whitespace for later use
302         xoff, yoff, tw, th = ctx.text_extents("_ _")[:4]
303         space_w = tw
304         xoff, yoff, tw, th = ctx.text_extents("__")[:4]
305         space_w -= tw
306
307         # this hilarious code should determine the spacing between characters
308         sep_w = 0.2*space_w
309         if sep_w <= 0:
310             sep_w = 1
311
312         # split nick into colored segments
313         xoffset = 0
314         _all_colors = re.compile(r'(\^\d|\^x[\dA-Fa-f]{3})')
315         parts = _all_colors.split(qstr)
316         while len(parts) > 0:
317             tag = None
318             txt = parts[0]
319             if _all_colors.match(txt):
320                 tag = txt[1:]  # strip leading '^'
321                 if len(parts) < 2:
322                     break
323                 txt = parts[1]
324                 del parts[1]
325             del parts[0]
326
327             if not txt or len(txt) == 0:
328                 # only colorcode and no real text, skip this
329                 continue
330
331             if tag:
332                 if tag.startswith('x'):
333                     r = int(tag[1] * 2, 16) / 255.0
334                     g = int(tag[2] * 2, 16) / 255.0
335                     b = int(tag[3] * 2, 16) / 255.0
336                     hue, light, satur = rgb_to_hls(r, g, b)
337                     if light < _contrast_threshold:
338                         light = _contrast_threshold
339                         r, g, b = hls_to_rgb(hue, light, satur)
340                 else:
341                     r,g,b = _dec_colors[int(tag[0])]
342             else:
343                 r,g,b = _dec_colors[7]
344
345             xoff, yoff, tw, th = ctx.text_extents(txt)[:4]
346             ctx.set_source_rgb(r, g, b)
347             ctx.move_to(self.nick_pos[0] + xoffset - xoff, self.nick_pos[1])
348             ctx.show_text(txt.encode("utf-8"))
349
350             tw += (len(txt)-len(txt.strip())) * space_w  # account for lost whitespaces
351             xoffset += int(tw + sep_w)
352
353         ## print elos and ranks
354
355         xoffset, yoffset = 0, 0
356         count = 0
357         for gt in game_types[:self.num_gametypes]:
358             if not elos.has_key(gt):
359                 continue
360             count += 1
361
362         # re-align segments if less than max. gametypes are shown
363         if count > 0:
364             if count < self.num_gametypes:
365                 diff = self.num_gametypes - count
366                 if diff % 2 == 0:
367                     xoffset += (diff-1) * self.gametype_width
368                     yoffset += (diff-1) * self.gametype_height
369                 else:
370                     xoffset += 0.5 * diff * self.gametype_width
371                     yoffset += 0.5 * diff * self.gametype_height
372
373             # show a number gametypes the player has participated in
374             for gt in game_types[:self.num_gametypes]:
375                 if not elos.has_key(gt):  # should not happen
376                     continue
377
378                 offset = (xoffset, yoffset)
379                 if self.gametype_pos:
380                     if self.gametype_upper:
381                         txt = self.gametype_text % gt.upper()
382                     else:
383                         txt = self.gametype_text % gt.lower()
384                     self.set_font(self.gametype_fontsize, self.gametype_color, bold=True)
385                     self.show_text(txt, self.gametype_pos, self.gametype_align, offset=offset)
386
387                 if self.elo_pos:
388                     txt = self.elo_text % round(elos[gt], 0)
389                     self.set_font(self.elo_fontsize, self.elo_color)
390                     self.show_text(txt, self.elo_pos, self.elo_align, offset=offset)
391                 if  self.rank_pos:
392                     if ranks.has_key(gt):
393                         txt = self.rank_text % ranks[gt]
394                     else:
395                         txt = "(preliminary)"
396                     self.set_font(self.rank_fontsize, self.rank_color)
397                     self.show_text(txt, self.rank_pos, self.rank_align, offset=offset)
398
399                 xoffset += self.gametype_width
400                 yoffset += self.gametype_height
401         else:
402             if self.nostats_pos:
403                 xoffset += (self.num_gametypes-2) * self.gametype_width
404                 yoffset += (self.num_gametypes-2) * self.gametype_height
405                 offset = (xoffset, yoffset)
406
407                 txt = self.nostats_text
408                 self.set_font(self.nostats_fontsize, self.nostats_color, bold=True)
409                 self.show_text(txt, self.nostats_pos, self.nostats_align, angle=self.nostats_angle, offset=offset)
410
411
412         # print win percentage
413
414         if self.wintext_pos:
415             txt = self.wintext_text
416             self.set_font(self.wintext_fontsize, self.wintext_color)
417             self.show_text(txt, self.wintext_pos, self.wintext_align)
418
419         txt = "???"
420         try:
421             txt = "%.2f%%" % round(win_pct, 2)
422         except:
423             win_pct = 0.
424
425         if self.winp_pos:
426             if win_pct >= 50.0:
427                 nr = 2*(win_pct/100-0.5)
428                 r = nr*self.winp_colortop[0] + (1-nr)*self.winp_colormid[0]
429                 g = nr*self.winp_colortop[1] + (1-nr)*self.winp_colormid[1]
430                 b = nr*self.winp_colortop[2] + (1-nr)*self.winp_colormid[2]
431             else:
432                 nr = 2*(win_pct/100)
433                 r = nr*self.winp_colormid[0] + (1-nr)*self.winp_colorbot[0]
434                 g = nr*self.winp_colormid[1] + (1-nr)*self.winp_colorbot[1]
435                 b = nr*self.winp_colormid[2] + (1-nr)*self.winp_colorbot[2]
436             self.set_font(self.winp_fontsize, (r,g,b), bold=True)
437             self.show_text(txt, self.winp_pos, self.winp_align)
438
439         if self.wins_pos:
440             txt = "%d win" % wins
441             if wins != 1:
442                 txt += "s"
443             self.set_font(self.wins_fontsize, self.wins_color)
444             self.show_text(txt, self.wins_pos, self.wins_align)
445
446         if self.loss_pos:
447             txt = "%d loss" % losses
448             if losses != 1:
449                 txt += "es"
450             self.set_font(self.loss_fontsize, self.loss_color)
451             self.show_text(txt, self.loss_pos, self.loss_align)
452
453
454         # print kill/death ratio
455
456         if self.kdtext_pos:
457             txt = self.kdtext_text
458             self.set_font(self.kdtext_fontsize, self.kdtext_color)
459             self.show_text(txt, self.kdtext_pos, self.kdtext_align)
460
461         txt = "???"
462         try:
463             txt = "%.3f" % round(kd_ratio, 3)
464         except:
465             kd_ratio = 0
466
467         if self.kdr_pos:
468             if kd_ratio >= 1.0:
469                 nr = kd_ratio-1.0
470                 if nr > 1:
471                     nr = 1
472                 r = nr*self.kdr_colortop[0] + (1-nr)*self.kdr_colormid[0]
473                 g = nr*self.kdr_colortop[1] + (1-nr)*self.kdr_colormid[1]
474                 b = nr*self.kdr_colortop[2] + (1-nr)*self.kdr_colormid[2]
475             else:
476                 nr = kd_ratio
477                 r = nr*self.kdr_colormid[0] + (1-nr)*self.kdr_colorbot[0]
478                 g = nr*self.kdr_colormid[1] + (1-nr)*self.kdr_colorbot[1]
479                 b = nr*self.kdr_colormid[2] + (1-nr)*self.kdr_colorbot[2]
480             self.set_font(self.kdr_fontsize, (r,g,b), bold=True)
481             self.show_text(txt, self.kdr_pos, self.kdr_align)
482
483         if self.kills_pos:
484             txt = "%d kill" % kills
485             if kills != 1:
486                 txt += "s"
487             self.set_font(self.kills_fontsize, self.kills_color)
488             self.show_text(txt, self.kills_pos, self.kills_align)
489
490         if self.deaths_pos:
491             txt = ""
492             if deaths is not None:
493                 txt = "%d death" % deaths
494                 if deaths != 1:
495                     txt += "s"
496             self.set_font(self.deaths_fontsize, self.deaths_color)
497             self.show_text(txt, self.deaths_pos, self.deaths_align)
498
499
500         # print playing time
501
502         if self.ptime_pos:
503             txt = self.ptime_text % str(alivetime)
504             self.set_font(self.ptime_fontsize, self.ptime_color)
505             self.show_text(txt, self.ptime_pos, self.ptime_align)
506
507
508         # save to PNG
509         #surf.write_to_png(output_filename)
510         surf.flush()
511         imgdata = surf.get_data()
512         write_png(output_filename, imgdata, self.width, self.height)
513