Skip to main content

document_graphics/
sgf.rs

1//! Bounded SGF collections and Go positions. No file access or application cache.
2//!
3//! `FF[4]` Go (`GM[1]`), including variations, setup stones, captures and annotations.
4//! SGF records describe positions even when moves violate competitive rules;
5//! replay deliberately does not enforce turn order, ko, or move legality.
6use std::{
7    ops::Range,
8    sync::{
9        Arc,
10        atomic::{AtomicBool, Ordering},
11    },
12};
13
14mod parser;
15mod position;
16mod render;
17pub use position::{Color, Line, Mark, MarkKind, Played, Point, Position};
18pub use render::{RenderOptions, rasterize, to_svg};
19
20pub const MAX_SOURCE: usize = 8 * 1024 * 1024;
21pub const MAX_NODES: usize = 100_000;
22pub const MAX_VALUE: usize = 128 * 1024;
23pub type NodeId = usize;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum Error {
28    Invalid {
29        offset: usize,
30        message: &'static str,
31    },
32    Unsupported(&'static str),
33    Limit(&'static str),
34    Cancelled,
35    Render(String),
36}
37impl std::fmt::Display for Error {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::Invalid { offset, message } => write!(f, "{message} at byte {}", offset + 1),
41            Self::Unsupported(s) | Self::Limit(s) => f.write_str(s),
42            Self::Cancelled => f.write_str("SGF preview cancelled"),
43            Self::Render(s) => f.write_str(s),
44        }
45    }
46}
47impl std::error::Error for Error {}
48
49#[derive(Debug)]
50pub struct Node {
51    pub parent: Option<NodeId>,
52    pub first_child: Option<NodeId>,
53    pub next_sibling: Option<NodeId>,
54    pub source: Range<usize>,
55    pub game: usize,
56    properties: Range<usize>,
57}
58#[derive(Debug)]
59pub struct Game {
60    pub root: NodeId,
61    pub width: u8,
62    pub height: u8,
63    encoding: Encoding,
64}
65#[derive(Debug, Clone, Copy)]
66enum Encoding {
67    Utf8,
68    Latin1,
69}
70#[derive(Debug)]
71struct Property {
72    name: Range<usize>,
73    values: Range<usize>,
74}
75
76/// Compact source ranges retain unknown properties without copying their values.
77/// Node IDs are local to this collection. No position or image is stored per node.
78#[derive(Debug)]
79pub struct Collection {
80    source: Arc<[u8]>,
81    nodes: Vec<Node>,
82    properties: Vec<Property>,
83    values: Vec<Range<usize>>,
84    games: Vec<Game>,
85}
86impl Collection {
87    pub fn parse(source: Arc<[u8]>, cancel: &AtomicBool) -> Result<Self, Error> {
88        parser::parse(source, cancel)
89    }
90    pub fn nodes(&self) -> &[Node] {
91        &self.nodes
92    }
93    pub fn games(&self) -> &[Game] {
94        &self.games
95    }
96    pub fn source(&self) -> &[u8] {
97        &self.source
98    }
99    pub fn node_for_offset(&self, offset: usize) -> NodeId {
100        self.nodes
101            .partition_point(|n| n.source.start <= offset)
102            .saturating_sub(1)
103    }
104    pub fn text(&self, node: NodeId, property: &str) -> Option<String> {
105        self.values(node, property).next()
106    }
107    pub fn children(&self, node: NodeId) -> impl Iterator<Item = NodeId> + '_ {
108        std::iter::successors(self.nodes.get(node).and_then(|n| n.first_child), |&id| {
109            self.nodes[id].next_sibling
110        })
111    }
112    pub fn title(&self, game: usize) -> String {
113        let Some(game) = self.games.get(game) else {
114            return "Go game".into();
115        };
116        if let Some(title) = self.text(game.root, "GN").filter(|s| !s.is_empty()) {
117            return title;
118        }
119        let player = |name| self.text(game.root, name).filter(|s| !s.is_empty());
120        match (player("PB"), player("PW")) {
121            (Some(b), Some(w)) => format!("{b} ยท {w}"),
122            _ => "Go game".into(),
123        }
124    }
125    pub fn position(&self, node: NodeId, cancel: &AtomicBool) -> Result<Position, Error> {
126        position::at(self, node, cancel)
127    }
128    fn ranges(&self, node: NodeId, name: &str) -> &[Range<usize>] {
129        self.nodes
130            .get(node)
131            .and_then(|n| {
132                self.properties[n.properties.clone()]
133                    .iter()
134                    .find(|p| &self.source[p.name.clone()] == name.as_bytes())
135            })
136            .map_or(&[], |p| &self.values[p.values.clone()])
137    }
138    fn values<'a>(&'a self, node: NodeId, name: &str) -> impl Iterator<Item = String> + 'a {
139        let text = matches!(name, "C" | "GC");
140        let encoding = self
141            .nodes
142            .get(node)
143            .map_or(Encoding::Utf8, |n| self.games[n.game].encoding);
144        self.ranges(node, name)
145            .iter()
146            .map(move |range| decode(&self.source[range.clone()], encoding, text))
147    }
148}
149
150fn check(cancel: &AtomicBool) -> Result<(), Error> {
151    if cancel.load(Ordering::Relaxed) {
152        Err(Error::Cancelled)
153    } else {
154        Ok(())
155    }
156}
157fn invalid(offset: usize, message: &'static str) -> Error {
158    Error::Invalid { offset, message }
159}
160
161fn decode(bytes: &[u8], encoding: Encoding, text: bool) -> String {
162    let mut out = Vec::with_capacity(bytes.len());
163    let mut i = 0;
164    while i < bytes.len() {
165        let escaped = bytes[i] == b'\\';
166        if escaped {
167            i += 1;
168            if i == bytes.len() {
169                break;
170            }
171        }
172        let c = bytes[i];
173        i += 1;
174        if matches!(c, b'\r' | b'\n') {
175            if i < bytes.len() && matches!(bytes[i], b'\r' | b'\n') && bytes[i] != c {
176                i += 1;
177            }
178            if !escaped {
179                out.push(if text { b'\n' } else { b' ' });
180            }
181        } else if c.is_ascii_whitespace() {
182            out.push(b' ');
183        } else {
184            out.push(c);
185        }
186    }
187    match encoding {
188        Encoding::Utf8 => String::from_utf8_lossy(&out).into_owned(),
189        Encoding::Latin1 => out.into_iter().map(char::from).collect(),
190    }
191}
192
193#[cfg(test)]
194mod tests;