From dc31630169af4069cb06fe37fd01d67d467ed63e Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Mon, 22 Jan 2024 04:31:43 +0530 Subject: [PATCH 01/25] feat: adds visualization of NAGs on the SVG board. --- chess/svg.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/chess/svg.py b/chess/svg.py index d3d19e89e..fa740ca3f 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -11,6 +11,7 @@ SQUARE_SIZE = 45 MARGIN = 20 +NAG_SIZE = 15 PIECES = { "b": """""", # noqa: E501 @@ -46,6 +47,39 @@ "h": """""", # noqa: E501 } +NAGS = { + # "!" + "1": """ + + + """, + # "?" + "2": """ + + + """, + # "!!" + "3": """ + + + + """, + # "??" + "4": """ + + + + + + """, + # "?!" + "6": """ + + + + """ +} + XX = """""" # noqa: E501 CHECK_GRADIENT = """""" # noqa: E501 @@ -226,7 +260,8 @@ def board(board: Optional[chess.BaseBoard] = None, *, colors: Dict[str, str] = {}, flipped: bool = False, borders: bool = False, - style: Optional[str] = None) -> str: + style: Optional[str] = None, + nag:Optional[int] = None) -> str: """ Renders a board with pieces and/or selected squares as an SVG image. @@ -256,6 +291,8 @@ def board(board: Optional[chess.BaseBoard] = None, *, :param borders: Pass ``True`` to enable a border around the board and, (if *coordinates* is enabled) the coordinate margin. :param style: A CSS stylesheet to include in the SVG image. + :param nag: Pass ``NAG Constant`` to show Numerical Notation Glyphs (NAGs). + (requires ``lastmove`` to be passed along as argument) >>> import chess >>> import chess.svg @@ -514,4 +551,24 @@ def board(board: Optional[chess.BaseBoard] = None, *, "class": "arrow", })) + if nag is not None and lastmove is not None: + ele = ET.fromstring(NAGS[str(nag)]) + defs.append(ele) + id = ele.attrib.get("id") + file_index = chess.square_file(lastmove.to_square) + rank_index = chess.square_rank(lastmove.to_square) + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin + from_file_index = chess.square_file(lastmove.from_square) + # Making sure the NAGs doesn't overlap the Last Move Arrow by switching + # between top left and top right corner depending upon where the Arrow + # is coming from. + x = x + (SQUARE_SIZE - NAG_SIZE if file_index >= from_file_index else 0) + ET.SubElement(svg, "use", _attrs({ + "href": f"#{id}", + "xlink:href": f"#{id}", + "x": x, + "y": y, + })) + return SvgWrapper(ET.tostring(svg).decode("utf-8")) From a75d0288c08a239fc18e7d9489ec9fe5caedfe47 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Mon, 22 Jan 2024 04:34:29 +0530 Subject: [PATCH 02/25] fix: refactored to remove unneccessary if statements without changing the behaviour --- chess/svg.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index fa740ca3f..f6c214598 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -319,6 +319,7 @@ def board(board: Optional[chess.BaseBoard] = None, *, margin = 15 if coordinates else 0 full_size = 2 * outer_border + 2 * margin + 2 * inner_border + 8 * SQUARE_SIZE svg = _svg(full_size, size) + defs = ET.SubElement(svg, "defs") if style: ET.SubElement(svg, "style").text = style @@ -327,9 +328,6 @@ def board(board: Optional[chess.BaseBoard] = None, *, desc = ET.SubElement(svg, "desc") asciiboard = ET.SubElement(desc, "pre") asciiboard.text = str(board) - - defs = ET.SubElement(svg, "defs") - if board: for piece_color in chess.COLORS: for piece_type in chess.PIECE_TYPES: if board.pieces_mask(piece_type, piece_color): @@ -339,9 +337,6 @@ def board(board: Optional[chess.BaseBoard] = None, *, if squares: defs.append(ET.fromstring(XX)) - if check is not None: - defs.append(ET.fromstring(CHECK_GRADIENT)) - if outer_border: outer_border_color, outer_border_opacity = _select_color(colors, "outer border") ET.SubElement(svg, "rect", _attrs({ @@ -437,6 +432,7 @@ def board(board: Optional[chess.BaseBoard] = None, *, # Render check mark. if check is not None: + defs.append(ET.fromstring(CHECK_GRADIENT)) file_index = chess.square_file(check) rank_index = chess.square_rank(check) @@ -453,14 +449,14 @@ def board(board: Optional[chess.BaseBoard] = None, *, })) # Render pieces and selected squares. - for square, bb in enumerate(chess.BB_SQUARES): - file_index = chess.square_file(square) - rank_index = chess.square_rank(square) + if board is not None: + for square, bb in enumerate(chess.BB_SQUARES): + file_index = chess.square_file(square) + rank_index = chess.square_rank(square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin - if board is not None: piece = board.piece_at(square) if piece: href = f"#{chess.COLOR_NAMES[piece.color]}-{chess.PIECE_NAMES[piece.piece_type]}" @@ -470,14 +466,14 @@ def board(board: Optional[chess.BaseBoard] = None, *, "transform": f"translate({x:d}, {y:d})", }) - # Render selected squares. - if square in squares: - ET.SubElement(svg, "use", _attrs({ - "href": "#xx", - "xlink:href": "#xx", - "x": x, - "y": y, - })) + # Render selected squares. + if square in squares: + ET.SubElement(svg, "use", _attrs({ + "href": "#xx", + "xlink:href": "#xx", + "x": x, + "y": y, + })) # Render arrows. for arrow in arrows: From 9c1d7f60241553ed7234d46239cd1660e071405c Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Tue, 23 Jan 2024 02:45:42 +0530 Subject: [PATCH 03/25] fix: separated rendering of X marked Squares from Piece rendering --- chess/svg.py | 46 +++++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index f6c214598..08a184990 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -324,19 +324,6 @@ def board(board: Optional[chess.BaseBoard] = None, *, if style: ET.SubElement(svg, "style").text = style - if board: - desc = ET.SubElement(svg, "desc") - asciiboard = ET.SubElement(desc, "pre") - asciiboard.text = str(board) - for piece_color in chess.COLORS: - for piece_type in chess.PIECE_TYPES: - if board.pieces_mask(piece_type, piece_color): - defs.append(ET.fromstring(PIECES[chess.Piece(piece_type, piece_color).symbol()])) - - squares = chess.SquareSet(squares) if squares else chess.SquareSet() - if squares: - defs.append(ET.fromstring(XX)) - if outer_border: outer_border_color, outer_border_opacity = _select_color(colors, "outer border") ET.SubElement(svg, "rect", _attrs({ @@ -450,6 +437,15 @@ def board(board: Optional[chess.BaseBoard] = None, *, # Render pieces and selected squares. if board is not None: + desc = ET.SubElement(svg, "desc") + asciiboard = ET.SubElement(desc, "pre") + asciiboard.text = str(board) + # Defining pieces + for piece_color in chess.COLORS: + for piece_type in chess.PIECE_TYPES: + if board.pieces_mask(piece_type, piece_color): + defs.append(ET.fromstring(PIECES[chess.Piece(piece_type, piece_color).symbol()])) + # Rendering pieces for square, bb in enumerate(chess.BB_SQUARES): file_index = chess.square_file(square) rank_index = chess.square_rank(square) @@ -466,14 +462,22 @@ def board(board: Optional[chess.BaseBoard] = None, *, "transform": f"translate({x:d}, {y:d})", }) - # Render selected squares. - if square in squares: - ET.SubElement(svg, "use", _attrs({ - "href": "#xx", - "xlink:href": "#xx", - "x": x, - "y": y, - })) + # Render X Squares + if squares is not None: + defs.append(ET.fromstring(XX)) + squares = chess.SquareSet(squares) if squares else chess.SquareSet() + for square in squares: + file_index = chess.square_file(square) + rank_index = chess.square_rank(square) + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin + # Render selected squares + ET.SubElement(svg, "use", _attrs({ + "href": "#xx", + "xlink:href": "#xx", + "x": x, + "y": y, + })) # Render arrows. for arrow in arrows: From 4a32115b2cadb5daa3459e7265c99edf81860ae6 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Wed, 24 Jan 2024 00:11:29 +0530 Subject: [PATCH 04/25] fixed the order in which ascii board appeared in the svg --- chess/svg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/svg.py b/chess/svg.py index 08a184990..0b104fab7 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -319,6 +319,7 @@ def board(board: Optional[chess.BaseBoard] = None, *, margin = 15 if coordinates else 0 full_size = 2 * outer_border + 2 * margin + 2 * inner_border + 8 * SQUARE_SIZE svg = _svg(full_size, size) + desc = ET.SubElement(svg, "desc") defs = ET.SubElement(svg, "defs") if style: @@ -437,7 +438,6 @@ def board(board: Optional[chess.BaseBoard] = None, *, # Render pieces and selected squares. if board is not None: - desc = ET.SubElement(svg, "desc") asciiboard = ET.SubElement(desc, "pre") asciiboard.text = str(board) # Defining pieces From eb13080335e140fb751d782663601d5515f54a17 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Wed, 24 Jan 2024 02:43:49 +0530 Subject: [PATCH 05/25] fix: added a check where a valid nag was passed but there is no Glyph for it --- chess/svg.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/chess/svg.py b/chess/svg.py index 0b104fab7..14ee87295 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -551,7 +551,9 @@ def board(board: Optional[chess.BaseBoard] = None, *, "class": "arrow", })) - if nag is not None and lastmove is not None: + if nag is not None and \ + lastmove is not None and \ + NAGS.get(str(nag), None) is not None: ele = ET.fromstring(NAGS[str(nag)]) defs.append(ele) id = ele.attrib.get("id") From b872884bdcb28fc5b0e13355d59ce70d31f177fd Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Wed, 24 Jan 2024 02:44:42 +0530 Subject: [PATCH 06/25] fix: Updated the ?! Glyph background color for it to be more visible --- chess/svg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/svg.py b/chess/svg.py index 14ee87295..2a19167ca 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -74,7 +74,7 @@ """, # "?!" "6": """ - + """ From 368eb5b83e9cc78b18d1e56f8cb1c40bec00a22b Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Wed, 24 Jan 2024 02:45:35 +0530 Subject: [PATCH 07/25] adds basic tests for the squares and NAGs --- test.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test.py b/test.py index e1d0d50c5..ca63f19df 100755 --- a/test.py +++ b/test.py @@ -4299,6 +4299,19 @@ def test_svg_piece(self): svg = chess.svg.piece(chess.Piece.from_symbol("K")) self.assertIn("id=\"white-king\"", svg) + def test_svg_squares(self): + svg = chess.svg.board(squares=[1,2]) + self.assertEqual(svg.count(' Date: Wed, 24 Jan 2024 04:07:03 +0530 Subject: [PATCH 08/25] fixed the background color of inaccuracy glypha again to match mistake but a bit darker --- chess/svg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/svg.py b/chess/svg.py index 2a19167ca..6acf88749 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -74,7 +74,7 @@ """, # "?!" "6": """ - + """ From 7e330b5003eea8c1e8e7aa35809a18d6cbbffd6e Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Wed, 24 Jan 2024 21:20:20 +0530 Subject: [PATCH 09/25] fix: bringing global constants in local scope for lil perf boost --- chess/svg.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/chess/svg.py b/chess/svg.py index 6acf88749..6c34bbd1c 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -313,6 +313,8 @@ def board(board: Optional[chess.BaseBoard] = None, *, .. deprecated:: 1.1 Use *orientation* with a color instead of the *flipped* toggle. """ + # Bringing GLOBAL Constants in the local scope for a little performance boost + global SQUARE_SIZE, MARGIN, NAG_SIZE, PIECES, COORDS, NAGS, XX, CHECK_GRADIENT, DEFAULT_COLORS orientation ^= flipped inner_border = 1 if borders and coordinates else 0 outer_border = 1 if borders else 0 From 1f9977a6984ea1fb2451d510d03d3dfb443391fe Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Thu, 8 Feb 2024 01:03:35 +0530 Subject: [PATCH 10/25] chores: updates docs to show which nags are supported by nag param in chess.svg.board function --- chess/svg.py | 1 + 1 file changed, 1 insertion(+) diff --git a/chess/svg.py b/chess/svg.py index 6c34bbd1c..729300121 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -292,6 +292,7 @@ def board(board: Optional[chess.BaseBoard] = None, *, (if *coordinates* is enabled) the coordinate margin. :param style: A CSS stylesheet to include in the SVG image. :param nag: Pass ``NAG Constant`` to show Numerical Notation Glyphs (NAGs). + Supports !(great), !!(brilliant), ?(mistake), ?!(inaccuracy) and ??(blunder) (requires ``lastmove`` to be passed along as argument) >>> import chess From 33341e2a512faa26818a6b600e381a7345ab7c5d Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Sun, 12 May 2024 18:03:21 +0530 Subject: [PATCH 11/25] Removes border crap and adds coordinates inside the squares --- chess/svg.py | 117 ++++++++++++++++----------------------------------- 1 file changed, 36 insertions(+), 81 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index 729300121..3f8f6ab3b 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -213,14 +213,7 @@ def _color(color: str) -> Tuple[str, float]: return color, 1.0 -def _coord(text: str, x: int, y: int, width: int, height: int, horizontal: bool, margin: int, *, color: str, opacity: float) -> ET.Element: - scale = margin / MARGIN - - if horizontal: - x += int(width - scale * width) // 2 - else: - y += int(height - scale * height) // 2 - +def _coord(text: str, x: float, y: float, scale: float, *, color: str, opacity: float) -> ET.Element: t = ET.Element("g", _attrs({ "transform": f"translate({x}, {y}) scale({scale}, {scale})", "fill": color, @@ -259,7 +252,6 @@ def board(board: Optional[chess.BaseBoard] = None, *, coordinates: bool = True, colors: Dict[str, str] = {}, flipped: bool = False, - borders: bool = False, style: Optional[str] = None, nag:Optional[int] = None) -> str: """ @@ -288,8 +280,6 @@ def board(board: Optional[chess.BaseBoard] = None, *, and ``arrow yellow``. Values should look like ``#ffce9e`` (opaque), or ``#15781B80`` (transparent). :param flipped: Pass ``True`` to flip the board. - :param borders: Pass ``True`` to enable a border around the board and, - (if *coordinates* is enabled) the coordinate margin. :param style: A CSS stylesheet to include in the SVG image. :param nag: Pass ``NAG Constant`` to show Numerical Notation Glyphs (NAGs). Supports !(great), !!(brilliant), ?(mistake), ?!(inaccuracy) and ??(blunder) @@ -317,10 +307,7 @@ def board(board: Optional[chess.BaseBoard] = None, *, # Bringing GLOBAL Constants in the local scope for a little performance boost global SQUARE_SIZE, MARGIN, NAG_SIZE, PIECES, COORDS, NAGS, XX, CHECK_GRADIENT, DEFAULT_COLORS orientation ^= flipped - inner_border = 1 if borders and coordinates else 0 - outer_border = 1 if borders else 0 - margin = 15 if coordinates else 0 - full_size = 2 * outer_border + 2 * margin + 2 * inner_border + 8 * SQUARE_SIZE + full_size = 8 * SQUARE_SIZE svg = _svg(full_size, size) desc = ET.SubElement(svg, "desc") defs = ET.SubElement(svg, "defs") @@ -328,65 +315,13 @@ def board(board: Optional[chess.BaseBoard] = None, *, if style: ET.SubElement(svg, "style").text = style - if outer_border: - outer_border_color, outer_border_opacity = _select_color(colors, "outer border") - ET.SubElement(svg, "rect", _attrs({ - "x": outer_border / 2, - "y": outer_border / 2, - "width": full_size - outer_border, - "height": full_size - outer_border, - "fill": "none", - "stroke": outer_border_color, - "stroke-width": outer_border, - "opacity": outer_border_opacity if outer_border_opacity < 1.0 else None, - })) - - if margin: - margin_color, margin_opacity = _select_color(colors, "margin") - ET.SubElement(svg, "rect", _attrs({ - "x": outer_border + margin / 2, - "y": outer_border + margin / 2, - "width": full_size - 2 * outer_border - margin, - "height": full_size - 2 * outer_border - margin, - "fill": "none", - "stroke": margin_color, - "stroke-width": margin, - "opacity": margin_opacity if margin_opacity < 1.0 else None, - })) - - if inner_border: - inner_border_color, inner_border_opacity = _select_color(colors, "inner border") - ET.SubElement(svg, "rect", _attrs({ - "x": outer_border + margin + inner_border / 2, - "y": outer_border + margin + inner_border / 2, - "width": full_size - 2 * outer_border - 2 * margin - inner_border, - "height": full_size - 2 * outer_border - 2 * margin - inner_border, - "fill": "none", - "stroke": inner_border_color, - "stroke-width": inner_border, - "opacity": inner_border_opacity if inner_border_opacity < 1.0 else None, - })) - - # Render coordinates. - if coordinates: - coord_color, coord_opacity = _select_color(colors, "coord") - for file_index, file_name in enumerate(chess.FILE_NAMES): - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + inner_border + margin + outer_border - # Keep some padding here to separate the ascender from the border - svg.append(_coord(file_name, x, 1, SQUARE_SIZE, margin, True, margin, color=coord_color, opacity=coord_opacity)) - svg.append(_coord(file_name, x, full_size - outer_border - margin, SQUARE_SIZE, margin, True, margin, color=coord_color, opacity=coord_opacity)) - for rank_index, rank_name in enumerate(chess.RANK_NAMES): - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + inner_border + margin + outer_border - svg.append(_coord(rank_name, 0, y, margin, SQUARE_SIZE, False, margin, color=coord_color, opacity=coord_opacity)) - svg.append(_coord(rank_name, full_size - outer_border - margin, y, margin, SQUARE_SIZE, False, margin, color=coord_color, opacity=coord_opacity)) - # Render board. for square, bb in enumerate(chess.BB_SQUARES): file_index = chess.square_file(square) rank_index = chess.square_rank(square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + inner_border + margin + outer_border - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + inner_border + margin + outer_border + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark"] if lastmove and square in [lastmove.from_square, lastmove.to_square]: @@ -427,8 +362,8 @@ def board(board: Optional[chess.BaseBoard] = None, *, file_index = chess.square_file(check) rank_index = chess.square_rank(check) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE ET.SubElement(svg, "rect", _attrs({ "x": x, @@ -453,8 +388,8 @@ def board(board: Optional[chess.BaseBoard] = None, *, file_index = chess.square_file(square) rank_index = chess.square_rank(square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE piece = board.piece_at(square) if piece: @@ -472,8 +407,8 @@ def board(board: Optional[chess.BaseBoard] = None, *, for square in squares: file_index = chess.square_file(square) rank_index = chess.square_rank(square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE # Render selected squares ET.SubElement(svg, "use", _attrs({ "href": "#xx", @@ -500,10 +435,10 @@ def board(board: Optional[chess.BaseBoard] = None, *, head_file = chess.square_file(head) head_rank = chess.square_rank(head) - xtail = outer_border + margin + inner_border + (tail_file + 0.5 if orientation else 7.5 - tail_file) * SQUARE_SIZE - ytail = outer_border + margin + inner_border + (7.5 - tail_rank if orientation else tail_rank + 0.5) * SQUARE_SIZE - xhead = outer_border + margin + inner_border + (head_file + 0.5 if orientation else 7.5 - head_file) * SQUARE_SIZE - yhead = outer_border + margin + inner_border + (7.5 - head_rank if orientation else head_rank + 0.5) * SQUARE_SIZE + xtail = (tail_file + 0.5 if orientation else 7.5 - tail_file) * SQUARE_SIZE + ytail = (7.5 - tail_rank if orientation else tail_rank + 0.5) * SQUARE_SIZE + xhead = (head_file + 0.5 if orientation else 7.5 - head_file) * SQUARE_SIZE + yhead = (7.5 - head_rank if orientation else head_rank + 0.5) * SQUARE_SIZE if (head_file, head_rank) == (tail_file, tail_rank): ET.SubElement(svg, "circle", _attrs({ @@ -562,8 +497,8 @@ def board(board: Optional[chess.BaseBoard] = None, *, id = ele.attrib.get("id") file_index = chess.square_file(lastmove.to_square) rank_index = chess.square_rank(lastmove.to_square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE from_file_index = chess.square_file(lastmove.from_square) # Making sure the NAGs doesn't overlap the Last Move Arrow by switching # between top left and top right corner depending upon where the Arrow @@ -576,4 +511,24 @@ def board(board: Optional[chess.BaseBoard] = None, *, "y": y, })) + # Render coordinates. + if coordinates: + light_color, light_opacity = _select_color(colors, "square light") + dark_color, dark_opacity = _select_color(colors, "square dark") + text_scale = 0.5 + width = 18 + height = 18 + width *= text_scale + height *= text_scale + for file_index, file_name in enumerate(chess.FILE_NAMES): + x = ((file_index if orientation else 7 - file_index) * SQUARE_SIZE) - width + y = full_size - height + coord_color, coord_opacity = (light_color, light_opacity) if (file_index+orientation)%2 == 1 else (dark_color, dark_opacity) + svg.append(_coord(file_name, x+1.5, y-1, text_scale, color=coord_color, opacity=coord_opacity)) + x += (7 - file_index if orientation else file_index) * SQUARE_SIZE + x += SQUARE_SIZE + for rank_index, rank_name in enumerate(chess.RANK_NAMES): + y = ((7 - rank_index if orientation else rank_index) * SQUARE_SIZE) - height + coord_color, coord_opacity = (dark_color, dark_opacity) if (rank_index+orientation)%2 == 1 else (light_color, light_opacity) + svg.append(_coord(rank_name, x-1, y+3, text_scale, color=coord_color, opacity=coord_opacity)) return SvgWrapper(ET.tostring(svg).decode("utf-8")) From d8b7160b0ca2931a090d0abdba2d14b31c9d6ba5 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Sun, 12 May 2024 18:06:50 +0530 Subject: [PATCH 12/25] Fixed the z-order of coords so that they don't overlap glyphs or other important stuff. --- chess/svg.py | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index 3f8f6ab3b..7ade5320a 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -400,6 +400,27 @@ def board(board: Optional[chess.BaseBoard] = None, *, "transform": f"translate({x:d}, {y:d})", }) + # Render coordinates. + if coordinates: + light_color, light_opacity = _select_color(colors, "square light") + dark_color, dark_opacity = _select_color(colors, "square dark") + text_scale = 0.5 + width = 18 + height = 18 + width *= text_scale + height *= text_scale + for file_index, file_name in enumerate(chess.FILE_NAMES): + x = ((file_index if orientation else 7 - file_index) * SQUARE_SIZE) - width + y = full_size - height + coord_color, coord_opacity = (light_color, light_opacity) if (file_index+orientation)%2 == 1 else (dark_color, dark_opacity) + svg.append(_coord(file_name, x+1.5, y-1, text_scale, color=coord_color, opacity=coord_opacity)) + x += (7 - file_index if orientation else file_index) * SQUARE_SIZE + x += SQUARE_SIZE + for rank_index, rank_name in enumerate(chess.RANK_NAMES): + y = ((7 - rank_index if orientation else rank_index) * SQUARE_SIZE) - height + coord_color, coord_opacity = (dark_color, dark_opacity) if (rank_index+orientation)%2 == 1 else (light_color, light_opacity) + svg.append(_coord(rank_name, x-1, y+3, text_scale, color=coord_color, opacity=coord_opacity)) + # Render X Squares if squares is not None: defs.append(ET.fromstring(XX)) @@ -510,25 +531,5 @@ def board(board: Optional[chess.BaseBoard] = None, *, "x": x, "y": y, })) - - # Render coordinates. - if coordinates: - light_color, light_opacity = _select_color(colors, "square light") - dark_color, dark_opacity = _select_color(colors, "square dark") - text_scale = 0.5 - width = 18 - height = 18 - width *= text_scale - height *= text_scale - for file_index, file_name in enumerate(chess.FILE_NAMES): - x = ((file_index if orientation else 7 - file_index) * SQUARE_SIZE) - width - y = full_size - height - coord_color, coord_opacity = (light_color, light_opacity) if (file_index+orientation)%2 == 1 else (dark_color, dark_opacity) - svg.append(_coord(file_name, x+1.5, y-1, text_scale, color=coord_color, opacity=coord_opacity)) - x += (7 - file_index if orientation else file_index) * SQUARE_SIZE - x += SQUARE_SIZE - for rank_index, rank_name in enumerate(chess.RANK_NAMES): - y = ((7 - rank_index if orientation else rank_index) * SQUARE_SIZE) - height - coord_color, coord_opacity = (dark_color, dark_opacity) if (rank_index+orientation)%2 == 1 else (light_color, light_opacity) - svg.append(_coord(rank_name, x-1, y+3, text_scale, color=coord_color, opacity=coord_opacity)) + return SvgWrapper(ET.tostring(svg).decode("utf-8")) From 8d808ce3dd51772a3c568bdf8a18228636daaa43 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Sun, 12 May 2024 18:39:04 +0530 Subject: [PATCH 13/25] Fixed type errors --- .gitignore | 1 + chess/svg.py | 13 ++++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 076e4fcff..2dca5d95c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ data/syzygy/giveaway/*.[gs]tb[wz] fuzz/corpus release-v*.txt +.venv \ No newline at end of file diff --git a/chess/svg.py b/chess/svg.py index 7ade5320a..450185121 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -405,19 +405,18 @@ def board(board: Optional[chess.BaseBoard] = None, *, light_color, light_opacity = _select_color(colors, "square light") dark_color, dark_opacity = _select_color(colors, "square dark") text_scale = 0.5 - width = 18 - height = 18 - width *= text_scale - height *= text_scale + coord_size = 18 + width = coord_size * text_scale + height = coord_size * text_scale for file_index, file_name in enumerate(chess.FILE_NAMES): - x = ((file_index if orientation else 7 - file_index) * SQUARE_SIZE) - width - y = full_size - height + x = ((file_index if orientation else 7 - file_index) * SQUARE_SIZE) - width # type: ignore + y = full_size - height # type: ignore coord_color, coord_opacity = (light_color, light_opacity) if (file_index+orientation)%2 == 1 else (dark_color, dark_opacity) svg.append(_coord(file_name, x+1.5, y-1, text_scale, color=coord_color, opacity=coord_opacity)) x += (7 - file_index if orientation else file_index) * SQUARE_SIZE x += SQUARE_SIZE for rank_index, rank_name in enumerate(chess.RANK_NAMES): - y = ((7 - rank_index if orientation else rank_index) * SQUARE_SIZE) - height + y = ((7 - rank_index if orientation else rank_index) * SQUARE_SIZE) - height # type: ignore coord_color, coord_opacity = (dark_color, dark_opacity) if (rank_index+orientation)%2 == 1 else (light_color, light_opacity) svg.append(_coord(rank_name, x-1, y+3, text_scale, color=coord_color, opacity=coord_opacity)) From 88171b7b9c6d2a4c94df4d7919aea0e9eb4cd8ac Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Mon, 13 May 2024 00:13:33 +0530 Subject: [PATCH 14/25] Fixed the bug where glyphs weren't placed correctly in flipped board --- chess/svg.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/chess/svg.py b/chess/svg.py index 450185121..6addc25ef 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -519,10 +519,12 @@ def board(board: Optional[chess.BaseBoard] = None, *, rank_index = chess.square_rank(lastmove.to_square) x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE - from_file_index = chess.square_file(lastmove.from_square) # Making sure the NAGs doesn't overlap the Last Move Arrow by switching # between top left and top right corner depending upon where the Arrow # is coming from. + from_file_index = chess.square_file(lastmove.from_square) + file_index = (file_index if orientation else 7 - file_index) + from_file_index = (from_file_index if orientation else 7 - from_file_index) x = x + (SQUARE_SIZE - NAG_SIZE if file_index >= from_file_index else 0) ET.SubElement(svg, "use", _attrs({ "href": f"#{id}", From f86269d1f811d97384f7a51ab9afc8ce0a9d2f9e Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Sun, 26 May 2024 03:44:28 +0530 Subject: [PATCH 15/25] Decouple the rendering of lastmove rects from rendering of board for performance --- chess/svg.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index 6addc25ef..bc77faa42 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -90,8 +90,6 @@ "square dark lastmove": "#aaa23b", "square light lastmove": "#cdd16a", "margin": "#212121", - "inner border": "#111", - "outer border": "#111", "coord": "#e5e5e5", "arrow green": "#15781B80", "arrow red": "#88202080", @@ -324,8 +322,6 @@ def board(board: Optional[chess.BaseBoard] = None, *, y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark"] - if lastmove and square in [lastmove.from_square, lastmove.to_square]: - cls.append("lastmove") square_color, square_opacity = _select_color(colors, " ".join(cls)) cls.append(chess.SQUARE_NAMES[square]) @@ -355,6 +351,32 @@ def board(board: Optional[chess.BaseBoard] = None, *, "fill": fill_color, "opacity": fill_opacity if fill_opacity < 1.0 else None, })) + + # Rendering lastmove + if lastmove: + for square in [lastmove.from_square, lastmove.to_square]: + bb = 1 << square + file_index = chess.square_file(square) + rank_index = chess.square_rank(square) + + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + + cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark", "lastmove"] + square_color, square_opacity = _select_color(colors, " ".join(cls)) + + cls.append(chess.SQUARE_NAMES[square]) + + ET.SubElement(svg, "rect", _attrs({ + "x": x, + "y": y, + "width": SQUARE_SIZE, + "height": SQUARE_SIZE, + "class": " ".join(cls), + "stroke": "none", + "fill": square_color, + "opacity": square_opacity if square_opacity < 1.0 else None, + })) # Render check mark. if check is not None: From 5eba38f8b96096e7aa4678c63ca7f8044d7bc712 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Wed, 27 Nov 2024 04:05:31 +0530 Subject: [PATCH 16/25] feat: adds parser and svg print support for Novelty nag --- chess/pgn.py | 4 ++++ chess/svg.py | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/chess/pgn.py b/chess/pgn.py index 55eddbc29..bf0008cb5 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -92,7 +92,9 @@ |(\() |(\)) |(\*|1-0|0-1|1/2-1/2) + |(\!N) |([\?!]{1,2}) + |(TN) """, re.DOTALL | re.VERBOSE) SKIP_MOVETEXT_REGEX = re.compile(r""";|\{|\}""") @@ -1736,6 +1738,8 @@ def read_game(handle: TextIO, *, Visitor: Any = GameBuilder) -> Any: visitor.visit_nag(NAG_SPECULATIVE_MOVE) elif token == "?!": visitor.visit_nag(NAG_DUBIOUS_MOVE) + elif token == "TN" or token == "!N": + visitor.visit_nag(NAG_NOVELTY) elif token in ["1-0", "0-1", "1/2-1/2", "*"] and len(board_stack) == 1: visitor.visit_result(token) else: diff --git a/chess/svg.py b/chess/svg.py index bc77faa42..f646d5c0e 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -77,7 +77,13 @@ - """ + """, + # "N" + "146": """ + + + """ + } XX = """""" # noqa: E501 From 5809ef1e498dc2a4025318786a629acff1fc4fcb Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Wed, 27 Nov 2024 04:07:48 +0530 Subject: [PATCH 17/25] fix: taken care all the linter warnings --- chess/__init__.py | 13 +++++++------ chess/pgn.py | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index a9328a04b..64c2b7d29 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1340,7 +1340,7 @@ def transform(self: BaseBoardT, f: Callable[[Bitboard], Bitboard]) -> BaseBoardT board.apply_transform(f) return board - def apply_mirror(self: BaseBoardT) -> None: + def apply_mirror(self: BaseBoard) -> None: self.apply_transform(flip_vertical) self.occupied_co[WHITE], self.occupied_co[BLACK] = self.occupied_co[BLACK], self.occupied_co[WHITE] @@ -1561,14 +1561,14 @@ class Board(BaseBoard): manipulation. """ - def __init__(self: BoardT, fen: Optional[str] = STARTING_FEN, *, chess960: bool = False) -> None: + def __init__(self: Board, fen: Optional[str] = STARTING_FEN, *, chess960: bool = False) -> None: BaseBoard.__init__(self, None) self.chess960 = chess960 self.ep_square = None self.move_stack = [] - self._stack: List[_BoardState[BoardT]] = [] + self._stack: List[_BoardState[Board]] = [] if fen is None: self.clear() @@ -2177,7 +2177,7 @@ def _board_state(self: BoardT) -> _BoardState[BoardT]: def _push_capture(self, move: Move, capture_square: Square, piece_type: PieceType, was_promoted: bool) -> None: pass - def push(self: BoardT, move: Move) -> None: + def push(self: Board, move: Move) -> None: """ Updates the position with the given *move* and puts it onto the move stack. @@ -2262,6 +2262,7 @@ def push(self: BoardT, move: Move) -> None: elif diff == -16 and square_rank(move.from_square) == 6: self.ep_square = move.from_square - 8 elif move.to_square == ep_square and abs(diff) in [7, 9] and not captured_piece_type: + assert ep_square is not None # Remove pawns captured en passant. down = -8 if self.turn == WHITE else 8 capture_square = ep_square + down @@ -2298,7 +2299,7 @@ def push(self: BoardT, move: Move) -> None: # Swap turn. self.turn = not self.turn - def pop(self: BoardT) -> Move: + def pop(self: Board) -> Move: """ Restores the previous position and returns the last move from the stack. @@ -3696,7 +3697,7 @@ def transform(self: BoardT, f: Callable[[Bitboard], Bitboard]) -> BoardT: board.apply_transform(f) return board - def apply_mirror(self: BoardT) -> None: + def apply_mirror(self: Board) -> None: super().apply_mirror() self.turn = not self.turn diff --git a/chess/pgn.py b/chess/pgn.py index bf0008cb5..c1969301e 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -924,7 +924,7 @@ def without_tag_roster(cls: Type[GameT]) -> GameT: return cls(headers={}) @classmethod - def builder(cls: Type[GameT]) -> GameBuilder[GameT]: + def builder(cls: Type[GameT]) -> GameBuilder[Game]: return GameBuilder(Game=cls) def __repr__(self) -> str: @@ -1028,7 +1028,7 @@ def __repr__(self) -> str: ", ".join("{}={!r}".format(key, value) for key, value in self.items())) @classmethod - def builder(cls: Type[HeadersT]) -> HeadersBuilder[HeadersT]: + def builder(cls: Type[HeadersT]) -> HeadersBuilder[Headers]: return HeadersBuilder(Headers=cls) @@ -1182,7 +1182,7 @@ class GameBuilder(BaseVisitor[GameT]): @typing.overload def __init__(self: GameBuilder[Game]) -> None: ... @typing.overload - def __init__(self: GameBuilder[GameT], *, Game: Type[GameT]) -> None: ... + def __init__(self: GameBuilder[Game], *, Game: Type[GameT]) -> None: ... def __init__(self, *, Game: Any = Game) -> None: self.Game = Game @@ -1279,7 +1279,7 @@ class HeadersBuilder(BaseVisitor[HeadersT]): @typing.overload def __init__(self: HeadersBuilder[Headers]) -> None: ... @typing.overload - def __init__(self: HeadersBuilder[HeadersT], *, Headers: Type[Headers]) -> None: ... + def __init__(self: HeadersBuilder[Headers], *, Headers: Type[Headers]) -> None: ... def __init__(self, *, Headers: Any = Headers) -> None: self.Headers = Headers From 98a1a38b7fe5c2eec2cb255da9d0c5e5b67143c9 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Thu, 28 Nov 2024 04:02:47 +0530 Subject: [PATCH 18/25] fix: Updated Novelty nag's color and reduced the size of letter N --- chess/svg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index f646d5c0e..474c6bfac 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -80,8 +80,8 @@ """, # "N" "146": """ - - + + """ } From bf7f63238df17b2559884ce3c44f267a9f56bce1 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Thu, 28 Nov 2024 04:04:18 +0530 Subject: [PATCH 19/25] fixes placement of the nag, made it more dynamic and also respects the first/last ranks and files --- chess/svg.py | 95 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 39 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index 474c6bfac..b45b40cca 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -308,8 +308,6 @@ def board(board: Optional[chess.BaseBoard] = None, *, .. deprecated:: 1.1 Use *orientation* with a color instead of the *flipped* toggle. """ - # Bringing GLOBAL Constants in the local scope for a little performance boost - global SQUARE_SIZE, MARGIN, NAG_SIZE, PIECES, COORDS, NAGS, XX, CHECK_GRADIENT, DEFAULT_COLORS orientation ^= flipped full_size = 8 * SQUARE_SIZE svg = _svg(full_size, size) @@ -321,11 +319,11 @@ def board(board: Optional[chess.BaseBoard] = None, *, # Render board. for square, bb in enumerate(chess.BB_SQUARES): - file_index = chess.square_file(square) - rank_index = chess.square_rank(square) + to_file = chess.square_file(square) + to_rank = chess.square_rank(square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE + y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark"] square_color, square_opacity = _select_color(colors, " ".join(cls)) @@ -362,11 +360,11 @@ def board(board: Optional[chess.BaseBoard] = None, *, if lastmove: for square in [lastmove.from_square, lastmove.to_square]: bb = 1 << square - file_index = chess.square_file(square) - rank_index = chess.square_rank(square) + to_file = chess.square_file(square) + to_rank = chess.square_rank(square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE + y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark", "lastmove"] square_color, square_opacity = _select_color(colors, " ".join(cls)) @@ -387,11 +385,11 @@ def board(board: Optional[chess.BaseBoard] = None, *, # Render check mark. if check is not None: defs.append(ET.fromstring(CHECK_GRADIENT)) - file_index = chess.square_file(check) - rank_index = chess.square_rank(check) + to_file = chess.square_file(check) + to_rank = chess.square_rank(check) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE + y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE ET.SubElement(svg, "rect", _attrs({ "x": x, @@ -413,11 +411,11 @@ def board(board: Optional[chess.BaseBoard] = None, *, defs.append(ET.fromstring(PIECES[chess.Piece(piece_type, piece_color).symbol()])) # Rendering pieces for square, bb in enumerate(chess.BB_SQUARES): - file_index = chess.square_file(square) - rank_index = chess.square_rank(square) + to_file = chess.square_file(square) + to_rank = chess.square_rank(square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE + y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE piece = board.piece_at(square) if piece: @@ -436,16 +434,16 @@ def board(board: Optional[chess.BaseBoard] = None, *, coord_size = 18 width = coord_size * text_scale height = coord_size * text_scale - for file_index, file_name in enumerate(chess.FILE_NAMES): - x = ((file_index if orientation else 7 - file_index) * SQUARE_SIZE) - width # type: ignore + for to_file, file_name in enumerate(chess.FILE_NAMES): + x = ((to_file if orientation else 7 - to_file) * SQUARE_SIZE) - width # type: ignore y = full_size - height # type: ignore - coord_color, coord_opacity = (light_color, light_opacity) if (file_index+orientation)%2 == 1 else (dark_color, dark_opacity) + coord_color, coord_opacity = (light_color, light_opacity) if (to_file+orientation)%2 == 1 else (dark_color, dark_opacity) svg.append(_coord(file_name, x+1.5, y-1, text_scale, color=coord_color, opacity=coord_opacity)) - x += (7 - file_index if orientation else file_index) * SQUARE_SIZE + x += (7 - to_file if orientation else to_file) * SQUARE_SIZE x += SQUARE_SIZE - for rank_index, rank_name in enumerate(chess.RANK_NAMES): - y = ((7 - rank_index if orientation else rank_index) * SQUARE_SIZE) - height # type: ignore - coord_color, coord_opacity = (dark_color, dark_opacity) if (rank_index+orientation)%2 == 1 else (light_color, light_opacity) + for to_rank, rank_name in enumerate(chess.RANK_NAMES): + y = ((7 - to_rank if orientation else to_rank) * SQUARE_SIZE) - height # type: ignore + coord_color, coord_opacity = (dark_color, dark_opacity) if (to_rank+orientation)%2 == 1 else (light_color, light_opacity) svg.append(_coord(rank_name, x-1, y+3, text_scale, color=coord_color, opacity=coord_opacity)) # Render X Squares @@ -453,10 +451,10 @@ def board(board: Optional[chess.BaseBoard] = None, *, defs.append(ET.fromstring(XX)) squares = chess.SquareSet(squares) if squares else chess.SquareSet() for square in squares: - file_index = chess.square_file(square) - rank_index = chess.square_rank(square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + to_file = chess.square_file(square) + to_rank = chess.square_rank(square) + x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE + y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE # Render selected squares ET.SubElement(svg, "use", _attrs({ "href": "#xx", @@ -543,17 +541,36 @@ def board(board: Optional[chess.BaseBoard] = None, *, ele = ET.fromstring(NAGS[str(nag)]) defs.append(ele) id = ele.attrib.get("id") - file_index = chess.square_file(lastmove.to_square) - rank_index = chess.square_rank(lastmove.to_square) - x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + to_file = chess.square_file(lastmove.to_square) + to_rank = chess.square_rank(lastmove.to_square) + to_file = to_file if orientation else 7 - to_file + to_rank = 7 - to_rank if orientation else to_rank + x = to_file * SQUARE_SIZE + y = to_rank * SQUARE_SIZE + + from_file = chess.square_file(lastmove.from_square) + from_rank = chess.square_rank(lastmove.from_square) + from_file = from_file if orientation else 7 - from_file + from_rank = 7 - from_rank if orientation else from_rank + + delta_file = to_file - from_file + offset = SQUARE_SIZE - NAG_SIZE + corner_offset = NAG_SIZE/2 + # Making sure the NAGs doesn't overlap the Last Move Arrow by switching - # between top left and top right corner depending upon where the Arrow - # is coming from. - from_file_index = chess.square_file(lastmove.from_square) - file_index = (file_index if orientation else 7 - file_index) - from_file_index = (from_file_index if orientation else 7 - from_file_index) - x = x + (SQUARE_SIZE - NAG_SIZE if file_index >= from_file_index else 0) + # between appropriate corners depending upon where the Arrow is coming from. + if delta_file >= 0: # Moving towards the right + x += offset # Top-right corner + x += corner_offset + if to_file == 7: + x -= corner_offset + else: # Moving towards the left OR Same File + x -= corner_offset + if to_file == 0: + x += corner_offset + y -= corner_offset + if to_rank == 0: + y += corner_offset ET.SubElement(svg, "use", _attrs({ "href": f"#{id}", "xlink:href": f"#{id}", From c70fe859fedd2a1b1cebb5afea18beeeea7b1970 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Fri, 29 Nov 2024 01:36:42 +0530 Subject: [PATCH 20/25] Reduces size of arrow --- chess/svg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index b45b40cca..c3072cd9d 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -498,7 +498,7 @@ def board(board: Optional[chess.BaseBoard] = None, *, "class": "circle", })) else: - marker_size = 0.75 * SQUARE_SIZE + marker_size = 0.50 * SQUARE_SIZE marker_margin = 0.1 * SQUARE_SIZE dx, dy = xhead - xtail, yhead - ytail @@ -517,7 +517,7 @@ def board(board: Optional[chess.BaseBoard] = None, *, "y2": shaft_y, "stroke": color, "opacity": opacity if opacity < 1.0 else None, - "stroke-width": SQUARE_SIZE * 0.2, + "stroke-width": SQUARE_SIZE * 0.15, "stroke-linecap": "butt", "class": "arrow", })) From 473d6573f95a5e162325d3f692247405111e62d7 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Fri, 29 Nov 2024 03:59:02 +0530 Subject: [PATCH 21/25] bumped size of nags and fix their colors --- chess/svg.py | 74 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index c3072cd9d..98fada4d8 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -50,39 +50,77 @@ NAGS = { # "!" "1": """ - - + + + + + + + + """, # "?" "2": """ - - + + + + + + + + + + """, # "!!" "3": """ - - - + + + + + + + + + + """, # "??" "4": """ - - - - - + + + + + + + + + + + + + + """, # "?!" "6": """ - - - + + + + + + + + + + """, # "N" "146": """ - - - """ + + + + """ } From 97eac2c6d4269909705f0e6b8b88d6ac3ca94afd Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Mon, 16 Dec 2024 21:52:53 +0530 Subject: [PATCH 22/25] fixed nag color of inaccuracy and mistake --- chess/svg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index 98fada4d8..475b1957c 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -63,7 +63,7 @@ "2": """ - + @@ -105,7 +105,7 @@ # "?!" "6": """ - + From 1aff9319a3049ffe6a16545de674a6d9fdc187b5 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Wed, 29 Jan 2025 04:08:32 +0530 Subject: [PATCH 23/25] fix: updated the inaccuracy nag color to be same as mistake --- chess/svg.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/svg.py b/chess/svg.py index 475b1957c..af609dcfc 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -105,7 +105,7 @@ # "?!" "6": """ - + From fc477d42a0e5503e6addfd4e4502c75d29bb4dd1 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Sun, 15 Jun 2025 08:48:52 +0530 Subject: [PATCH 24/25] refactor: rename arrow handling methods for %csl and %cal and update related tests --- chess/pgn.py | 67 +++++++++++++++++++++++++++++++++++++++++++--------- test.py | 24 +++++++++++-------- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/chess/pgn.py b/chess/pgn.py index c1969301e..a3e486d7f 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -114,15 +114,24 @@ (?P\s?) """, re.VERBOSE) -ARROWS_REGEX = re.compile(r""" +CAL_REGEX = re.compile(r""" (?P\s?) - \[%(?:csl|cal)\s(?P + \[%(?:cal)\s(?P [RGYB][a-h][1-8](?:[a-h][1-8])? (?:,[RGYB][a-h][1-8](?:[a-h][1-8])?)* )\] (?P\s?) """, re.VERBOSE) +CSL_REGEX = re.compile(r""" + (?P\s?) + \[%(?:csl)\s(?P + [RGYB][a-h][1-8]? + (?:,[RGYB][a-h][1-8]?)* + )\] + (?P\s?) + """, re.VERBOSE) + def _condense_affix(infix: str) -> Callable[[typing.Match[str]], str]: def repl(match: typing.Match[str]) -> str: if infix: @@ -500,24 +509,36 @@ def set_eval(self, score: Optional[chess.engine.PovScore], depth: Optional[int] def arrows(self) -> List[chess.svg.Arrow]: """ - Parses all ``[%csl ...]`` and ``[%cal ...]`` annotations in the comment + Parses all ``[%cal ...]`` annotations in the comment of this node. Returns a list of :class:`arrows `. """ arrows = [] - for match in ARROWS_REGEX.finditer(self.comment): + for match in CAL_REGEX.finditer(self.comment): + for group in match.group("arrows").split(","): + arrows.append(chess.svg.Arrow.from_pgn(group)) + + return arrows + + def csl(self) -> List[chess.svg.Arrow]: + """ + Parses all ``[%csl ...]`` annotations in the comment of this node. + + Returns a list of :class:`arrows `. + """ + arrows = [] + for match in CSL_REGEX.finditer(self.comment): for group in match.group("arrows").split(","): arrows.append(chess.svg.Arrow.from_pgn(group)) return arrows - def set_arrows(self, arrows: Iterable[Union[chess.svg.Arrow, Tuple[Square, Square]]]) -> None: + def set_cal(self, arrows: Iterable[Union[chess.svg.Arrow, Tuple[Square, Square]]]) -> None: """ - Replaces all valid ``[%csl ...]`` and ``[%cal ...]`` annotations in + Replaces all valid ``[%cal ...]`` annotations in the comment of this node or adds new ones. """ - csl: List[str] = [] cal: List[str] = [] for arrow in arrows: @@ -526,13 +547,11 @@ def set_arrows(self, arrows: Iterable[Union[chess.svg.Arrow, Tuple[Square, Squar arrow = chess.svg.Arrow(tail, head) except TypeError: pass - (csl if arrow.tail == arrow.head else cal).append(arrow.pgn()) # type: ignore + cal.append(arrow.pgn()) # type: ignore - self.comment = ARROWS_REGEX.sub(_condense_affix(""), self.comment) + self.comment = CAL_REGEX.sub(_condense_affix(""), self.comment) prefix = "" - if csl: - prefix += f"[%csl {','.join(csl)}]" if cal: prefix += f"[%cal {','.join(cal)}]" @@ -541,6 +560,32 @@ def set_arrows(self, arrows: Iterable[Union[chess.svg.Arrow, Tuple[Square, Squar else: self.comment = prefix + self.comment + def set_csl(self, arrows: Iterable[Union[chess.svg.Arrow, Tuple[Square, Square]]]) -> None: + """ + Replaces all valid ``[%csl ...]`` annotations in + the comment of this node or adds new ones. + """ + csl: List[str] = [] + + for arrow in arrows: + try: + tail, head = arrow # type: ignore + arrow = chess.svg.Arrow(tail, head) + except TypeError: + pass + csl.append(arrow.pgn()) # type: ignore + + self.comment = CSL_REGEX.sub(_condense_affix(""), self.comment) + + prefix = "" + if csl: + prefix += f"[%csl {','.join(csl)}]" + + if prefix and self.comment and not self.comment.startswith(" ") and not self.comment.startswith("\n"): + self.comment = prefix + " " + self.comment + else: + self.comment = prefix + self.comment + def clock(self) -> Optional[float]: """ Parses the first valid ``[%clk ...]`` annotation in the comment of diff --git a/test.py b/test.py index 940902c67..0d250e985 100755 --- a/test.py +++ b/test.py @@ -2839,28 +2839,32 @@ def test_annotations(self): self.assertEqual(game.eval_depth(), 5) self.assertEqual(game.arrows(), []) - game.set_arrows([(chess.A1, chess.A1), chess.svg.Arrow(chess.A1, chess.H1, color="red"), chess.svg.Arrow(chess.B1, chess.B8)]) - self.assertEqual(game.comment, "[%csl Ga1][%cal Ra1h1,Gb1b8] foo [%bar] baz [%clk 3:25:45] [%eval #1,5]") + game.set_cal([chess.svg.Arrow(chess.A1, chess.H1, color="red"), chess.svg.Arrow(chess.B1, chess.B8)]) + game.set_csl([(chess.A1, chess.A1)]) + self.assertEqual(game.comment, "[%csl Ga1] [%cal Ra1h1,Gb1b8] foo [%bar] baz [%clk 3:25:45] [%eval #1,5]") arrows = game.arrows() - self.assertEqual(len(arrows), 3) - self.assertEqual(arrows[0].color, "green") - self.assertEqual(arrows[1].color, "red") - self.assertEqual(arrows[2].color, "green") + self.assertEqual(len(arrows), 2) + self.assertEqual(arrows[0].color, "red") + self.assertEqual(arrows[1].color, "green") + csl = game.csl() + self.assertEqual(len(csl), 1) + self.assertEqual(csl[0].color, "green") self.assertTrue(game.emt() is None) emt = 321 game.set_emt(emt) - self.assertEqual(game.comment, "[%csl Ga1][%cal Ra1h1,Gb1b8] foo [%bar] baz [%clk 3:25:45] [%eval #1,5] [%emt 0:05:21]") + self.assertEqual(game.comment, "[%csl Ga1] [%cal Ra1h1,Gb1b8] foo [%bar] baz [%clk 3:25:45] [%eval #1,5] [%emt 0:05:21]") self.assertEqual(game.emt(), emt) game.set_eval(None) - self.assertEqual(game.comment, "[%csl Ga1][%cal Ra1h1,Gb1b8] foo [%bar] baz [%clk 3:25:45] [%emt 0:05:21]") + self.assertEqual(game.comment, "[%csl Ga1] [%cal Ra1h1,Gb1b8] foo [%bar] baz [%clk 3:25:45] [%emt 0:05:21]") game.set_emt(None) - self.assertEqual(game.comment, "[%csl Ga1][%cal Ra1h1,Gb1b8] foo [%bar] baz [%clk 3:25:45]") + self.assertEqual(game.comment, "[%csl Ga1] [%cal Ra1h1,Gb1b8] foo [%bar] baz [%clk 3:25:45]") game.set_clock(None) - game.set_arrows([]) + game.set_cal([]) + game.set_csl([]) self.assertEqual(game.comment, "foo [%bar] baz") def test_eval(self): From 3e6d8819896262c23dcd4b3ff2f3ccffda5b88e0 Mon Sep 17 00:00:00 2001 From: Vikas Gautam Date: Sun, 28 Sep 2025 00:58:18 +0530 Subject: [PATCH 25/25] Updating chess.svg from the custom implementation from current personal project --- .gitignore | 4 +- chess/svg.py | 1358 +++++++++++++++++++++++++++++--------------------- 2 files changed, 793 insertions(+), 569 deletions(-) diff --git a/.gitignore b/.gitignore index 2dca5d95c..ecdcd1344 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,6 @@ data/syzygy/giveaway/*.[gs]tb[wz] fuzz/corpus release-v*.txt -.venv \ No newline at end of file +.venv + +test_animation \ No newline at end of file diff --git a/chess/svg.py b/chess/svg.py index af609dcfc..be3f465f1 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -1,619 +1,841 @@ -from __future__ import annotations - +from copy import copy import math import xml.etree.ElementTree as ET import chess -from typing import Dict, Iterable, Optional, Tuple, Union -from chess import Color, IntoSquareSet, Square - +from typing import Dict, Optional, Self, Tuple, Union, List +from chess import Color, Square SQUARE_SIZE = 45 MARGIN = 20 -NAG_SIZE = 15 +NAG_SIZE = 20 +POSITION_OFFSET = SQUARE_SIZE - NAG_SIZE +CORNER_OFFSET = NAG_SIZE/2 PIECES = { - "b": """""", # noqa: E501 - "k": """""", # noqa: E501 - "n": """""", # noqa: E501 - "p": """""", # noqa: E501 - "q": """""", # noqa: E501 - "r": """""", # noqa: E501 - "B": """""", # noqa: E501 - "K": """""", # noqa: E501 - "N": """""", # noqa: E501 - "P": """""", # noqa: E501 - "Q": """""", # noqa: E501 - "R": """""", # noqa: E501 + "b": ET.fromstring(""" """), + "k": ET.fromstring(""" """), + "n": ET.fromstring(""" """), + "p": ET.fromstring(""" """), + "q": ET.fromstring(""" """), + "r": ET.fromstring(""" """), + "B": ET.fromstring(""" """), + "K": ET.fromstring(""" """), + "N": ET.fromstring(""" """), + "P": ET.fromstring(""" """), + "Q": ET.fromstring(""" """), + "R": ET.fromstring(""" """), } COORDS = { - "1": """""", # noqa: E501 - "2": """""", # noqa: E501 - "3": """""", # noqa: E501 - "4": """""", # noqa: E501 - "5": """""", # noqa: E501 - "6": """""", # noqa: E501 - "7": """""", # noqa: E501 - "8": """""", # noqa: E501 - "a": """""", # noqa: E501 - "b": """""", # noqa: E501 - "c": """""", # noqa: E501 - "d": """""", # noqa: E501 - "e": """""", # noqa: E501 - "f": """""", # noqa: E501 - "g": """""", # noqa: E501 - "h": """""", # noqa: E501 + "1": ET.fromstring(""""""), + "2": ET.fromstring(""""""), + "3": ET.fromstring(""""""), + "4": ET.fromstring(""""""), + "5": ET.fromstring(""""""), + "6": ET.fromstring(""""""), + "7": ET.fromstring(""""""), + "8": ET.fromstring(""""""), + "a": ET.fromstring(""""""), + "b": ET.fromstring(""""""), + "c": ET.fromstring(""""""), + "d": ET.fromstring(""""""), + "e": ET.fromstring(""""""), + "f": ET.fromstring(""""""), + "g": ET.fromstring(""""""), + "h": ET.fromstring(""""""), } NAGS = { - # "!" - "1": """ - - - - - - - - - """, - # "?" - "2": """ - - - - - - - - - - - """, - # "!!" - "3": """ - - - - - - - - - - - """, - # "??" - "4": """ - - - - - - - - - - - - - - - """, - # "?!" - "6": """ - - - - - - - - - - - """, - # "N" - "146": """ - - - - """ - + # "!" + "1": ET.fromstring(""" + + + + + + + + + """), + # "?" + "2": ET.fromstring(""" + + + + + + + + + + + """), + # "!!" + "3": ET.fromstring(""" + + + + + + + + + + + """), + # "??" + "4": ET.fromstring(""" + + + + + + + + + + + + + + + """), + # "?!" + "6": ET.fromstring(""" + + + + + + + + + + + """), + # "N" + "146": ET.fromstring(""" + + + + """) } -XX = """""" # noqa: E501 - -CHECK_GRADIENT = """""" # noqa: E501 +LOOSER_NAG = ET.fromstring(""" + + + + + +""") + +WINNER_NAG = ET.fromstring(""" + + + + + +""") + +CHECKMATE_NAG = ET.fromstring(""" + + + + # + +""") + +DRAW_NAG = ET.fromstring(""" + + + + + +""") + +HANGING_NAG = ET.fromstring(""" + + + + + + + + + + + +""") + +CHECK_GRADIENT = ET.fromstring("""""") DEFAULT_COLORS = { - "square light": "#ffce9e", - "square dark": "#d18b47", - "square dark lastmove": "#aaa23b", - "square light lastmove": "#cdd16a", - "margin": "#212121", - "coord": "#e5e5e5", - "arrow green": "#15781B80", - "arrow red": "#88202080", - "arrow yellow": "#e68f00b3", - "arrow blue": "#00308880", + "square light": "#ffce9e", + "square dark": "#d18b47", + "square dark lastmove": "#aaa23b", + "square light lastmove": "#cdd16a", + "margin": "#212121", + "coord": "#e5e5e5", + "arrow green": "#9fcf3fa3", + "arrow red": "#f8553fa3", + "arrow yellow": "#ffaa00a3", + "arrow blue": "#48c1f9a3", + "arrow light": "#4a4a4a33" } +DEFAULT_COLORS["red"] = DEFAULT_COLORS["arrow red"] +DEFAULT_COLORS["green"] = DEFAULT_COLORS["arrow green"] +DEFAULT_COLORS["yellow"] = DEFAULT_COLORS["arrow yellow"] +DEFAULT_COLORS["blue"] = DEFAULT_COLORS["arrow blue"] class Arrow: - """Details of an arrow to be drawn.""" - - tail: Square - """Start square of the arrow.""" - - head: Square - """End square of the arrow.""" - - color: str - """Arrow color.""" - - def __init__(self, tail: Square, head: Square, *, color: str = "green") -> None: - self.tail = tail - self.head = head - self.color = color - - def pgn(self) -> str: - """ - Returns the arrow in the format used by ``[%csl ...]`` and - ``[%cal ...]`` PGN annotations, e.g., ``Ga1`` or ``Ya2h2``. - - Colors other than ``red``, ``yellow``, and ``blue`` default to green. - """ - if self.color == "red": - color = "R" - elif self.color == "yellow": - color = "Y" - elif self.color == "blue": - color = "B" - else: - color = "G" - - if self.tail == self.head: - return f"{color}{chess.SQUARE_NAMES[self.tail]}" - else: - return f"{color}{chess.SQUARE_NAMES[self.tail]}{chess.SQUARE_NAMES[self.head]}" - - def __str__(self) -> str: - return self.pgn() - - def __repr__(self) -> str: - return f"Arrow({chess.SQUARE_NAMES[self.tail].upper()}, {chess.SQUARE_NAMES[self.head].upper()}, color={self.color!r})" - - @classmethod - def from_pgn(cls, pgn: str) -> Arrow: - """ - Parses an arrow from the format used by ``[%csl ...]`` and - ``[%cal ...]`` PGN annotations, e.g., ``Ga1`` or ``Ya2h2``. - - Also allows skipping the color prefix, defaulting to green. - - :raises: :exc:`ValueError` if the format is invalid. - """ - if pgn.startswith("G"): - color = "green" - pgn = pgn[1:] - elif pgn.startswith("R"): - color = "red" - pgn = pgn[1:] - elif pgn.startswith("Y"): - color = "yellow" - pgn = pgn[1:] - elif pgn.startswith("B"): - color = "blue" - pgn = pgn[1:] - else: - color = "green" - - tail = chess.parse_square(pgn[:2]) - head = chess.parse_square(pgn[2:]) if len(pgn) > 2 else tail - return cls(tail, head, color=color) - - -class SvgWrapper(str): - def _repr_svg_(self) -> SvgWrapper: - return self - - -def _svg(viewbox: int, size: Optional[int]) -> ET.Element: - svg = ET.Element("svg", { - "xmlns": "http://www.w3.org/2000/svg", - "xmlns:xlink": "http://www.w3.org/1999/xlink", - "viewBox": f"0 0 {viewbox:d} {viewbox:d}", - }) - - if size is not None: - svg.set("width", str(size)) - svg.set("height", str(size)) - - return svg + """Details of an arrow to be drawn.""" + tail: Square + """Start square of the arrow.""" -def _attrs(attrs: Dict[str, Union[str, int, float, None]]) -> Dict[str, str]: - return {k: str(v) for k, v in attrs.items() if v is not None} - + head: Square + """End square of the arrow.""" -def _select_color(colors: Dict[str, str], color: str) -> Tuple[str, float]: - return _color(colors.get(color, DEFAULT_COLORS[color])) + color: str + """Arrow color.""" + def __init__(self, tail: Square, head: Square, *, color: str = "green") -> None: + self.tail = tail + self.head = head + self.color = color -def _color(color: str) -> Tuple[str, float]: - if color.startswith("#"): - try: - if len(color) == 5: - return color[:4], int(color[4], 16) / 0xf - elif len(color) == 9: - return color[:7], int(color[7:], 16) / 0xff - except ValueError: - pass # Ignore invalid hex value - return color, 1.0 - + def pgn(self) -> str: + """ + Returns the arrow in the format used by ``[%csl ...]`` and + ``[%cal ...]`` PGN annotations, e.g., ``Ga1`` or ``Ya2h2``. -def _coord(text: str, x: float, y: float, scale: float, *, color: str, opacity: float) -> ET.Element: - t = ET.Element("g", _attrs({ - "transform": f"translate({x}, {y}) scale({scale}, {scale})", - "fill": color, - "stroke": color, - "opacity": opacity if opacity < 1.0 else None, - })) - t.append(ET.fromstring(COORDS[text])) - return t + Colors other than ``red``, ``yellow``, and ``blue`` default to green. + """ + if self.color == "red": + color = "R" + elif self.color == "yellow": + color = "Y" + elif self.color == "blue": + color = "B" + elif self.color == "light": + color = "L" + else: + color = "G" + + if self.tail == self.head: + return f"{color}{chess.SQUARE_NAMES[self.tail]}" + else: + return f"{color}{chess.SQUARE_NAMES[self.tail]}{chess.SQUARE_NAMES[self.head]}" + + def __str__(self) -> str: + return self.pgn() + + def __repr__(self) -> str: + return f"Arrow({chess.SQUARE_NAMES[self.tail].upper()}, {chess.SQUARE_NAMES[self.head].upper()}, color={self.color!r})" + + @classmethod + def from_pgn(cls, pgn: str) -> Self: + """ + Parses an arrow from the format used by ``[%csl ...]`` and + ``[%cal ...]`` PGN annotations, e.g., ``Ga1`` or ``Ya2h2``. + Also allows skipping the color prefix, defaulting to green. -def piece(piece: chess.Piece, size: Optional[int] = None) -> str: + :raises: :exc:`ValueError` if the format is invalid. """ - Renders the given :class:`chess.Piece` as an SVG image. + if pgn.startswith("G"): + color = "green" + pgn = pgn[1:] + elif pgn.startswith("R"): + color = "red" + pgn = pgn[1:] + elif pgn.startswith("Y"): + color = "yellow" + pgn = pgn[1:] + elif pgn.startswith("B"): + color = "blue" + pgn = pgn[1:] + elif pgn.startswith("L"): + color = "light" + pgn = pgn[1:] + else: + color = "green" + + tail = chess.parse_square(pgn[:2]) + head = chess.parse_square(pgn[2:]) if len(pgn) > 2 else tail + return cls(tail, head, color=color) + +def _svg(viewbox: int, size: Optional[int|float]) -> ET.Element: + svg = ET.Element("svg", { + "xmlns": "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink", + "viewBox": f"0 0 {viewbox:d} {viewbox:d}", + }) + + if size is not None: + svg.set("width", str(size)) + svg.set("height", str(size)) + + return svg - >>> import chess - >>> import chess.svg - >>> - >>> chess.svg.piece(chess.Piece.from_symbol("R")) # doctest: +SKIP +def _attrs(attrs: Dict[str, Union[str, int, float, None]]) -> Dict[str, str]: + return {k: str(v) for k, v in attrs.items() if v is not None} - .. image:: ../docs/wR.svg - :alt: R - """ - svg = _svg(SQUARE_SIZE, size) - svg.append(ET.fromstring(PIECES[piece.symbol()])) - return SvgWrapper(ET.tostring(svg).decode("utf-8")) +def _select_color(colors: Dict[str, str], color: str) -> Tuple[str, float]: + return _color(colors.get(color, DEFAULT_COLORS[color])) +def _color(color: str) -> Tuple[str, float]: + if color.startswith("#"): + try: + if len(color) == 5: + return color[:4], int(color[4], 16) / 0xf + elif len(color) == 9: + return color[:7], int(color[7:], 16) / 0xff + except ValueError: + pass # Ignore invalid hex value + return color, 1.0 + +def _coord(text: str, x: float, y: float, scale: float, *, color: str, opacity: float) -> ET.Element: + t = ET.Element("g", _attrs({ + "transform": f"translate({x}, {y}) scale({scale}, {scale})", + "fill": color, + "stroke": color, + "opacity": opacity if opacity < 1.0 else None, + })) + t.append(COORDS[text]) + return t -def board(board: Optional[chess.BaseBoard] = None, *, +def piece(piece: chess.Piece, size: Optional[int] = None) -> str: + """ + Renders the given :class:`chess.Piece` as an SVG image. + + >>> import chess + >>> import chess.svg + >>> + >>> chess.svg.piece(chess.Piece.from_symbol("R")) # doctest: +SKIP + + .. image:: ../docs/wR.svg + :alt: R + """ + svg = _svg(SQUARE_SIZE, size) + svg.append(PIECES[piece.symbol()]) + return ET.tostring(svg).decode("utf-8") + +def board_coords(sqr: chess.Square, orientation: bool): + file, rank = chess.square_file(sqr), chess.square_rank(sqr) + file = file if orientation else 7 - file + rank = 7 - rank if orientation else rank + return file, rank + +def svg_coords(sqr, orientation): + file, rank = board_coords(sqr, orientation) + x, y = file * SQUARE_SIZE, rank * SQUARE_SIZE + return x, y + +def animate_move(move, orientation, progress): + from_file = chess.square_file(move.from_square) + from_rank = chess.square_rank(move.from_square) + to_file = chess.square_file(move.to_square) + to_rank = chess.square_rank(move.to_square) + current_file = from_file + (to_file - from_file) * progress + current_rank = from_rank + (to_rank - from_rank) * progress + x = (current_file if orientation else 7 - current_file) * SQUARE_SIZE + y = (7 - current_rank if orientation else current_rank) * SQUARE_SIZE + return x, y + +def castling_moves(board: chess.Board, lastmove: chess.Move): + """Returns the corresponding rook move for a castling move in standard chess and Chess960.""" + king_start = lastmove.from_square + king_end = lastmove.to_square + rank = chess.square_rank(king_end) # 0 for White, 7 for Black + + king_file = chess.square_file(king_start) + + # Determine if it's kingside or queenside castling + is_kingside = king_end > king_start # King moves right → Kingside castling + + # Get the rook's starting square dynamically + rank = chess.square_rank(lastmove.to_square) + + # Find the rook's starting square by looking at the occupied squares + rooks = board.pieces(chess.ROOK, board.turn) # Get all rooks of the current player + rook_start = None + for rook_sq in rooks: + rook_file = chess.square_file(rook_sq) + if (is_kingside and rook_file > king_file) or \ + (not is_kingside and rook_file < king_file): + rook_start = rook_sq + break + + if rook_start is None: + raise ValueError("No valid rook found for castling") + +# Correcting rook and king destination squares + if rank == 0: # White castling + rook_end = chess.F1 if is_kingside else chess.D1 + king_end = chess.G1 if is_kingside else chess.C1 + else: # Black castling + rook_end = chess.F8 if is_kingside else chess.D8 + king_end = chess.G8 if is_kingside else chess.C8 + + return chess.Move(king_start, king_end), chess.Move(rook_start, rook_end) + +def hanging_pieces(board: chess.Board): + for sq in chess.SQUARES: + piece = board.piece_at(sq) + if piece is None: + continue + attackers = board.attackers(not piece.color, sq) # Opponent's attackers + defenders = board.attackers(piece.color, sq) # Own defenders + if len(attackers) > len(defenders): # More attackers than defenders + yield sq + +def board(board: chess.Board, *, orientation: Color = chess.WHITE, lastmove: Optional[chess.Move] = None, - check: Optional[Square] = None, - arrows: Iterable[Union[Arrow, Tuple[Square, Square]]] = [], - fill: Dict[Square, str] = {}, - squares: Optional[IntoSquareSet] = None, - size: Optional[int] = None, + cals: List[Arrow] = [], + csls: List[Arrow] = [], + size: Optional[int|float] = None, coordinates: bool = True, colors: Dict[str, str] = {}, - flipped: bool = False, style: Optional[str] = None, - nag:Optional[int] = None) -> str: - """ - Renders a board with pieces and/or selected squares as an SVG image. - - :param board: A :class:`chess.BaseBoard` for a chessboard with pieces, or - ``None`` (the default) for a chessboard without pieces. - :param orientation: The point of view, defaulting to ``chess.WHITE``. - :param lastmove: A :class:`chess.Move` to be highlighted. - :param check: A square to be marked indicating a check. - :param arrows: A list of :class:`~chess.svg.Arrow` objects, like - ``[chess.svg.Arrow(chess.E2, chess.E4)]``, or a list of tuples, like - ``[(chess.E2, chess.E4)]``. An arrow from a square pointing to the same - square is drawn as a circle, like ``[(chess.E2, chess.E2)]``. - :param fill: A dictionary mapping squares to a colors that they should be - filled with. - :param squares: A :class:`chess.SquareSet` with selected squares to mark - with an X. - :param size: The size of the image in pixels (e.g., ``400`` for a 400 by - 400 board), or ``None`` (the default) for no size limit. - :param coordinates: Pass ``False`` to disable the coordinate margin. - :param colors: A dictionary to override default colors. Possible keys are - ``square light``, ``square dark``, ``square light lastmove``, - ``square dark lastmove``, ``margin``, ``coord``, ``inner border``, - ``outer border``, ``arrow green``, ``arrow blue``, ``arrow red``, - and ``arrow yellow``. Values should look like ``#ffce9e`` (opaque), - or ``#15781B80`` (transparent). - :param flipped: Pass ``True`` to flip the board. - :param style: A CSS stylesheet to include in the SVG image. - :param nag: Pass ``NAG Constant`` to show Numerical Notation Glyphs (NAGs). - Supports !(great), !!(brilliant), ?(mistake), ?!(inaccuracy) and ??(blunder) - (requires ``lastmove`` to be passed along as argument) - - >>> import chess - >>> import chess.svg - >>> - >>> board = chess.Board("8/8/8/8/4N3/8/8/8 w - - 0 1") - >>> - >>> chess.svg.board( - ... board, - ... fill=dict.fromkeys(board.attacks(chess.E4), "#cc0000cc"), - ... arrows=[chess.svg.Arrow(chess.E4, chess.F6, color="#0000cccc")], - ... squares=chess.SquareSet(chess.BB_DARK_SQUARES & chess.BB_FILE_B), - ... size=350, - ... ) # doctest: +SKIP - - .. image:: ../docs/Ne4.svg - :alt: 8/8/8/8/4N3/8/8/8 - - .. deprecated:: 1.1 - Use *orientation* with a color instead of the *flipped* toggle. - """ - orientation ^= flipped + nag:Optional[int] = None , + result: None | str = None): + """ + Renders a board with pieces and/or selected squares as an SVG image. + + :param board: A :class:`chess.BaseBoard` for a chessboard with pieces, or + ``None`` (the default) for a chessboard without pieces. + :param orientation: The point of view, defaulting to ``chess.WHITE``. + :param lastmove: A :class:`chess.Move` to be highlighted. + :param check: A square to be marked indicating a check. + :param arrows: A list of :class:`~chess.svg.Arrow` objects, like + ``[chess.svg.Arrow(chess.E2, chess.E4)]``, or a list of tuples, like + ``[(chess.E2, chess.E4)]``. An arrow from a square pointing to the same + square is drawn as a circle, like ``[(chess.E2, chess.E2)]``. + :param squares: A :class:`chess.SquareSet` with selected squares to mark + with an X. + :param size: The size of the image in pixels (e.g., ``400`` for a 400 by + 400 board), or ``None`` (the default) for no size limit. + :param coordinates: Pass ``False`` to disable the coordinate margin. + :param colors: A dictionary to override default colors. Possible keys are + ``square light``, ``square dark``, ``square light lastmove``, + ``square dark lastmove``, ``margin``, ``coord``, ``inner border``, + ``outer border``, ``arrow green``, ``arrow blue``, ``arrow red``, + and ``arrow yellow``. Values should look like ``#ffce9e`` (opaque), + or ``#15781B80`` (transparent). + :param flipped: Pass ``True`` to flip the board. + :param style: A CSS stylesheet to include in the SVG image. + :param nag: Pass ``NAG Constant`` to show Numerical Notation Glyphs (NAGs). + Supports !(great), !!(brilliant), ?(mistake), ?!(inaccuracy) and ??(blunder) + (requires ``lastmove`` to be passed along as argument) + + >>> import chess + >>> import chess.svg + >>> + >>> board = chess.Board("8/8/8/8/4N3/8/8/8 w - - 0 1") + >>> + >>> chess.svg.board( + ... board, + ... fill=dict.fromkeys(board.attacks(chess.E4), "#cc0000cc"), + ... arrows=[chess.svg.Arrow(chess.E4, chess.F6, color="#0000cccc")], + ... squares=chess.SquareSet(chess.BB_DARK_SQUARES & chess.BB_FILE_B), + ... size=350, + ... ) # doctest: +SKIP + + .. image:: ../docs/Ne4.svg + :alt: 8/8/8/8/4N3/8/8/8 + + .. deprecated:: 1.1 + Use *orientation* with a color instead of the *flipped* toggle. + """ + full_size = 8 * SQUARE_SIZE + svg = _svg(full_size, size) + desc = ET.SubElement(svg, "desc") + defs = ET.SubElement(svg, "defs") + + if style: + ET.SubElement(svg, "style").text = style + + render_board(svg, colors, orientation) + + if lastmove is not None: + highlight_lastmove(svg, lastmove, colors, orientation) + + # Render check mark + if board.is_check(): + render_check(svg, board, orientation) + + asciiboard = ET.SubElement(desc, "pre") + asciiboard.text = str(board) + add_defs(defs) + + render_pieces(svg, board, orientation) + + if coordinates: + render_coords(svg, colors, orientation) + + for sqr in csls: + highlight_sqr(svg, sqr, colors, orientation) + + for arrow in cals: + render_arrow(svg, arrow, colors, orientation) + + if nag is not None and \ + lastmove is not None and \ + NAGS.get(str(nag), None) is not None: + render_nag(svg, defs, nag, lastmove, orientation) + + if result is not None: + render_result(svg, board, defs, result, orientation) + + return ET.tostring(svg).decode("utf-8") + +def render_result(svg: ET.Element, board: chess.Board, defs: ET.Element, result: str, orientation: bool): + white_king = board.king(chess.WHITE) + black_king = board.king(chess.BLACK) + assert white_king is not None, "No White King on the board" + assert black_king is not None, "No Black King on the board" + if result == "0-1" or result == "1-0": + defs.append(WINNER_NAG) + defs.append(LOOSER_NAG) + defs.append(CHECKMATE_NAG) + looser_nag = CHECKMATE_NAG if board.is_checkmate() else LOOSER_NAG + king_sqrs = [(white_king, looser_nag), (black_king, WINNER_NAG)] if result == "0-1" else \ + [(white_king, WINNER_NAG), (black_king, looser_nag)] + else: + defs.append(DRAW_NAG) + king_sqrs = [(white_king, DRAW_NAG), (black_king, DRAW_NAG)] + + for king_sqr, nag_glyph in king_sqrs: + file, rank = board_coords(king_sqr, orientation) + x, y = file * SQUARE_SIZE, rank * SQUARE_SIZE + x += POSITION_OFFSET + CORNER_OFFSET + y -= CORNER_OFFSET + if file == 7: + x -= CORNER_OFFSET + if rank == 0: + y += CORNER_OFFSET + id = nag_glyph.attrib.get("id") + ET.SubElement(svg, "use", _attrs({ + "href": f"#{id}", + "xlink:href": f"#{id}", + "x": x, + "y": y, + })) + +def render_coords(svg: ET.Element, colors: dict[str, str], orientation: bool): + full_size = 8 * SQUARE_SIZE + light_color, light_opacity = _select_color(colors, "square light") + dark_color, dark_opacity = _select_color(colors, "square dark") + text_scale = 0.5 + coord_size = 18 + width = coord_size * text_scale + height = coord_size * text_scale + x, to_file = 0, 0 + for to_file, file_name in enumerate(chess.FILE_NAMES): + x = ((to_file if orientation else 7 - to_file) * SQUARE_SIZE) - width # type: ignore + y = full_size - height # type: ignore + coord_color, coord_opacity = (light_color, light_opacity) if (to_file+orientation)%2 == 1 else (dark_color, dark_opacity) + svg.append(_coord(file_name, x+1.5, y-1, text_scale, color=coord_color, opacity=coord_opacity)) + x += (7 - to_file if orientation else to_file) * SQUARE_SIZE + x += SQUARE_SIZE + for to_rank, rank_name in enumerate(chess.RANK_NAMES): + y = ((7 - to_rank if orientation else to_rank) * SQUARE_SIZE) - height # type: ignore + coord_color, coord_opacity = (dark_color, dark_opacity) if (to_rank+orientation)%2 == 1 else (light_color, light_opacity) + svg.append(_coord(rank_name, x-1, y+3, text_scale, color=coord_color, opacity=coord_opacity)) + +def render_board(svg: ET.Element, colors: dict[str, str], orientation: bool): + for square, bb in enumerate(chess.BB_SQUARES): + x, y = svg_coords(square, orientation) + cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark"] + square_color, square_opacity = _select_color(colors, " ".join(cls)) + cls.append(chess.SQUARE_NAMES[square]) + ET.SubElement(svg, "rect", _attrs({ + "x": x, + "y": y, + "width": SQUARE_SIZE, + "height": SQUARE_SIZE, + "class": " ".join(cls), + "stroke": "none", + "fill": square_color, + "opacity": square_opacity if square_opacity < 1.0 else None, + })) + +def add_defs(defs: ET.Element): + # Pieces + for piece_color in chess.COLORS: + for piece_type in chess.PIECE_TYPES: + defs.append(PIECES[chess.Piece(piece_type, piece_color).symbol()]) + # Hanging Glyph + defs.append(HANGING_NAG) + # Check Gradient + defs.append(CHECK_GRADIENT) + +def highlight_lastmove(svg: ET.Element, lastmove: chess.Move, colors: dict[str, str], orientation: bool): + for square in (lastmove.from_square, lastmove.to_square): + bb = 1 << square + x, y = svg_coords(square, orientation) + cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark", "lastmove"] + square_color, square_opacity = _select_color(colors, " ".join(cls)) + cls.append(chess.SQUARE_NAMES[square]) + ET.SubElement(svg, "rect", _attrs({ + "x": x, + "y": y, + "width": SQUARE_SIZE, + "height": SQUARE_SIZE, + "class": " ".join(cls), + "stroke": "none", + "fill": square_color, + "opacity": "0.7", + })) + +def render_check(svg: ET.Element, board: chess.Board, orientation: bool): + king_sqr = board.king(board.turn) + assert king_sqr is not None, "king_sqr must exist" + x, y = svg_coords(king_sqr, orientation) + ET.SubElement(svg, "rect", _attrs({ + "x": x, + "y": y, + "width": SQUARE_SIZE, + "height": SQUARE_SIZE, + "class": "check", + "fill": "url(#check_gradient)", + })) + +def render_pieces(svg: ET.Element, board: chess.Board, orientation: bool): + for square, _ in enumerate(chess.BB_SQUARES): + piece = board.piece_at(square) + if not piece: + continue + x, y = svg_coords(square, orientation) + href = f"#{chess.COLOR_NAMES[piece.color]}-{chess.PIECE_NAMES[piece.piece_type]}" + ET.SubElement(svg, "use", { + "href": href, + "xlink:href": href, + "id": f"{x}_{y}", + "transform": f"translate({x:d}, {y:d})", + }) + +def render_nag(svg: ET.Element, defs: ET.Element, nag: int, lastmove: chess.Move, orientation: bool): + ele = NAGS[str(nag)] + defs.append(ele) + id = ele.attrib.get("id") + to_file, to_rank = board_coords(lastmove.to_square, orientation) + x, y = to_file * SQUARE_SIZE, to_rank * SQUARE_SIZE + # Making sure the NAGs doesn't overlap the Last Move Arrow by switching + # between appropriate corners depending upon where the Arrow is coming from. + x += POSITION_OFFSET # Top-right corner + x += CORNER_OFFSET + if to_file == 7: + x -= CORNER_OFFSET + y -= CORNER_OFFSET + if to_rank == 0: + y += CORNER_OFFSET + ET.SubElement(svg, "use", _attrs({ + "href": f"#{id}", + "xlink:href": f"#{id}", + "id": "nag_", + "x": x, + "y": y, + })) + +def render_arrow(svg: ET.Element, arrow: Arrow, colors: dict[str, str], orientation: bool): + try: + tail, head, color = arrow.tail, arrow.head, arrow.color # type: ignore + except AttributeError: + tail, head = arrow # type: ignore + color = "green" + + try: + color, opacity = _select_color(colors, " ".join(["arrow", color])) + except KeyError: + opacity = 1.0 + + tail_file = chess.square_file(tail) + tail_rank = chess.square_rank(tail) + head_file = chess.square_file(head) + head_rank = chess.square_rank(head) + + xtail = (tail_file + 0.5 if orientation else 7.5 - tail_file) * SQUARE_SIZE + ytail = (7.5 - tail_rank if orientation else tail_rank + 0.5) * SQUARE_SIZE + xhead = (head_file + 0.5 if orientation else 7.5 - head_file) * SQUARE_SIZE + yhead = (7.5 - head_rank if orientation else head_rank + 0.5) * SQUARE_SIZE + + marker_size = 0.40 * SQUARE_SIZE + marker_margin = 0.1 * SQUARE_SIZE + + dx, dy = xhead - xtail, yhead - ytail + hypot = math.hypot(dx, dy) + + shaft_x = xhead - dx * (marker_size + marker_margin) / hypot + shaft_y = yhead - dy * (marker_size + marker_margin) / hypot + + xtip = xhead - dx * marker_margin / hypot + ytip = yhead - dy * marker_margin / hypot + + ET.SubElement(svg, "line", _attrs({ + "x1": xtail, + "y1": ytail, + "x2": shaft_x, + "y2": shaft_y, + "stroke": color, + "opacity": opacity if opacity < 1.0 else None, + "stroke-width": SQUARE_SIZE * 0.15, + "stroke-linecap": "butt", + "class": "arrow", + })) + + marker = [ + (xtip, ytip), + (shaft_x + dy * 0.60 * marker_size / hypot, + shaft_y - dx * 0.60 * marker_size / hypot), + (shaft_x - dy * 0.60 * marker_size / hypot, + shaft_y + dx * 0.60 * marker_size / hypot)] + + ET.SubElement(svg, "polygon", _attrs({ + "points": " ".join(f"{x},{y}" for x, y in marker), + "fill": color, + "opacity": opacity if opacity < 1.0 else None, + "class": "arrow", + })) + +def generate_animation_frames(svg: ET.Element, board: chess.Board, lastmove: chess.Move, orientation: bool, nframes: int): + from_file, from_rank = board_coords(lastmove.from_square, orientation) + x, y = from_file * SQUARE_SIZE, from_rank * SQUARE_SIZE + from_piece = svg.find(f".//use[@id='{x}_{y}']") + assert from_piece is not None, "from_piece must exist" + + nag_glyph = svg.find(f".//use[@id='nag_']") + + to_file, to_rank = board_coords(lastmove.to_square, orientation) + x, y = to_file * SQUARE_SIZE, to_rank * SQUARE_SIZE + to_piece = svg.find(f".//use[@id='{x}_{y}']") + + rook_piece, rook_move = None, None + if board.is_castling(lastmove): + lastmove, rook_move = castling_moves(board, lastmove) + x, y = svg_coords(rook_move.from_square, orientation) + rook_piece = svg.find(f".//use[@id='{x}_{y}']") + + transition_start = 1 - (1/nframes) + + frames_list = [] + for i in range(0, nframes): + progress = i / nframes + transition_progress = (1 - math.cos(math.pi * progress)) / 2 + x, y = animate_move(lastmove, orientation, transition_progress) + from_piece.set("transform", f"translate({x:.2f}, {y:.2f})") + opacity = max(0, (progress - transition_start) / (1 - transition_start)) + if to_piece is not None: + to_piece.set("opacity", f"{(1-opacity):.2f}") + if nag_glyph is not None: + nag_glyph.set("opacity", f"{opacity:.2f}") + if rook_piece is not None: + assert rook_move is not None, "rook_move must exist" + x, y = animate_move(rook_move, orientation, transition_progress) + rook_piece.set("transform", f"translate({x:.2f}, {y:.2f})") + frames_list.append(ET.tostring(svg).decode("utf-8")) + + return frames_list + +def highlight_sqr(svg: ET.Element, arrow: Arrow, colors: dict[str, str], orientation: bool): + fill_color, fill_opacity = _select_color(colors, arrow.color) + x,y = svg_coords(arrow.tail, orientation) + ET.SubElement(svg, "rect", _attrs({ + "x": x, + "y": y, + "width": SQUARE_SIZE, + "height": SQUARE_SIZE, + "stroke": "none", + "fill": fill_color, + "opacity": fill_opacity if fill_opacity < 1.0 else None, + })) + +class SVGBoard: + svg: ET.Element + defs: ET.Element + orientation: bool + colors: Dict[str, str] + animation_frames_per_square: int + + def __init__(self, size: int|float, + show_coords: bool = True, + orientation: bool = chess.WHITE, + colors: Dict[str, str] = {}, + style: Optional[str] = None): + self.colors = colors + self.orientation = orientation full_size = 8 * SQUARE_SIZE - svg = _svg(full_size, size) - desc = ET.SubElement(svg, "desc") - defs = ET.SubElement(svg, "defs") + self.svg = _svg(full_size, size) + self.defs = ET.Element("defs") if style: - ET.SubElement(svg, "style").text = style - - # Render board. - for square, bb in enumerate(chess.BB_SQUARES): - to_file = chess.square_file(square) - to_rank = chess.square_rank(square) - - x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE - y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE - - cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark"] - square_color, square_opacity = _select_color(colors, " ".join(cls)) - - cls.append(chess.SQUARE_NAMES[square]) - - ET.SubElement(svg, "rect", _attrs({ - "x": x, - "y": y, - "width": SQUARE_SIZE, - "height": SQUARE_SIZE, - "class": " ".join(cls), - "stroke": "none", - "fill": square_color, - "opacity": square_opacity if square_opacity < 1.0 else None, - })) - - try: - fill_color, fill_opacity = _color(fill[square]) - except KeyError: - pass - else: - ET.SubElement(svg, "rect", _attrs({ - "x": x, - "y": y, - "width": SQUARE_SIZE, - "height": SQUARE_SIZE, - "stroke": "none", - "fill": fill_color, - "opacity": fill_opacity if fill_opacity < 1.0 else None, - })) + ET.SubElement(self.svg, "style").text = style + + render_board(self.svg, colors, self.orientation) + + add_defs(self.defs) + + # Render coordinates + if show_coords: + render_coords(self.svg, colors, self.orientation) + + + def render(self, board: chess.Board, cals: List[Arrow] = [], csls: List[Arrow] = [], nag: Optional[int] = None, result: str|None = None) -> str: + svg = copy(self.svg) + defs = copy(self.defs) + svg.append(defs) + + lastmove = board.move_stack[-1] if board.move_stack else None + if lastmove: + highlight_lastmove(svg, lastmove, self.colors, self.orientation) - # Rendering lastmove + for arrow in csls: + highlight_sqr(svg, arrow, self.colors, self.orientation) + + if board.is_check(): + render_check(svg, board, self.orientation) + + render_pieces(svg, board, self.orientation) + + for arrow in cals: + render_arrow(svg, arrow, self.colors, self.orientation) + + if nag is not None and \ + lastmove is not None and \ + NAGS.get(str(nag), None) is not None: + render_nag(svg, defs, nag, lastmove, self.orientation) + + if result is not None: + render_result(svg, board, defs, result, self.orientation) + + return ET.tostring(svg).decode("utf-8") + + + def animate(self, board: chess.Board, nframes: int, nag: Optional[int] = None) -> list[str]: + svg = copy(self.svg) + defs = copy(self.defs) + svg.append(defs) + + lastmove = board.pop() if lastmove: - for square in [lastmove.from_square, lastmove.to_square]: - bb = 1 << square - to_file = chess.square_file(square) - to_rank = chess.square_rank(square) - - x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE - y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE - - cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark", "lastmove"] - square_color, square_opacity = _select_color(colors, " ".join(cls)) - - cls.append(chess.SQUARE_NAMES[square]) - - ET.SubElement(svg, "rect", _attrs({ - "x": x, - "y": y, - "width": SQUARE_SIZE, - "height": SQUARE_SIZE, - "class": " ".join(cls), - "stroke": "none", - "fill": square_color, - "opacity": square_opacity if square_opacity < 1.0 else None, - })) - - # Render check mark. - if check is not None: - defs.append(ET.fromstring(CHECK_GRADIENT)) - to_file = chess.square_file(check) - to_rank = chess.square_rank(check) - - x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE - y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE - - ET.SubElement(svg, "rect", _attrs({ - "x": x, - "y": y, - "width": SQUARE_SIZE, - "height": SQUARE_SIZE, - "class": "check", - "fill": "url(#check_gradient)", - })) - - # Render pieces and selected squares. - if board is not None: - asciiboard = ET.SubElement(desc, "pre") - asciiboard.text = str(board) - # Defining pieces - for piece_color in chess.COLORS: - for piece_type in chess.PIECE_TYPES: - if board.pieces_mask(piece_type, piece_color): - defs.append(ET.fromstring(PIECES[chess.Piece(piece_type, piece_color).symbol()])) - # Rendering pieces - for square, bb in enumerate(chess.BB_SQUARES): - to_file = chess.square_file(square) - to_rank = chess.square_rank(square) - - x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE - y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE - - piece = board.piece_at(square) - if piece: - href = f"#{chess.COLOR_NAMES[piece.color]}-{chess.PIECE_NAMES[piece.piece_type]}" - ET.SubElement(svg, "use", { - "href": href, - "xlink:href": href, - "transform": f"translate({x:d}, {y:d})", - }) - - # Render coordinates. - if coordinates: - light_color, light_opacity = _select_color(colors, "square light") - dark_color, dark_opacity = _select_color(colors, "square dark") - text_scale = 0.5 - coord_size = 18 - width = coord_size * text_scale - height = coord_size * text_scale - for to_file, file_name in enumerate(chess.FILE_NAMES): - x = ((to_file if orientation else 7 - to_file) * SQUARE_SIZE) - width # type: ignore - y = full_size - height # type: ignore - coord_color, coord_opacity = (light_color, light_opacity) if (to_file+orientation)%2 == 1 else (dark_color, dark_opacity) - svg.append(_coord(file_name, x+1.5, y-1, text_scale, color=coord_color, opacity=coord_opacity)) - x += (7 - to_file if orientation else to_file) * SQUARE_SIZE - x += SQUARE_SIZE - for to_rank, rank_name in enumerate(chess.RANK_NAMES): - y = ((7 - to_rank if orientation else to_rank) * SQUARE_SIZE) - height # type: ignore - coord_color, coord_opacity = (dark_color, dark_opacity) if (to_rank+orientation)%2 == 1 else (light_color, light_opacity) - svg.append(_coord(rank_name, x-1, y+3, text_scale, color=coord_color, opacity=coord_opacity)) - - # Render X Squares - if squares is not None: - defs.append(ET.fromstring(XX)) - squares = chess.SquareSet(squares) if squares else chess.SquareSet() - for square in squares: - to_file = chess.square_file(square) - to_rank = chess.square_rank(square) - x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE - y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE - # Render selected squares - ET.SubElement(svg, "use", _attrs({ - "href": "#xx", - "xlink:href": "#xx", - "x": x, - "y": y, - })) - - # Render arrows. - for arrow in arrows: - try: - tail, head, color = arrow.tail, arrow.head, arrow.color # type: ignore - except AttributeError: - tail, head = arrow # type: ignore - color = "green" - - try: - color, opacity = _select_color(colors, " ".join(["arrow", color])) - except KeyError: - opacity = 1.0 - - tail_file = chess.square_file(tail) - tail_rank = chess.square_rank(tail) - head_file = chess.square_file(head) - head_rank = chess.square_rank(head) - - xtail = (tail_file + 0.5 if orientation else 7.5 - tail_file) * SQUARE_SIZE - ytail = (7.5 - tail_rank if orientation else tail_rank + 0.5) * SQUARE_SIZE - xhead = (head_file + 0.5 if orientation else 7.5 - head_file) * SQUARE_SIZE - yhead = (7.5 - head_rank if orientation else head_rank + 0.5) * SQUARE_SIZE - - if (head_file, head_rank) == (tail_file, tail_rank): - ET.SubElement(svg, "circle", _attrs({ - "cx": xhead, - "cy": yhead, - "r": SQUARE_SIZE * 0.9 / 2, - "stroke-width": SQUARE_SIZE * 0.1, - "stroke": color, - "opacity": opacity if opacity < 1.0 else None, - "fill": "none", - "class": "circle", - })) - else: - marker_size = 0.50 * SQUARE_SIZE - marker_margin = 0.1 * SQUARE_SIZE - - dx, dy = xhead - xtail, yhead - ytail - hypot = math.hypot(dx, dy) - - shaft_x = xhead - dx * (marker_size + marker_margin) / hypot - shaft_y = yhead - dy * (marker_size + marker_margin) / hypot - - xtip = xhead - dx * marker_margin / hypot - ytip = yhead - dy * marker_margin / hypot - - ET.SubElement(svg, "line", _attrs({ - "x1": xtail, - "y1": ytail, - "x2": shaft_x, - "y2": shaft_y, - "stroke": color, - "opacity": opacity if opacity < 1.0 else None, - "stroke-width": SQUARE_SIZE * 0.15, - "stroke-linecap": "butt", - "class": "arrow", - })) - - marker = [(xtip, ytip), - (shaft_x + dy * 0.5 * marker_size / hypot, - shaft_y - dx * 0.5 * marker_size / hypot), - (shaft_x - dy * 0.5 * marker_size / hypot, - shaft_y + dx * 0.5 * marker_size / hypot)] - - ET.SubElement(svg, "polygon", _attrs({ - "points": " ".join(f"{x},{y}" for x, y in marker), - "fill": color, - "opacity": opacity if opacity < 1.0 else None, - "class": "arrow", - })) + highlight_lastmove(svg, lastmove, self.colors, self.orientation) + + render_pieces(svg, board, self.orientation) if nag is not None and \ lastmove is not None and \ NAGS.get(str(nag), None) is not None: - ele = ET.fromstring(NAGS[str(nag)]) - defs.append(ele) - id = ele.attrib.get("id") - to_file = chess.square_file(lastmove.to_square) - to_rank = chess.square_rank(lastmove.to_square) - to_file = to_file if orientation else 7 - to_file - to_rank = 7 - to_rank if orientation else to_rank - x = to_file * SQUARE_SIZE - y = to_rank * SQUARE_SIZE - - from_file = chess.square_file(lastmove.from_square) - from_rank = chess.square_rank(lastmove.from_square) - from_file = from_file if orientation else 7 - from_file - from_rank = 7 - from_rank if orientation else from_rank - - delta_file = to_file - from_file - offset = SQUARE_SIZE - NAG_SIZE - corner_offset = NAG_SIZE/2 - - # Making sure the NAGs doesn't overlap the Last Move Arrow by switching - # between appropriate corners depending upon where the Arrow is coming from. - if delta_file >= 0: # Moving towards the right - x += offset # Top-right corner - x += corner_offset - if to_file == 7: - x -= corner_offset - else: # Moving towards the left OR Same File - x -= corner_offset - if to_file == 0: - x += corner_offset - y -= corner_offset - if to_rank == 0: - y += corner_offset - ET.SubElement(svg, "use", _attrs({ - "href": f"#{id}", - "xlink:href": f"#{id}", - "x": x, - "y": y, - })) - - return SvgWrapper(ET.tostring(svg).decode("utf-8")) + render_nag(svg, defs, nag, lastmove, self.orientation) + + # Generating animation frames + if not board.piece_at(lastmove.from_square): + return [self.render(board, nag=nag)] + + frames = generate_animation_frames(svg, board, lastmove, self.orientation, nframes) + board.push(lastmove) + return frames