├── requirements.txt ├── pgn2gif ├── __init__.py ├── assets │ ├── bb.png │ ├── bk.png │ ├── bn.png │ ├── bp.png │ ├── bq.png │ ├── br.png │ ├── wb.png │ ├── wk.png │ ├── wn.png │ ├── wp.png │ ├── wq.png │ └── wr.png ├── pgn2gif.py └── chess.py ├── Pipfile ├── setup.py ├── LICENSE └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | pillow -------------------------------------------------------------------------------- /pgn2gif/__init__.py: -------------------------------------------------------------------------------- 1 | from .pgn2gif import main, PgnToGifCreator -------------------------------------------------------------------------------- /pgn2gif/assets/bb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/bb.png -------------------------------------------------------------------------------- /pgn2gif/assets/bk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/bk.png -------------------------------------------------------------------------------- /pgn2gif/assets/bn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/bn.png -------------------------------------------------------------------------------- /pgn2gif/assets/bp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/bp.png -------------------------------------------------------------------------------- /pgn2gif/assets/bq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/bq.png -------------------------------------------------------------------------------- /pgn2gif/assets/br.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/br.png -------------------------------------------------------------------------------- /pgn2gif/assets/wb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/wb.png -------------------------------------------------------------------------------- /pgn2gif/assets/wk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/wk.png -------------------------------------------------------------------------------- /pgn2gif/assets/wn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/wn.png -------------------------------------------------------------------------------- /pgn2gif/assets/wp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/wp.png -------------------------------------------------------------------------------- /pgn2gif/assets/wq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/wq.png -------------------------------------------------------------------------------- /pgn2gif/assets/wr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dn1z/pgn2gif/HEAD/pgn2gif/assets/wr.png -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | pillow = "*" 8 | 9 | [dev-packages] 10 | 11 | [requires] 12 | python_version = "3.7" 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | try: 4 | from setuptools import setup 5 | except ImportError: 6 | from distutils.core import setup 7 | 8 | NAME = 'pgn2gif' 9 | AUTHOR = 'Deniz Kizilirmak' 10 | URL = 'https://github.com/dn1z/pgn2gif' 11 | DESCRIPTION = 'A small tool that generates gif of chess game' 12 | 13 | VERSION = '1.2' 14 | 15 | REQUIRES_PYTHON = '>=3.5' 16 | REQUIRED = ['pillow'] 17 | 18 | here = os.path.dirname(__file__) 19 | try: 20 | with open(os.path.join(here, 'README.md')) as f: 21 | LONG_DESCRIPTION = f.read() 22 | except: 23 | LONG_DESCRIPTION = DESCRIPTION 24 | 25 | setup( 26 | name=NAME, 27 | author=AUTHOR, 28 | url=URL, 29 | license='MIT', 30 | version=VERSION, 31 | description=DESCRIPTION, 32 | long_description=LONG_DESCRIPTION, 33 | package_data={ 34 | 'pgn2gif': ['assets/*.png'] 35 | }, 36 | packages=['pgn2gif'], 37 | install_requires=REQUIRED, 38 | python_requires=REQUIRES_PYTHON, 39 | entry_points={ 40 | 'console_scripts': ['pgn2gif = pgn2gif:main'] 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 M. Deniz Kızılırmak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pgn2gif 2 | Generate gifs from pgn files of your chess games. 3 | 4 | ## Installation 5 | * You need [python 3.5](https://www.python.org/downloads/) or newer installed. 6 | * Clone the repo with `git clone https://github.com/dn1z/pgn2gif`. 7 | * In the cloned directory run 8 | ``` 9 | pip install -r requirements.txt 10 | ``` 11 | * If you want to install system wide 12 | ``` 13 | python setup.py install 14 | ``` 15 | 16 | ## Usage 17 | Run pgn2gif with the following options: 18 | ``` 19 | usage: pgn2gif [-h] [-d DURATION] [-o OUT] [-r] [--black-square-color BLACK_SQUARE_COLOR] [--white-square-color WHITE_SQUARE_COLOR] [path [path ...]] 20 | 21 | positional arguments: 22 | path Path to the pgn file(s) 23 | 24 | optional arguments: 25 | -h, --help show this help message and exit 26 | -d DURATION, --duration DURATION 27 | Duration between moves in seconds 28 | -o OUT, --out OUT Name of the output folder 29 | -r, --reverse Reverse board 30 | --black-square-color BLACK_SQUARE_COLOR 31 | Color of black squares in hex 32 | --white-square-color WHITE_SQUARE_COLOR 33 | Color of white squares in hex 34 | ``` 35 | Also can be used as a library 36 | ```python 37 | import pgn2gif 38 | 39 | creator = pgn2gif.PgnToGifCreator(reverse=True, duration=0.1, ws_color='white', bs_color='gray') 40 | creator.create_gif("first.pgn") # creates first.gif 41 | creator.create_gif("second.pgn", out_path="../chess.gif") 42 | ``` 43 | 44 | ## Example 45 | 46 | ### PGN 47 | ``` 48 | 1. Nf3 Nf6 2. d4 e6 3. c4 b6 4. g3 Bb7 5. Bg2 Be7 6. O-O O-O 49 | 7. d5 exd5 8. Nh4 c6 9. cxd5 Nxd5 10. Nf5 Nc7 11. e4 d5 50 | 12. exd5 Nxd5 13. Nc3 Nxc3 14. Qg4 g6 15. Nh6+ Kg7 16. bxc3 51 | Bc8 17. Qf4 Qd6 18. Qa4 g5 19. Re1 Kxh6 20. h4 f6 21. Be3 Bf5 52 | 22. Rad1 Qa3 23. Qc4 b5 24. hxg5+ fxg5 25. Qh4+ Kg6 26. Qh1 53 | Kg7 27. Be4 Bg6 28. Bxg6 hxg6 29. Qh3 Bf6 30. Kg2 Qxa2 31. Rh1 54 | Qg8 32. c4 Re8 33. Bd4 Bxd4 34. Rxd4 Rd8 35. Rxd8 Qxd8 36. Qe6 55 | Nd7 37. Rd1 Nc5 38. Rxd8 Nxe6 39. Rxa8 Kf6 40. cxb5 cxb5 56 | 41. Kf3 Nd4+ 42. Ke4 Nc6 43. Rc8 Ne7 44. Rb8 Nf5 45. g4 Nh6 57 | 46. f3 Nf7 47. Ra8 Nd6+ 48. Kd5 Nc4 49. Rxa7 Ne3+ 50. Ke4 Nc4 58 | 51. Ra6+ Kg7 52. Rc6 Kf7 53. Rc5 Ke6 54. Rxg5 Kf6 55. Rc5 g5 59 | 56. Kd4 1-0 60 | ``` 61 | 62 | ### GIF output 63 | 64 | 65 | ## License 66 | Copyright (c) M. Deniz Kızılırmak. All rights reserved. 67 | 68 | Licensed under the MIT license. 69 | -------------------------------------------------------------------------------- /pgn2gif/pgn2gif.py: -------------------------------------------------------------------------------- 1 | try: 2 | from . import chess 3 | except ImportError: 4 | import chess 5 | 6 | import argparse 7 | from pathlib import Path 8 | 9 | from PIL import Image 10 | 11 | 12 | class PgnToGifCreator: 13 | ''' 14 | PGN to GIF creator class 15 | Parameters 16 | ---------- 17 | reverse : bool, optional 18 | Whether to reverse board or not 19 | duration : float, optional 20 | Duration between moves in seconds 21 | ws_color : str, optional 22 | Color of white squares in hex or string 23 | bs_color : str, optional 24 | Color of black squares in hex or string 25 | ''' 26 | 27 | _BOARD_SIZE = 480 28 | _SQ_SIZE = _BOARD_SIZE // 8 29 | 30 | def __init__(self, reverse=False, duration=0.4, ws_color='#f0d9b5', bs_color='#b58863'): 31 | self.duration = duration 32 | 33 | self._pieces = {} 34 | self._reverse = reverse 35 | self._ws_color = ws_color 36 | self._bs_color = bs_color 37 | self._should_redraw = True 38 | 39 | @property 40 | def reverse(self): 41 | return self._reverse 42 | 43 | @reverse.setter 44 | def reverse(self, reverse): 45 | self._reverse = reverse 46 | self._should_redraw = True 47 | 48 | @property 49 | def ws_color(self): 50 | return self._ws_color 51 | 52 | @ws_color.setter 53 | def ws_color(self, ws_color): 54 | self._ws_color = ws_color 55 | self._should_redraw = True 56 | 57 | @property 58 | def bs_color(self): 59 | return self._bs_color 60 | 61 | @bs_color.setter 62 | def bs_color(self, bs_color): 63 | self._bs_color = bs_color 64 | self._should_redraw = True 65 | 66 | def _draw_board(self): 67 | if not self._pieces: 68 | for asset in (Path(__file__).parent/'assets').iterdir(): 69 | self._pieces[asset.stem] = Image.open(asset) 70 | 71 | self._ws = Image.new('RGBA', (self._SQ_SIZE, self._SQ_SIZE), 72 | self.ws_color) 73 | self._bs = Image.new('RGBA', (self._SQ_SIZE, self._SQ_SIZE), 74 | self.bs_color) 75 | 76 | self._initial_board = Image.new( 77 | 'RGBA', (self._BOARD_SIZE, self._BOARD_SIZE)) 78 | self._update_board_image(self._initial_board, chess.INITIAL_STATE, 79 | list(chess.INITIAL_STATE.keys())) 80 | 81 | self._should_redraw = False 82 | 83 | def _coordinates_of_square(self, square): 84 | c = ord(square[0]) - 97 85 | r = int(square[1]) - 1 86 | 87 | if self.reverse: 88 | return ((7 - c) * self._SQ_SIZE, r * self._SQ_SIZE) 89 | else: 90 | return (c * self._SQ_SIZE, (7 - r) * self._SQ_SIZE) 91 | 92 | def _update_board_image(self, board_image, game_state, changed_squares): 93 | for square in changed_squares: 94 | crd = self._coordinates_of_square(square) 95 | 96 | if sum(crd) % (self._SQ_SIZE * 2) == 0: 97 | board_image.paste(self._ws, crd, self._ws) 98 | else: 99 | board_image.paste(self._bs, crd, self._bs) 100 | 101 | piece = game_state[square] 102 | if piece: 103 | img = self._pieces[piece] 104 | board_image.paste(img, crd, img) 105 | 106 | def create_gif(self, pgn, out_path=None): 107 | ''' 108 | Creates gif of pgn with same name. 109 | player1-player2.pgn -> player1-player2.gif (or as out_path) 110 | PARAMETERS 111 | ----------- 112 | pgn : str 113 | Path of pgn file 114 | out_path : str, optional 115 | Output path of gif 116 | ''' 117 | if self._should_redraw: 118 | self._draw_board() 119 | 120 | board_image = self._initial_board.copy() 121 | frames = [board_image.copy()] 122 | 123 | game = chess.ChessGame(pgn) 124 | 125 | while not game.is_finished: 126 | previous = game.state.copy() 127 | game.next() 128 | self._update_board_image(board_image, game.state, [ 129 | s for s in game.state.keys() if game.state[s] != previous[s]]) 130 | frames.append(board_image.copy()) 131 | 132 | last = frames[len(frames) - 1] 133 | for _ in range(3): 134 | frames.append(last) 135 | 136 | if not out_path: 137 | out_path = Path(pgn).stem + '.gif' 138 | 139 | frames[0].save(out_path, format="GIF", append_images=frames[1:], 140 | optimize=True, save_all=True, duration=int(self.duration * 1000), loop=0) 141 | 142 | 143 | def main(): 144 | parser = argparse.ArgumentParser() 145 | parser.add_argument( 146 | 'path', nargs='+', help='Path to the pgn file(s)') 147 | parser.add_argument( 148 | '-d', '--duration', help='Duration between moves in seconds', default=0.4) 149 | parser.add_argument( 150 | '-o', '--out', help='Name of the output folder', default=Path.cwd()) 151 | parser.add_argument( 152 | '-r', '--reverse', help='Reverse board', action='store_true') 153 | parser.add_argument( 154 | '--black-square-color', 155 | help='Color of black squares in hex or string', 156 | default='#b58863') 157 | parser.add_argument( 158 | '--white-square-color', 159 | help='Color of white squares in hex or string', 160 | default='#f0d9b5') 161 | args = parser.parse_args() 162 | 163 | creator = PgnToGifCreator( 164 | args.reverse, float(args.duration), args.white_square_color, args.black_square_color) 165 | for pgn in args.path: 166 | f = Path(pgn).stem + '.gif' 167 | creator.create_gif(pgn, Path(args.out) / f) 168 | 169 | 170 | if __name__ == '__main__': 171 | main() 172 | -------------------------------------------------------------------------------- /pgn2gif/chess.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | INITIAL_STATE = { 4 | 'a8': 'br', 5 | 'b8': 'bn', 6 | 'c8': 'bb', 7 | 'd8': 'bq', 8 | 'e8': 'bk', 9 | 'f8': 'bb', 10 | 'g8': 'bn', 11 | 'h8': 'br', 12 | 'a7': 'bp', 13 | 'b7': 'bp', 14 | 'c7': 'bp', 15 | 'd7': 'bp', 16 | 'e7': 'bp', 17 | 'f7': 'bp', 18 | 'g7': 'bp', 19 | 'h7': 'bp', 20 | 'a6': '', 21 | 'b6': '', 22 | 'c6': '', 23 | 'd6': '', 24 | 'e6': '', 25 | 'f6': '', 26 | 'g6': '', 27 | 'h6': '', 28 | 'a5': '', 29 | 'b5': '', 30 | 'c5': '', 31 | 'd5': '', 32 | 'e5': '', 33 | 'f5': '', 34 | 'g5': '', 35 | 'h5': '', 36 | 'a4': '', 37 | 'b4': '', 38 | 'c4': '', 39 | 'd4': '', 40 | 'e4': '', 41 | 'f4': '', 42 | 'g4': '', 43 | 'h4': '', 44 | 'a3': '', 45 | 'b3': '', 46 | 'c3': '', 47 | 'd3': '', 48 | 'e3': '', 49 | 'f3': '', 50 | 'g3': '', 51 | 'h3': '', 52 | 'a2': 'wp', 53 | 'b2': 'wp', 54 | 'c2': 'wp', 55 | 'd2': 'wp', 56 | 'e2': 'wp', 57 | 'f2': 'wp', 58 | 'g2': 'wp', 59 | 'h2': 'wp', 60 | 'a1': 'wr', 61 | 'b1': 'wn', 62 | 'c1': 'wb', 63 | 'd1': 'wq', 64 | 'e1': 'wk', 65 | 'f1': 'wb', 66 | 'g1': 'wn', 67 | 'h1': 'wr' 68 | } 69 | 70 | COLUMNS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] 71 | 72 | 73 | class ChessGame: 74 | def __init__(self, pgn): 75 | self.moves = [] 76 | self.is_finished = False 77 | self.is_white_turn = True 78 | self._last_played_move_index = -1 79 | 80 | self._parse_pgn_file(pgn) 81 | self.state = INITIAL_STATE.copy() 82 | 83 | def _check_knight_move(self, sqr1, sqr2): 84 | cd = abs(ord(sqr1[0]) - ord(sqr2[0])) 85 | rd = abs(int(sqr1[1]) - int(sqr2[1])) 86 | return (cd == 2 and rd == 1) or (cd == 1 and rd == 2) 87 | 88 | def _check_line(self, sqr1, sqr2): 89 | c1 = sqr1[0] 90 | c2 = sqr2[0] 91 | r1 = int(sqr1[1]) 92 | r2 = int(sqr2[1]) 93 | if r1 == r2: 94 | i1, i2 = ord(c1) - 97, ord(c2) - 97 95 | return all(self.state[COLUMNS[i] + str(r1)] == '' 96 | for i in range(min(i1, i2) + 1, max(i1, i2))) 97 | elif c1 == c2: 98 | return all(self.state[c1 + str(i)] == '' 99 | for i in range(min(r1, r2) + 1, max(r1, r2))) 100 | return False 101 | 102 | def _check_diagonal(self, sqr1, sqr2): 103 | c1 = ord(sqr1[0]) - 97 104 | c2 = ord(sqr2[0]) - 97 105 | r1 = int(sqr1[1]) 106 | r2 = int(sqr2[1]) 107 | 108 | if abs(c1 - c2) == abs(r1 - r2): 109 | if c1 > c2 and r1 > r2 or c2 > c1 and r2 > r1: 110 | min_c = min(c1, c2) 111 | min_r = min(r1, r2) 112 | return all( 113 | self.state[COLUMNS[min_c + i] + str(min_r + i)] == '' 114 | for i in range(1, abs(c1 - c2))) 115 | elif c1 > c2 and r2 > r1: 116 | return all(self.state[COLUMNS[c2 + i] + str(r2 - i)] == '' 117 | for i in range(1, c1 - c2)) 118 | else: 119 | return all(self.state[COLUMNS[c2 - i] + str(r2 + i)] == '' 120 | for i in range(1, c2 - c1)) 121 | 122 | return False 123 | 124 | def _update_state(self, frm, to, pt): 125 | self.state[frm] = '' 126 | self.state[to] = pt 127 | 128 | def _find_non_pawn(self, move, to, piece): 129 | if len(move) == 5: 130 | return move[1:3] 131 | 132 | p = piece[1] 133 | key = '' if len(move) == 3 else move[1] 134 | 135 | if p == 'r': 136 | return next( 137 | s for s, pt in self.state.items() 138 | if pt == piece and key in s and self._check_line(s, to)) 139 | elif p == 'b': 140 | return next( 141 | s for s, pt in self.state.items() 142 | if pt == piece and key in s and self._check_diagonal(s, to)) 143 | elif p == 'n': 144 | return next(s for s, pt in self.state.items() if pt == piece 145 | and key in s and self._check_knight_move(s, to)) 146 | else: 147 | return next( 148 | s for s, pt in self.state.items() 149 | if pt == piece and key in s and ( 150 | self._check_line(s, to) or self._check_diagonal(s, to))) 151 | 152 | def _find_pawn(self, move): 153 | pt = 'wp' if self.is_white_turn else 'bp' 154 | c = move[-2] 155 | r = int(move[-1]) 156 | 157 | if len(move) == 2: 158 | if self.is_white_turn: 159 | origin = c + next( 160 | str(i) 161 | for i in range(r, 0, -1) if self.state[c + str(i)] == pt) 162 | else: 163 | origin = c + next( 164 | str(i) 165 | for i in range(r, 9) if self.state[c + str(i)] == pt) 166 | else: 167 | if self.state[move[-2:]] != '': 168 | origin = move[0] + (str(r - 1) 169 | if self.is_white_turn else str(r + 1)) 170 | else: # En passant 171 | nps = c + (str(r - 1) if self.is_white_turn else str(r + 1)) 172 | self.state[nps] = '' 173 | origin = move[0] + nps[-1] 174 | 175 | return origin 176 | 177 | def _castle(self, move): 178 | row, color = ('1', 'w') if self.is_white_turn else ('8', 'b') 179 | if move.count('O') == 2: 180 | r = 'h' + row 181 | k_to = 'g' + row 182 | r_to = 'f' + row 183 | else: 184 | r = 'a' + row 185 | k_to = 'c' + row 186 | r_to = 'd' + row 187 | 188 | self._update_state('e' + row, k_to, color + 'k') 189 | self._update_state(r, r_to, color + 'r') 190 | 191 | def _promote(self, move): 192 | pt = ('w' if self.is_white_turn else 'b') + move[-1].lower() 193 | origin = move[0] + ('7' if self.is_white_turn else '2') 194 | self._update_state(origin, move[-4:-2], pt) 195 | 196 | def _parse_pgn_file(self, pgn): 197 | with open(pgn) as p: 198 | data = p.read() 199 | data = re.sub(r'{.*?}|\[.*?\]|[x]', '', data, flags=re.DOTALL) 200 | self.moves = re.findall( 201 | r'[a-h][a-h]?[1-8]=?[BKNRQ]?|O-O-?O?|[BKNRQ][a-h1-8]?[a-h1-8]?[a-h][1-8]', 202 | data) 203 | 204 | def next(self): 205 | if self.is_finished: 206 | return 207 | 208 | self._last_played_move_index += 1 209 | try: 210 | move = self.moves[self._last_played_move_index] 211 | except: 212 | self.is_finished = True 213 | return 214 | 215 | if 'O' in move: 216 | self._castle(move) 217 | 218 | elif '=' in move: 219 | self._promote(move) 220 | 221 | else: 222 | dest = move[-2:] 223 | if (move.islower()): 224 | pt = ('w' if self.is_white_turn else 'b') + 'p' 225 | origin = self._find_pawn(move) 226 | else: 227 | pt = ('w' if self.is_white_turn else 'b') + move[0].lower() 228 | origin = self._find_non_pawn(move, dest, pt) 229 | 230 | self._update_state(origin, dest, pt) 231 | 232 | self.is_white_turn = not self.is_white_turn 233 | --------------------------------------------------------------------------------