rsshogi 1.0.2

Reusable Rust shogi primitives for board state, move generation, legality, and record parsing.
Documentation
use super::Position;
use crate::types::{Color, Hand, HandPiece, Move32, Piece, PieceType, Square};
use std::fmt::Write;

const SVG_PIECE_DEFS: [&str; 28] = [
    "<g id=\"black-pawn\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">歩</text></g>",
    "<g id=\"black-lance\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">香</text></g>",
    "<g id=\"black-knight\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">桂</text></g>",
    "<g id=\"black-silver\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">銀</text></g>",
    "<g id=\"black-gold\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">金</text></g>",
    "<g id=\"black-bishop\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">角</text></g>",
    "<g id=\"black-rook\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">飛</text></g>",
    "<g id=\"black-king\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">王</text></g>",
    "<g id=\"black-pro-pawn\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">と</text></g>",
    "<g id=\"black-pro-lance\" transform=\"scale(1.0, 0.5)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"18\">成</text><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"34\">香</text></g>",
    "<g id=\"black-pro-knight\" transform=\"scale(1.0, 0.5)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"18\">成</text><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"34\">桂</text></g>",
    "<g id=\"black-pro-silver\" transform=\"scale(1.0, 0.5)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"18\">成</text><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"34\">銀</text></g>",
    "<g id=\"black-horse\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">馬</text></g>",
    "<g id=\"black-dragon\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"10.5\" y=\"16.5\">龍</text></g>",
    "<g id=\"white-pawn\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">歩</text></g>",
    "<g id=\"white-lance\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">香</text></g>",
    "<g id=\"white-knight\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">桂</text></g>",
    "<g id=\"white-silver\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">銀</text></g>",
    "<g id=\"white-gold\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">金</text></g>",
    "<g id=\"white-bishop\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">角</text></g>",
    "<g id=\"white-rook\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">飛</text></g>",
    "<g id=\"white-king\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">王</text></g>",
    "<g id=\"white-pro-pawn\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">と</text></g>",
    "<g id=\"white-pro-lance\" transform=\"scale(1.0, 0.5) rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-22\">成</text><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-6\">香</text></g>",
    "<g id=\"white-pro-knight\" transform=\"scale(1.0, 0.5) rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-22\">成</text><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-6\">桂</text></g>",
    "<g id=\"white-pro-silver\" transform=\"scale(1.0, 0.5) rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-22\">成</text><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-6\">銀</text></g>",
    "<g id=\"white-horse\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">馬</text></g>",
    "<g id=\"white-dragon\" transform=\"rotate(180)\"><text font-family=\"serif\" font-size=\"17\" text-anchor=\"middle\" x=\"-10.5\" y=\"-3.5\">龍</text></g>",
];

const SVG_SQUARES: &str = "<g stroke=\"black\"><rect x=\"20\" y=\"10\" width=\"181\" height=\"181\" fill=\"none\" stroke-width=\"1.5\" /><line x1=\"20.5\" y1=\"30.5\" x2=\"200.5\" y2=\"30.5\" stroke-width=\"1.0\" /><line x1=\"20.5\" y1=\"50.5\" x2=\"200.5\" y2=\"50.5\" stroke-width=\"1.0\" /><line x1=\"20.5\" y1=\"70.5\" x2=\"200.5\" y2=\"70.5\" stroke-width=\"1.0\" /><line x1=\"20.5\" y1=\"90.5\" x2=\"200.5\" y2=\"90.5\" stroke-width=\"1.0\" /><line x1=\"20.5\" y1=\"110.5\" x2=\"200.5\" y2=\"110.5\" stroke-width=\"1.0\" /><line x1=\"20.5\" y1=\"130.5\" x2=\"200.5\" y2=\"130.5\" stroke-width=\"1.0\" /><line x1=\"20.5\" y1=\"150.5\" x2=\"200.5\" y2=\"150.5\" stroke-width=\"1.0\" /><line x1=\"20.5\" y1=\"170.5\" x2=\"200.5\" y2=\"170.5\" stroke-width=\"1.0\" /><line x1=\"40.5\" y1=\"10.5\" x2=\"40.5\" y2=\"190.5\" stroke-width=\"1.0\" /><line x1=\"60.5\" y1=\"10.5\" x2=\"60.5\" y2=\"190.5\" stroke-width=\"1.0\" /><line x1=\"80.5\" y1=\"10.5\" x2=\"80.5\" y2=\"190.5\" stroke-width=\"1.0\" /><line x1=\"100.5\" y1=\"10.5\" x2=\"100.5\" y2=\"190.5\" stroke-width=\"1.0\" /><line x1=\"120.5\" y1=\"10.5\" x2=\"120.5\" y2=\"190.5\" stroke-width=\"1.0\" /><line x1=\"140.5\" y1=\"10.5\" x2=\"140.5\" y2=\"190.5\" stroke-width=\"1.0\" /><line x1=\"160.5\" y1=\"10.5\" x2=\"160.5\" y2=\"190.5\" stroke-width=\"1.0\" /><line x1=\"180.5\" y1=\"10.5\" x2=\"180.5\" y2=\"190.5\" stroke-width=\"1.0\" /></g>";

const SVG_COORDINATES: &str = "<g><text font-family=\"serif\" text-anchor=\"middle\" font-size=\"9\" x=\"30.5\" y=\"8\">9</text><text font-family=\"serif\" text-anchor=\"middle\" font-size=\"9\" x=\"50.5\" y=\"8\">8</text><text font-family=\"serif\" text-anchor=\"middle\" font-size=\"9\" x=\"70.5\" y=\"8\">7</text><text font-family=\"serif\" text-anchor=\"middle\" font-size=\"9\" x=\"90.5\" y=\"8\">6</text><text font-family=\"serif\" text-anchor=\"middle\" font-size=\"9\" x=\"110.5\" y=\"8\">5</text><text font-family=\"serif\" text-anchor=\"middle\" font-size=\"9\" x=\"130.5\" y=\"8\">4</text><text font-family=\"serif\" text-anchor=\"middle\" font-size=\"9\" x=\"150.5\" y=\"8\">3</text><text font-family=\"serif\" text-anchor=\"middle\" font-size=\"9\" x=\"170.5\" y=\"8\">2</text><text font-family=\"serif\" text-anchor=\"middle\" font-size=\"9\" x=\"190.5\" y=\"8\">1</text><text font-family=\"serif\" font-size=\"9\" x=\"203.5\" y=\"23\">一</text><text font-family=\"serif\" font-size=\"9\" x=\"203.5\" y=\"43\">二</text><text font-family=\"serif\" font-size=\"9\" x=\"203.5\" y=\"63\">三</text><text font-family=\"serif\" font-size=\"9\" x=\"203.5\" y=\"83\">四</text><text font-family=\"serif\" font-size=\"9\" x=\"203.5\" y=\"103\">五</text><text font-family=\"serif\" font-size=\"9\" x=\"203.5\" y=\"123\">六</text><text font-family=\"serif\" font-size=\"9\" x=\"203.5\" y=\"143\">七</text><text font-family=\"serif\" font-size=\"9\" x=\"203.5\" y=\"163\">八</text><text font-family=\"serif\" font-size=\"9\" x=\"203.5\" y=\"183\">九</text></g>";

const NUMBER_JAPANESE_KANJI_SYMBOLS: [&str; 19] = [
    "", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二", "十三", "十四",
    "十五", "十六", "十七", "十八",
];

const HAND_PIECE_ORDER: [PieceType; 7] = [
    PieceType::PAWN,
    PieceType::LANCE,
    PieceType::KNIGHT,
    PieceType::SILVER,
    PieceType::GOLD,
    PieceType::BISHOP,
    PieceType::ROOK,
];

const HAND_PIECE_SYMBOLS: [&str; 7] = ["歩", "香", "桂", "銀", "金", "角", "飛"];

impl Position {
    /// 局面を SVG 文字列として描画する。
    ///
    /// `last_move` を指定すると、移動元と移動先をハイライトする。
    /// `scale` が 0 以下の場合は `1.0` として扱う。
    ///
    /// # Examples
    ///
    /// ```
    /// use rsshogi::board;
    /// use rsshogi::types::Move;
    ///
    /// board::init();
    ///
    /// let mut pos = board::hirate_position();
    /// let mv = pos.move32_from_move(Move::from_usi("7g7f").unwrap());
    /// pos.apply_move32(mv);
    ///
    /// let svg = pos.to_svg(Some(mv), 1.0);
    /// assert!(svg.starts_with("<svg"));
    /// ```
    #[must_use]
    pub fn to_svg(&self, last_move: Option<Move32>, scale: f32) -> String {
        let scale = if scale <= 0.0 { 1.0 } else { scale };
        let width = 230.0;
        let height = 192.0;

        let mut out = String::new();
        let _ = write!(
            out,
            "<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">",
            width * scale,
            height * scale,
            width,
            height
        );
        out.push_str("<defs>");
        for def in SVG_PIECE_DEFS {
            out.push_str(def);
        }
        out.push_str("</defs>");

        if let Some(mv) = last_move.filter(|mv| mv.is_normal()) {
            let to = mv.to_sq();
            if let Some((x, y)) = svg_square_coords(to) {
                let _ = write!(
                    out,
                    "<rect x=\"{}\" y=\"{}\" width=\"20\" height=\"20\" fill=\"#f6b94d\" />",
                    x, y
                );
            }
            if !mv.is_drop() {
                let from = mv.from_sq();
                if let Some((x, y)) = svg_square_coords(from) {
                    let _ = write!(
                        out,
                        "<rect x=\"{}\" y=\"{}\" width=\"20\" height=\"20\" fill=\"#fdf0e3\" />",
                        x, y
                    );
                }
            }
        }

        out.push_str(SVG_SQUARES);
        out.push_str(SVG_COORDINATES);

        for idx in 0..81 {
            let sq = Square::from_index(idx);
            let piece = self.piece_on(sq);
            if piece == Piece::NONE {
                continue;
            }
            let Some(id) = svg_piece_id(piece) else {
                continue;
            };
            let Some((x, y)) = svg_square_coords(sq) else {
                continue;
            };
            let _ = write!(out, "<use xlink:href=\"#{id}\" x=\"{x}\" y=\"{y}\" />");
        }

        for color in [Color::BLACK, Color::WHITE] {
            let texts = svg_hand_texts(self.hand(color), color);
            let mut hand_scale = 1.0;
            if texts.len() + 1 > 13 {
                hand_scale = 13.0 / (texts.len() as f32 + 1.0);
            }
            let (x, y) = if color == Color::BLACK { (214.0, 190.0) } else { (-16.0, -10.0) };
            for (idx, text) in texts.into_iter().enumerate() {
                let font_size = 14.0 * hand_scale;
                let y = y - font_size * idx as f32;
                let _ = write!(
                    out,
                    "<text font-family=\"serif\" font-size=\"{}\" x=\"{}\" y=\"{}\"{}>{}</text>",
                    font_size,
                    x,
                    y,
                    if color == Color::WHITE { " transform=\"rotate(180)\"" } else { "" },
                    text
                );
            }
        }

        out.push_str("</svg>");
        out
    }
}

fn svg_square_coords(sq: Square) -> Option<(f32, f32)> {
    if !sq.is_valid() || sq.is_none() {
        return None;
    }
    let file = sq.file().raw() as f32;
    let rank = sq.rank().raw() as f32;
    let x = 20.5 + (8.0 - file) * 20.0;
    let y = 10.5 + rank * 20.0;
    Some((x, y))
}

fn svg_piece_id(piece: Piece) -> Option<&'static str> {
    if piece == Piece::NONE {
        return None;
    }
    let prefix = match piece.color() {
        Color::BLACK => "black",
        Color::WHITE => "white",
    };
    let suffix = match piece.piece_type() {
        PieceType::PAWN => "pawn",
        PieceType::LANCE => "lance",
        PieceType::KNIGHT => "knight",
        PieceType::SILVER => "silver",
        PieceType::GOLD => "gold",
        PieceType::BISHOP => "bishop",
        PieceType::ROOK => "rook",
        PieceType::KING => "king",
        PieceType::PRO_PAWN => "pro-pawn",
        PieceType::PRO_LANCE => "pro-lance",
        PieceType::PRO_KNIGHT => "pro-knight",
        PieceType::PRO_SILVER => "pro-silver",
        PieceType::HORSE => "horse",
        PieceType::DRAGON => "dragon",
        _ => return None,
    };
    Some(match prefix {
        "black" => match suffix {
            "pawn" => "black-pawn",
            "lance" => "black-lance",
            "knight" => "black-knight",
            "silver" => "black-silver",
            "gold" => "black-gold",
            "bishop" => "black-bishop",
            "rook" => "black-rook",
            "king" => "black-king",
            "pro-pawn" => "black-pro-pawn",
            "pro-lance" => "black-pro-lance",
            "pro-knight" => "black-pro-knight",
            "pro-silver" => "black-pro-silver",
            "horse" => "black-horse",
            "dragon" => "black-dragon",
            _ => return None,
        },
        "white" => match suffix {
            "pawn" => "white-pawn",
            "lance" => "white-lance",
            "knight" => "white-knight",
            "silver" => "white-silver",
            "gold" => "white-gold",
            "bishop" => "white-bishop",
            "rook" => "white-rook",
            "king" => "white-king",
            "pro-pawn" => "white-pro-pawn",
            "pro-lance" => "white-pro-lance",
            "pro-knight" => "white-pro-knight",
            "pro-silver" => "white-pro-silver",
            "horse" => "white-horse",
            "dragon" => "white-dragon",
            _ => return None,
        },
        _ => return None,
    })
}

fn svg_hand_texts(hand: Hand, color: Color) -> Vec<String> {
    let mut items = Vec::new();
    for (piece_type, symbol) in HAND_PIECE_ORDER.iter().zip(HAND_PIECE_SYMBOLS.iter()) {
        let count = HandPiece::from_piece_type(*piece_type).map(|hp| hand.count(hp)).unwrap_or(0);
        if count >= 11 {
            let ones = (count % 10) as usize;
            if ones > 0 {
                items.push(NUMBER_JAPANESE_KANJI_SYMBOLS[ones].to_string());
            }
            items.push(NUMBER_JAPANESE_KANJI_SYMBOLS[10].to_string());
        } else if count >= 2 {
            items.push(NUMBER_JAPANESE_KANJI_SYMBOLS[count as usize].to_string());
        }
        if count >= 1 {
            items.push((*symbol).to_string());
        }
    }

    items.push(if color == Color::BLACK { "☗" } else { "☖" }.to_string());
    items
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::board;

    #[test]
    fn svg_output_contains_pieces() {
        board::init();
        let mut pos = Position::empty();
        pos.set_hirate();
        let svg = pos.to_svg(None, 1.0);
        assert!(svg.contains("<svg"));
        assert!(svg.contains("xlink:href=\"#black-king\""));
        assert!(svg.contains("xlink:href=\"#white-king\""));
        assert!(svg.contains(">☗<"));
        assert!(svg.contains(">☖<"));
        assert!(!svg.contains(">先<"));
        assert!(!svg.contains(">後<"));
        assert!(!svg.contains(">手<"));
    }
}