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..a3e486d7f 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""";|\{|\}""") @@ -112,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: @@ -498,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: @@ -524,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)}]" @@ -539,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 @@ -922,7 +969,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 +1073,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 +1227,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 +1324,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 +1783,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..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): @@ -4300,6 +4304,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('