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/__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 55eddbc29..c1969301e 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""";|\{|\}""") @@ -922,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: @@ -1026,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) @@ -1180,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 @@ -1277,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 @@ -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 d3d19e89e..af609dcfc 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,83 @@ "h": """""", # noqa: E501 } +NAGS = { + # "!" + "1": """ + + + + + + + + + """, + # "?" + "2": """ + + + + + + + + + + + """, + # "!!" + "3": """ + + + + + + + + + + + """, + # "??" + "4": """ + + + + + + + + + + + + + + + """, + # "?!" + "6": """ + + + + + + + + + + + """, + # "N" + "146": """ + + + + """ + +} + XX = """""" # noqa: E501 CHECK_GRADIENT = """""" # noqa: E501 @@ -56,8 +134,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", @@ -179,14 +255,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, @@ -225,8 +294,8 @@ def board(board: Optional[chess.BaseBoard] = None, *, coordinates: bool = True, 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. @@ -253,9 +322,10 @@ 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) + (requires ``lastmove`` to be passed along as argument) >>> import chess >>> import chess.svg @@ -277,97 +347,23 @@ def board(board: Optional[chess.BaseBoard] = None, *, Use *orientation* with a color instead of the *flipped* toggle. """ 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") if style: ET.SubElement(svg, "style").text = style - if board: - 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): - 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 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({ - "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) + to_file = chess.square_file(square) + to_rank = 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 = (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"] - 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]) @@ -397,14 +393,41 @@ 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 + 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: - file_index = chess.square_file(check) - rank_index = chess.square_rank(check) + defs.append(ET.fromstring(CHECK_GRADIENT)) + to_file = chess.square_file(check) + to_rank = 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 = (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, @@ -416,14 +439,22 @@ 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: + 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 = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin - y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin + x = (to_file if orientation else 7 - to_file) * SQUARE_SIZE + y = (7 - to_rank if orientation else to_rank) * SQUARE_SIZE - 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]}" @@ -433,8 +464,36 @@ def board(board: Optional[chess.BaseBoard] = None, *, "transform": f"translate({x:d}, {y:d})", }) - # Render selected squares. - if square in squares: + # 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", @@ -460,10 +519,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({ @@ -477,7 +536,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 @@ -496,7 +555,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", })) @@ -514,4 +573,47 @@ def board(board: Optional[chess.BaseBoard] = None, *, "class": "arrow", })) + 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")) diff --git a/test.py b/test.py index 8dbc95a25..940902c67 100755 --- a/test.py +++ b/test.py @@ -4300,6 +4300,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('