From fe478c12bf424842b154987e1ee42ebff8da4857 Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Mon, 1 Jun 2026 21:28:56 +0200 Subject: [PATCH] Fix escaped quote handling in PGN tag values --- game.go | 16 +++++++++++++++- lexer.go | 15 ++++++++++++--- scanner_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/game.go b/game.go index d1e4dc890..32c7eba8a 100644 --- a/game.go +++ b/game.go @@ -377,6 +377,20 @@ func (g *Game) FEN() string { return g.pos.String() } +// escapeTagValue escapes backslash and double-quote characters so that the +// resulting string is safe to embed inside a PGN tag value. +func escapeTagValue(v string) string { + var sb strings.Builder + for i := 0; i < len(v); i++ { + c := v[i] + if c == '\\' || c == '"' { + sb.WriteByte('\\') + } + sb.WriteByte(c) + } + return sb.String() +} + // String implements the fmt.Stringer interface and returns // the game's PGN. func (g *Game) String() string { @@ -397,7 +411,7 @@ func (g *Game) String() string { // Write tag pairs. for _, tagPair := range tagPairList { - sb.WriteString(fmt.Sprintf("[%s \"%s\"]\n", tagPair.Key, tagPair.Value)) + sb.WriteString(fmt.Sprintf("[%s \"%s\"]\n", tagPair.Key, escapeTagValue(tagPair.Value))) } // Append empty line after tag pairs as per definition diff --git a/lexer.go b/lexer.go index 06dae3e83..9eab1a099 100644 --- a/lexer.go +++ b/lexer.go @@ -409,13 +409,22 @@ func (l *Lexer) readChar() { func (l *Lexer) readTagValue() Token { l.readChar() // skip opening quote - position := l.position + var value strings.Builder for l.ch != '"' && l.ch != 0 { + if l.ch == '\\' { + next := l.peekChar() + if next == '"' || next == '\\' { + l.readChar() // skip backslash + value.WriteByte(l.ch) + l.readChar() + continue + } + } + value.WriteByte(l.ch) l.readChar() } - value := l.input[position:l.position] l.readChar() // skip closing quote - return Token{Type: TagValue, Value: value} + return Token{Type: TagValue, Value: value.String()} } func (l *Lexer) readTagKey() Token { diff --git a/scanner_test.go b/scanner_test.go index c88160918..48d424b0a 100644 --- a/scanner_test.go +++ b/scanner_test.go @@ -328,3 +328,37 @@ func TestScannerMultiFromPosNoExpand(t *testing.T) { scanner := NewScanner(reader) validateExpand(t, scanner, expectedLastLines, expectedFinalPos) } + +func TestEscapedQuoteInTagValue(t *testing.T) { + const escapedQuoteGame = `[Event "Internet Section 08A g/8'+2\""] +[Site "Dos Hermanas"] +[Date "2004.03.08"] +[Round "6"] +[White "Di Berardino, Diego Rafael"] +[Black "Mar, Fernando"] +[Result "1/2-1/2"] +[ECO "B96"] + +1. e4 c5 2. Nf3 1/2-1/2 +` + + scanner := NewScanner(strings.NewReader(escapedQuoteGame)) + if !scanner.HasNext() { + t.Fatal("scanner should report one game") + } + game, err := scanner.ParseNext() + if err != nil { + t.Fatalf("BUG: parser rejects spec-legal escaped quote in tag value: %v", err) + } + + // The in-memory value should contain a literal double quote. + if game.GetTagPair("Event") != `Internet Section 08A g/8'+2"` { + t.Fatalf("expected unescaped quote in tag value, got %q", game.GetTagPair("Event")) + } + + // Round-trip: String() must emit the escaped form again. + pgn := game.String() + if !strings.Contains(pgn, `[Event "Internet Section 08A g/8'+2\""]`) { + t.Fatalf("round-trip PGN does not contain escaped quote: %s", pgn) + } +}