# This file is part of the python-chess library. # Copyright (C) 2016-2021 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # Piece vector graphics are copyright (C) Colin M.L. Burnett # and also licensed under the # GNU General Public License. from __future__ import annotations import math import xml.etree.ElementTree as ET import chess from typing import Dict, Iterable, Optional, Tuple, Union from chess import Color, IntoSquareSet, Square SQUARE_SIZE = 45 MARGIN = 20 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 } 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 } XX = """""" # noqa: E501 CHECK_GRADIENT = """""" # noqa: E501 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", } 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", "version": "1.2", "baseProfile": "tiny", "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 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} def _color(colors: Dict[str, str], color: str) -> Tuple[str, float]: color = colors.get(color, DEFAULT_COLORS[color]) 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: 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 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 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(ET.fromstring(PIECES[piece.symbol()])) return SvgWrapper(ET.tostring(svg).decode("utf-8")) def board(board: Optional[chess.BaseBoard] = None, *, orientation: Color = chess.WHITE, lastmove: Optional[chess.Move] = None, check: Optional[Square] = None, arrows: Iterable[Union[Arrow, Tuple[Square, Square]]] = [], squares: Optional[IntoSquareSet] = None, size: Optional[int] = None, coordinates: bool = True, colors: Dict[str, str] = {}, flipped: bool = False, style: Optional[str] = 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 squares: A :class:`chess.SquareSet` with selected squares. :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``, ``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. >>> import chess >>> import chess.svg >>> >>> board = chess.Board("8/8/8/8/4N3/8/8/8 w - - 0 1") >>> squares = board.attacks(chess.E4) >>> chess.svg.board(board, squares=squares, 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 margin = 15 if coordinates else 0 svg = _svg(8 * SQUARE_SIZE + 2 * margin, size) if style: ET.SubElement(svg, "style").text = style 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)) # Render coordinates. if coordinates: margin_color, margin_opacity = _color(colors, "margin") ET.SubElement(svg, "rect", _attrs({ "x": 0, "y": 0, "width": 2 * margin + 8 * SQUARE_SIZE, "height": 2 * margin + 8 * SQUARE_SIZE, "fill": margin_color, "opacity": margin_opacity if margin_opacity < 1.0 else None, })) coord_color, coord_opacity = _color(colors, "coord") for file_index, file_name in enumerate(chess.FILE_NAMES): x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin svg.append(_coord(file_name, x, 0, SQUARE_SIZE, margin, True, margin, color=coord_color, opacity=coord_opacity)) svg.append(_coord(file_name, x, margin + 8 * SQUARE_SIZE, 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 + margin svg.append(_coord(rank_name, 0, y, margin, SQUARE_SIZE, False, margin, color=coord_color, opacity=coord_opacity)) svg.append(_coord(rank_name, margin + 8 * SQUARE_SIZE, 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 + margin y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin 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") fill_color, fill_opacity = _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": fill_color, "opacity": fill_opacity if fill_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) x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + margin y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + margin 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. 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 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]}" ET.SubElement(svg, "use", { "href": href, "xlink:href": href, "transform": f"translate({x:d}, {y:d})", }) # Render selected squares. if squares is not None and square in 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 = _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 = margin + (tail_file + 0.5 if orientation else 7.5 - tail_file) * SQUARE_SIZE ytail = margin + (7.5 - tail_rank if orientation else tail_rank + 0.5) * SQUARE_SIZE xhead = margin + (head_file + 0.5 if orientation else 7.5 - head_file) * SQUARE_SIZE yhead = margin + (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.75 * 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.2, "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", })) return SvgWrapper(ET.tostring(svg).decode("utf-8"))