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('