// SgfReader.java package net.sf.gogui.sgf; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.IOException; import java.io.StreamTokenizer; import java.io.UnsupportedEncodingException; import java.nio.charset.Charset; import java.util.Map; import java.util.TreeMap; import java.util.TreeSet; import java.util.ArrayList; import java.util.Locale; import java.util.Set; import net.sf.gogui.game.GameInfo; import net.sf.gogui.game.GameTree; import net.sf.gogui.game.MarkType; import net.sf.gogui.game.Node; import net.sf.gogui.game.StringInfo; import net.sf.gogui.game.StringInfoColor; import net.sf.gogui.game.TimeSettings; import net.sf.gogui.go.GoColor; import static net.sf.gogui.go.GoColor.BLACK; import static net.sf.gogui.go.GoColor.WHITE; import static net.sf.gogui.go.GoColor.EMPTY; import net.sf.gogui.go.GoPoint; import net.sf.gogui.go.InvalidKomiException; import net.sf.gogui.go.InvalidPointException; import net.sf.gogui.go.Komi; import net.sf.gogui.go.Move; import net.sf.gogui.go.PointList; import net.sf.gogui.util.ByteCountInputStream; import net.sf.gogui.util.ProgressShow; /** SGF reader. @bug The error messages sometimes contain wrong line numbers, because of problems in StreamTokenizer.lineno(). Maybe the implementation should be replaced not using StreamTokenizer, because this class is a legacy class. (Does this happen only on Windows?) */ public final class SgfReader { /** Read SGF file from stream. Default charset is ISO-8859-1 according to the SGF version 4 standard. The charset property is only respected if the stream is a FileInputStream, because it has to be reopened with a different encoding. The stream is closed after reading. @param in Stream to read from. @param file File name if input stream is a FileInputStream to allow reopening the stream after a charset change @param progressShow Callback to show progress, can be null @param size Size of stream if progressShow != null @throws SgfError If reading fails. */ public SgfReader(InputStream in, File file, ProgressShow progressShow, long size) throws SgfError { m_file = file; m_progressShow = progressShow; m_size = size; m_isFile = (in instanceof FileInputStream && file != null); if (progressShow != null) progressShow.showProgress(0); try { // SGF FF 4 standard defines ISO-8859-1 as default readSgf(in, "ISO-8859-1"); } catch (SgfCharsetChanged e1) { try { in.close(); in = new FileInputStream(file); } catch (IOException e2) { throw new SgfError("Could not reset SGF stream after" + " charset change."); } try { readSgf(in, m_newCharset); } catch (SgfCharsetChanged e3) { assert false; } } finally { try { in.close(); } catch (IOException e) { System.err.println("Could not close SGF stream"); } } } /** Get game tree of loaded SGF file. @return The game tree. */ public GameTree getTree() { return m_tree; } /** Get warnings that occurred during loading SGF file. @return String with warning messages or null if no warnings. */ public String getWarnings() { if (m_warnings.isEmpty()) return null; StringBuilder result = new StringBuilder(m_warnings.size() * 80); for (String s : m_warnings) { result.append(s); result.append('\n'); } return result.toString(); } private static class SgfCharsetChanged extends Exception { } private final boolean m_isFile; /** Has current node inconsistent FF3 overtime settings properties. */ private boolean m_ignoreOvertime; private int m_lastPercent; private int m_boardSize; private int m_byoyomiMoves; private final long m_size; private long m_byoyomi; private long m_preByoyomi; private ByteCountInputStream m_byteCountInputStream; private java.io.Reader m_reader; private GameTree m_tree; private final ProgressShow m_progressShow; /** Contains strings with warnings. */ private final Set<String> m_warnings = new TreeSet<String>(); private StreamTokenizer m_tokenizer; private final File m_file; private String m_newCharset; /** Pre-allocated temporary buffer for use within functions. */ private final StringBuilder m_buffer = new StringBuilder(512); private final PointList m_pointList = new PointList(); /** Map containing the properties of the current node. */ private final Map<String,ArrayList<String>> m_props = new TreeMap<String,ArrayList<String>>(); /** Apply some fixes for broken SGF files. */ private void applyFixes() { Node root = m_tree.getRoot(); GameInfo info = m_tree.getGameInfo(root); if (root.hasSetup() && root.getPlayer() == null) { if (info.getHandicap() > 0) { root.setPlayer(WHITE); } else { boolean hasBlackChildMoves = false; boolean hasWhiteChildMoves = false; for (int i = 0; i < root.getNumberChildren(); ++i) { Move move = root.getChild(i).getMove(); if (move == null) continue; if (move.getColor() == BLACK) hasBlackChildMoves = true; if (move.getColor() == WHITE) hasWhiteChildMoves = true; } if (hasBlackChildMoves && ! hasWhiteChildMoves) root.setPlayer(BLACK); if (hasWhiteChildMoves && ! hasBlackChildMoves) root.setPlayer(WHITE); } } } private void checkEndOfFile() throws SgfError, IOException { while (true) { m_tokenizer.nextToken(); int t = m_tokenizer.ttype; if (t == '(') throw getError("Multiple SGF trees not supported"); else if (t == StreamTokenizer.TT_EOF) return; else if (t != ' ' && t != '\t' && t != '\n' && t != '\r') { setWarning("Extra text after SGF tree"); return; } } } /** Check for obsolete long names for standard properties. These are still used in asome old SGF files. @param property Property name @return Short standard version of the property or original property */ private String checkForObsoleteLongProps(String property) { if (property.length() <= 2) return property; property = property.intern(); String shortName = null; if (property == "ADDBLACK") shortName = "AB"; else if (property == "ADDEMPTY") shortName = "AE"; else if (property == "ADDWHITE") shortName = "AW"; else if (property == "BLACK") shortName = "B"; else if (property == "BLACKRANK") shortName = "BR"; else if (property == "COMMENT") shortName = "C"; else if (property == "COPYRIGHT") shortName = "CP"; else if (property == "DATE") shortName = "DT"; else if (property == "EVENT") shortName = "EV"; else if (property == "GAME") shortName = "GM"; else if (property == "HANDICAP") shortName = "HA"; else if (property == "KOMI") shortName = "KM"; else if (property == "PLACE") shortName = "PC"; else if (property == "PLAYERBLACK") shortName = "PB"; else if (property == "PLAYERWHITE") shortName = "PW"; else if (property == "PLAYER") shortName = "PL"; else if (property == "RESULT") shortName = "RE"; else if (property == "ROUND") shortName = "RO"; else if (property == "RULES") shortName = "RU"; else if (property == "SIZE") shortName = "SZ"; else if (property == "WHITE") shortName = "W"; else if (property == "WHITERANK") shortName = "WR"; if (shortName != null) return shortName; return property; } private GameInfo createGameInfo(Node node) { return node.createGameInfo(); } private void findRoot() throws SgfError, IOException { while (true) { m_tokenizer.nextToken(); int t = m_tokenizer.ttype; if (t == '(') { // Better make sure that ( is followed by a node m_tokenizer.nextToken(); t = m_tokenizer.ttype; if (t == ';') { m_tokenizer.pushBack(); return; } else setWarning("Extra text before SGF tree"); } else if (t == StreamTokenizer.TT_EOF) throw getError("No root tree found"); else setWarning("Extra text before SGF tree"); } } private int getBoardSize() { if (m_boardSize == -1) m_boardSize = 19; // Default size for Go in the SGF standard return m_boardSize; } private SgfError getError(String message) { int lineNumber = m_tokenizer.lineno(); if (m_file == null) return new SgfError(lineNumber + ": " + message); else { String s = m_file.getName() + ":" + lineNumber + ": " + message; return new SgfError(s); } } private void handleProps(Node node, boolean isRoot) throws IOException, SgfError, SgfCharsetChanged { // Handle SZ property first to be able to parse points if (m_props.containsKey("SZ")) { ArrayList<String> values = m_props.get("SZ"); m_props.remove("SZ"); if (! isRoot) setWarning("Size property not in root node ignored"); else { try { int size = parseInt(values.get(0)); if (size <= 0 || size > GoPoint.MAX_SIZE) setWarning("Invalid board size value"); assert m_boardSize == -1; m_boardSize = size; } catch (NumberFormatException e) { setWarning("Invalid board size value"); } } } for (Map.Entry<String,ArrayList<String>> entry : m_props.entrySet()) { String p = entry.getKey(); ArrayList<String> values = entry.getValue(); String v = values.get(0); if (p == "AB") { parsePointList(values); node.addStones(BLACK, m_pointList); } else if (p == "AE") { parsePointList(values); node.addStones(EMPTY, m_pointList); } else if (p == "AN") set(node, StringInfo.ANNOTATION, v); else if (p == "AW") { parsePointList(values); node.addStones(WHITE, m_pointList); } else if (p == "B") { node.setMove(Move.get(BLACK, parsePoint(v))); } else if (p == "BL") { try { node.setTimeLeft(BLACK, Double.parseDouble(v)); } catch (NumberFormatException e) { } } else if (p == "BR") set(node, StringInfoColor.RANK, BLACK, v); else if (p == "BT") set(node, StringInfoColor.TEAM, BLACK, v); else if (p == "C") node.setComment(v); else if (p == "CA") { if (isRoot && m_isFile && m_newCharset == null) { m_newCharset = v.trim(); if (Charset.isSupported(m_newCharset)) throw new SgfCharsetChanged(); else setWarning("Unknown character set \"" + m_newCharset + "\""); } } else if (p == "CP") set(node, StringInfo.COPYRIGHT, v); else if (p == "CR") parseMarked(node, MarkType.CIRCLE, values); else if (p == "DT") set(node, StringInfo.DATE, v); else if (p == "FF") { int format = -1; try { format = Integer.parseInt(v); } catch (NumberFormatException e) { } if (format < 1 || format > 4) setWarning("Unknown SGF file format version"); } else if (p == "GM") { // Some SGF files contain GM[], interpret as GM[1] v = v.trim(); if (! v.equals("") && ! v.equals("1")) throw getError("Not a Go game"); } else if (p == "HA") { // Some SGF files contain HA[], interpret as unknown handicap v = v.trim(); if (! v.equals("")) { try { int handicap = Integer.parseInt(v); if (handicap == 1 || handicap < 0) setWarning("Invalid handicap value"); else createGameInfo(node).setHandicap(handicap); } catch (NumberFormatException e) { setWarning("Invalid handicap value"); } } } else if (p == "KM") parseKomi(node, v); else if (p == "LB") { for (int i = 0; i < values.size(); ++i) { String value = values.get(i); int pos = value.indexOf(':'); if (pos > 0) { GoPoint point = parsePoint(value.substring(0, pos)); String text = value.substring(pos + 1); node.setLabel(point, text); } } } else if (p == "MA" || p == "M") parseMarked(node, MarkType.MARK, values); else if (p == "OB") { try { node.setMovesLeft(BLACK, Integer.parseInt(v)); } catch (NumberFormatException e) { } } else if (p == "OM") parseOvertimeMoves(v); else if (p == "OP") parseOvertimePeriod(v); else if (p == "OT") parseOvertime(node, v); else if (p == "OW") { try { node.setMovesLeft(WHITE, Integer.parseInt(v)); } catch (NumberFormatException e) { } } else if (p == "PB") set(node, StringInfoColor.NAME, BLACK, v); else if (p == "PW") set(node, StringInfoColor.NAME, WHITE, v); else if (p == "PL") node.setPlayer(parseColor(v)); else if (p == "RE") set(node, StringInfo.RESULT, v); else if (p == "RO") set(node, StringInfo.ROUND, v); else if (p == "RU") set(node, StringInfo.RULES, v); else if (p == "SO") set(node, StringInfo.SOURCE, v); else if (p == "SQ") parseMarked(node, MarkType.SQUARE, values); else if (p == "SL") parseMarked(node, MarkType.SELECT, values); else if (p == "TB") parseMarked(node, MarkType.TERRITORY_BLACK, values); else if (p == "TM") parseTime(node, v); else if (p == "TR") parseMarked(node, MarkType.TRIANGLE, values); else if (p == "US") set(node, StringInfo.USER, v); else if (p == "W") node.setMove(Move.get(WHITE, parsePoint(v))); else if (p == "TW") parseMarked(node, MarkType.TERRITORY_WHITE, values); else if (p == "V") { try { node.setValue(Float.parseFloat(v)); } catch (NumberFormatException e) { } } else if (p == "WL") { try { node.setTimeLeft(WHITE, Double.parseDouble(v)); } catch (NumberFormatException e) { } } else if (p == "WR") set(node, StringInfoColor.RANK, WHITE, v); else if (p == "WT") set(node, StringInfoColor.TEAM, WHITE, v); else if (p != "FF" && p != "GN" && p != "AP") node.addSgfProperty(p, values); } } private GoColor parseColor(String s) throws SgfError { GoColor color; s = s.trim().toLowerCase(Locale.ENGLISH); if (s.equals("b") || s.equals("1")) color = BLACK; else if (s.equals("w") || s.equals("2")) color = WHITE; else throw getError("Invalid color value"); return color; } private int parseInt(String s) throws SgfError { int i = -1; try { i = Integer.parseInt(s.trim()); } catch (NumberFormatException e) { throw getError("Number expected"); } return i; } private void parseKomi(Node node, String value) throws SgfError { try { Komi komi = Komi.parseKomi(value); createGameInfo(node).setKomi(komi); if (komi != null && ! komi.isMultipleOf(0.5)) setWarning("Komi is not a multiple of 0.5"); } catch (InvalidKomiException e) { setWarning("Invalid value for komi"); } } private void parseMarked(Node node, MarkType type, ArrayList<String> values) throws SgfError { parsePointList(values); for (GoPoint p : m_pointList) node.addMarked(p, type); } private void parseOvertime(Node node, String value) { SgfUtil.Overtime overtime = SgfUtil.parseOvertime(value); if (overtime == null) // Preserve information node.addSgfProperty("OT", value); else { m_byoyomi = overtime.m_byoyomi; m_byoyomiMoves = overtime.m_byoyomiMoves; } } /** FF3 OM property */ private void parseOvertimeMoves(String value) { try { m_byoyomiMoves = Integer.parseInt(value); } catch (NumberFormatException e) { setWarning("Invalid value for byoyomi moves"); m_ignoreOvertime = true; } } /** FF3 OP property */ private void parseOvertimePeriod(String value) { try { m_byoyomi = (long)(Double.parseDouble(value) * 1000); } catch (NumberFormatException e) { setWarning("Invalid value for byoyomi time"); m_ignoreOvertime = true; } } /** Parse point value. @return Point or null, if pass move @throw SgfError On invalid value */ private GoPoint parsePoint(String s) throws SgfError { s = s.trim().toLowerCase(Locale.ENGLISH); if (s.equals("")) return null; if (s.length() > 2 || (s.length() == 2 && s.charAt(1) < 'a' || s.charAt(1) > 'z')) { // Try human-readable encoding as used by SmartGo try { return GoPoint.parsePoint(s, GoPoint.MAX_SIZE); } catch (InvalidPointException e) { throwInvalidCoordinates(s); } } else if (s.length() != 2) throwInvalidCoordinates(s); int boardSize = getBoardSize(); if (s.equals("tt") && boardSize <= 19) return null; int x = s.charAt(0) - 'a'; int y = boardSize - (s.charAt(1) - 'a') - 1; if (x < 0 || x >= boardSize || y < 0 || y >= boardSize) { if (x == boardSize && y == -1) { // Some programs encode pass moves, e.g. as jj for boardsize 9 setWarning("Non-standard pass move encoding"); return null; } throw getError("Coordinates \"" + s + "\" outside board size " + boardSize); } return GoPoint.get(x, y); } private void parsePointList(ArrayList<String> values) throws SgfError { m_pointList.clear(); for (int i = 0; i < values.size(); ++i) { String value = values.get(i); int pos = value.indexOf(':'); if (pos < 0) { GoPoint point = parsePoint(value); if (point == null) setWarning("Point list argument contains PASS"); else m_pointList.add(point); } else { GoPoint point1 = parsePoint(value.substring(0, pos)); GoPoint point2 = parsePoint(value.substring(pos + 1)); if (point1 == null || point2 == null) { setWarning("Compressed point list contains PASS"); continue; } int xMin = Math.min(point1.getX(), point2.getX()); int xMax = Math.max(point1.getX(), point2.getX()); int yMin = Math.min(point1.getY(), point2.getY()); int yMax = Math.max(point1.getY(), point2.getY()); for (int x = xMin; x <= xMax; ++x) for (int y = yMin; y <= yMax; ++y) m_pointList.add(GoPoint.get(x, y)); } } } /** TM property. According to FF4, TM needs to be a real value, but older SGF versions allow a string with unspecified content. We try to parse a few known formats. */ private void parseTime(Node node, String value) { value = value.trim(); if (value.equals("") || value.equals("-")) return; long preByoyomi = SgfUtil.parseTime(value); if (preByoyomi < 0) { setWarning("Unknown format in time property"); node.addSgfProperty("TM", value); // Preserve information } else m_preByoyomi = preByoyomi; } private Node readNext(Node father, boolean isRoot) throws IOException, SgfError, SgfCharsetChanged { if (m_progressShow != null) { int percent; if (m_size > 0) { long count = m_byteCountInputStream.getCount(); percent = (int)(count * 100 / m_size); } else percent = 100; if (percent != m_lastPercent) m_progressShow.showProgress(percent); m_lastPercent = percent; } m_tokenizer.nextToken(); int ttype = m_tokenizer.ttype; if (ttype == '(') { Node node = father; while (node != null) node = readNext(node, false); return father; } if (ttype == ')') return null; if (ttype == StreamTokenizer.TT_EOF) { setWarning("Game tree not closed"); return null; } if (ttype != ';') throw getError("Next node expected"); Node son = new Node(); if (father != null) father.append(son); m_ignoreOvertime = false; m_byoyomiMoves = -1; m_byoyomi = -1; m_preByoyomi = -1; m_props.clear(); while (readProp()); handleProps(son, isRoot); setTimeSettings(son); return son; } private boolean readProp() throws IOException, SgfError { m_tokenizer.nextToken(); int ttype = m_tokenizer.ttype; if (ttype == StreamTokenizer.TT_WORD) { // Use intern() to allow fast comparsion with == String p = m_tokenizer.sval.toUpperCase(Locale.ENGLISH).intern(); ArrayList<String> values = new ArrayList<String>(); String s; while ((s = readValue()) != null) values.add(s); if (values.isEmpty()) throw getError("Property \"" + p + "\" has no value"); p = checkForObsoleteLongProps(p); if (m_props.containsKey(p)) // Silently accept duplicate properties, as long as they have // the same value (only check for single value properties) if (m_props.get(p).size() > 1 || values.size() > 1 || ! values.get(0).equals(m_props.get(p).get(0))) setWarning("Duplicate property " + p + " in node"); m_props.put(p, values); return true; } if (ttype != '\n') // Don't pushBack newline, will confuse lineno() (Bug 4942853) m_tokenizer.pushBack(); return false; } private void readSgf(InputStream in, String charset) throws SgfError, SgfCharsetChanged { try { m_boardSize = -1; if (m_progressShow != null) { m_byteCountInputStream = new ByteCountInputStream(in); in = m_byteCountInputStream; } InputStreamReader reader; try { reader = new InputStreamReader(in, charset); } catch (UnsupportedEncodingException e) { // Should actually not happen, because this function is only // called with charset ISO-8859-1 (should be supported on every // Java platform according to Charset documentation) or with a // CA property value, which was already checked with // Charset.isSupported() setWarning("Character set \"" + charset + "\" not supported"); reader = new InputStreamReader(in); } m_reader = new BufferedReader(reader); m_tokenizer = new StreamTokenizer(m_reader); findRoot(); Node root = readNext(null, true); Node node = root; while (node != null) node = readNext(node, false); checkEndOfFile(); getBoardSize(); // Set to default value if still unknown m_tree = new GameTree(m_boardSize, root); applyFixes(); } catch (FileNotFoundException e) { throw new SgfError("File not found."); } catch (IOException e) { throw new SgfError("IO error"); } catch (OutOfMemoryError e) { throw new SgfError("Out of memory"); } } private String readValue() throws IOException, SgfError { m_tokenizer.nextToken(); int ttype = m_tokenizer.ttype; if (ttype != '[') { if (ttype != '\n') // Don't pushBack newline, will confuse lineno() (Bug 4942853) m_tokenizer.pushBack(); return null; } m_buffer.setLength(0); boolean quoted = false; Character last = null; while (true) { int c = m_reader.read(); if (c < 0) throw getError("Property value incomplete"); if (quoted) { if (c != '\n' && c != '\r') m_buffer.append((char)c); last = Character.valueOf((char)c); quoted = false; } else { if (c == ']') break; quoted = (c == '\\'); if (! quoted) { // Transform all linebreaks allowed in SGF (LF, CR, LFCR, // CRLF) to a single '\n' boolean isLinebreak = (c == '\n' || c == '\r'); boolean lastLinebreak = (last != null && (last.charValue() == '\n' || last.charValue() == '\r')); boolean filterSecondLinebreak = (isLinebreak && lastLinebreak && c != last.charValue()); if (filterSecondLinebreak) last = null; else { if (isLinebreak) m_buffer.append('\n'); else m_buffer.append((char)c); last = Character.valueOf((char)c); } } } } return m_buffer.toString(); } private void set(Node node, StringInfo type, String value) { GameInfo info = createGameInfo(node); info.set(type, value); } private void set(Node node, StringInfoColor type, GoColor c, String value) { GameInfo info = createGameInfo(node); info.set(type, c, value); } private void setTimeSettings(Node node) { TimeSettings s = null; if (m_preByoyomi > 0 && (m_ignoreOvertime || m_byoyomi <= 0 || m_byoyomiMoves <= 0)) s = new TimeSettings(m_preByoyomi); else if (m_preByoyomi <= 0 && ! m_ignoreOvertime && m_byoyomi > 0 && m_byoyomiMoves > 0) s = new TimeSettings(0, m_byoyomi, m_byoyomiMoves); else if (m_preByoyomi > 0 && ! m_ignoreOvertime && m_byoyomi > 0 && m_byoyomiMoves > 0) s = new TimeSettings(m_preByoyomi, m_byoyomi, m_byoyomiMoves); if (s != null) node.createGameInfo().setTimeSettings(s); } private void setWarning(String message) { m_warnings.add(message); } private void throwInvalidCoordinates(String s) throws SgfError { throw getError("Invalid coordinates \"" + s + "\""); } }