Skip to main content

document_graphics/
chess.rs

1//! Bounded PGN collections and standard-chess FEN positions.
2//! Parsing, legal move replay and rendering perform no file or network access.
3use cozy_chess::Board;
4pub use cozy_chess::{Color, Piece, Square};
5use std::{
6    collections::BTreeMap,
7    ops::Range,
8    sync::{
9        Arc,
10        atomic::{AtomicBool, Ordering},
11    },
12};
13mod parser;
14mod position;
15mod render;
16pub use position::Position;
17pub use render::{RenderOptions, rasterize, to_svg};
18
19pub const MAX_SOURCE: usize = 8 * 1024 * 1024;
20pub const MAX_NODES: usize = 100_000;
21pub const MAX_VALUE: usize = 64 * 1024;
22pub const MAX_DEPTH: usize = 128;
23pub const MAX_PLIES: u32 = 16_384;
24pub type NodeId = usize;
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Format {
27    Pgn,
28    Fen,
29}
30#[derive(Debug, Clone, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum Error {
33    Invalid {
34        offset: usize,
35        message: &'static str,
36    },
37    Unsupported(&'static str),
38    Limit(&'static str),
39    Cancelled,
40    Render(String),
41}
42impl std::fmt::Display for Error {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Invalid { offset, message } => write!(f, "{message} at byte {}", offset + 1),
46            Self::Unsupported(s) | Self::Limit(s) => f.write_str(s),
47            Self::Cancelled => f.write_str("Chess preview cancelled"),
48            Self::Render(s) => f.write_str(s),
49        }
50    }
51}
52impl std::error::Error for Error {}
53#[derive(Debug)]
54pub struct Node {
55    pub parent: Option<NodeId>,
56    pub first_child: Option<NodeId>,
57    pub next_sibling: Option<NodeId>,
58    pub source: Range<usize>,
59    pub game: usize,
60    /// Half-moves since this game's starting position.
61    pub ply: u32,
62    san: Range<usize>,
63    comments: Vec<Range<usize>>,
64    annotations: Vec<Range<usize>>,
65}
66#[derive(Debug)]
67pub struct Game {
68    pub root: NodeId,
69    pub tags: BTreeMap<String, String>,
70    start: Board,
71}
72/// Source spans and tree links; no board or raster is retained per move.
73#[derive(Debug)]
74pub struct Collection {
75    source: Arc<[u8]>,
76    nodes: Vec<Node>,
77    games: Vec<Game>,
78}
79impl Collection {
80    pub fn parse(source: Arc<[u8]>, format: Format, cancel: &AtomicBool) -> Result<Self, Error> {
81        parser::parse(source, format, cancel)
82    }
83    pub fn nodes(&self) -> &[Node] {
84        &self.nodes
85    }
86    pub fn games(&self) -> &[Game] {
87        &self.games
88    }
89    pub fn source(&self) -> &[u8] {
90        &self.source
91    }
92    pub fn node_for_offset(&self, offset: usize) -> NodeId {
93        self.nodes
94            .partition_point(|n| n.source.start <= offset)
95            .saturating_sub(1)
96    }
97    pub fn san(&self, node: NodeId) -> Option<&str> {
98        self.nodes
99            .get(node)
100            .filter(|n| n.parent.is_some())
101            .and_then(|n| std::str::from_utf8(&self.source[n.san.clone()]).ok())
102    }
103    pub fn comment(&self, node: NodeId) -> String {
104        self.nodes.get(node).map_or_else(String::new, |n| {
105            n.comments
106                .iter()
107                .map(|r| decode(&self.source[r.clone()]))
108                .collect::<Vec<_>>()
109                .join("\n\n")
110        })
111    }
112    pub fn annotations(&self, node: NodeId) -> String {
113        self.nodes.get(node).map_or_else(String::new, |n| {
114            n.annotations
115                .iter()
116                .map(|r| decode(&self.source[r.clone()]))
117                .collect::<Vec<_>>()
118                .join(" ")
119        })
120    }
121    pub fn has_comment(&self, node: NodeId) -> bool {
122        self.nodes
123            .get(node)
124            .is_some_and(|n| !n.comments.is_empty() || !n.annotations.is_empty())
125    }
126    pub fn move_number(&self, node: NodeId) -> u32 {
127        let n = &self.nodes[node];
128        let start = &self.games[n.game].start;
129        u32::from(start.fullmove_number())
130            + (n.ply.saturating_sub(1) + u32::from(start.side_to_move() == Color::Black)) / 2
131    }
132    pub fn move_color(&self, node: NodeId) -> Option<Color> {
133        let n = self.nodes.get(node)?;
134        n.parent?;
135        let start = self.games[n.game].start.side_to_move();
136        Some(if n.ply % 2 == 1 { start } else { !start })
137    }
138    pub fn title(&self, game: usize) -> String {
139        let Some(g) = self.games.get(game) else {
140            return "Chess game".into();
141        };
142        match (g.tags.get("White"), g.tags.get("Black")) {
143            (Some(w), Some(b)) => format!("{w} ยท {b}"),
144            _ => g
145                .tags
146                .get("Event")
147                .cloned()
148                .unwrap_or_else(|| "Chess position".into()),
149        }
150    }
151    pub fn position(&self, node: NodeId, cancel: &AtomicBool) -> Result<Position, Error> {
152        position::at(self, node, cancel)
153    }
154}
155fn check(cancel: &AtomicBool) -> Result<(), Error> {
156    if cancel.load(Ordering::Relaxed) {
157        Err(Error::Cancelled)
158    } else {
159        Ok(())
160    }
161}
162fn invalid(offset: usize, message: &'static str) -> Error {
163    Error::Invalid { offset, message }
164}
165fn decode(bytes: &[u8]) -> String {
166    std::str::from_utf8(bytes)
167        .map(str::to_owned)
168        .unwrap_or_else(|_| bytes.iter().copied().map(char::from).collect())
169}
170#[cfg(test)]
171mod tests;