UNPKG

@echecs/pgn

Version:

Parse PGN (Portable Game Notation) chess games into structured JavaScript objects. Zero dependencies, strict TypeScript, no-throw API.

338 lines (260 loc) 10.5 kB
# PGN [![npm](https://img.shields.io/npm/v/@echecs/pgn)](https://www.npmjs.com/package/@echecs/pgn) [![Test](https://github.com/mormubis/pgn/actions/workflows/test.yml/badge.svg)](https://github.com/mormubis/pgn/actions/workflows/test.yml) [![Coverage](https://codecov.io/gh/mormubis/pgn/branch/main/graph/badge.svg)](https://codecov.io/gh/mormubis/pgn) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) **PGN** is a fast TypeScript parser for [Portable Game Notation](http://www.saremba.de/chessgml/standards/pgn/pgn-complete.htm) — the standard format for recording chess games. It parses PGN input into structured move objects with decomposed SAN, paired white/black moves, and full support for annotations and variations. Zero runtime dependencies. ## Why this library? Most PGN parsers on npm either give you raw strings with no structure, or fail on anything beyond a plain game record. If you're building a chess engine, opening book, or game viewer, you need more: - **Decomposed SAN** — every move is parsed into `piece`, `from`, `to`, `capture`, `promotion`, `check`, and `checkmate` fields. No regex on your side. - **Paired move structure** — moves are returned as `[moveNumber, whiteMove, blackMove]` tuples, ready to render or process without further work. - **RAV support** — recursive annotation variations (`(...)` sub-lines) are parsed into a `variants` tree on each move. Essential for opening books and annotated games. - **NAG support** — symbolic (`!`, `?`, `!!`, `??`, `!?`, `?!`) and numeric (`$1``$255`) annotations are surfaced as an `annotations` array. Essential for Lichess and ChessBase exports. - **Multi-game files** — parse entire PGN databases in one call, or stream them game-by-game with `stream()` for memory-efficient processing of large files. Tested on files with 3 500+ games. - **Fast** — built on a [Peggy](https://peggyjs.org/) PEG parser. Throughput is within 1.11.2x of the fastest parsers on npm, which do far less work per move (see [BENCHMARK_RESULTS.md](./BENCHMARK_RESULTS.md)). If you only need raw SAN strings and a flat move list, any PGN parser will do. If you need structured, engine-ready output with annotations and variations, this is the one. ## Installation ```bash npm install @echecs/pgn ``` ## Quick Start ```typescript import parse from '@echecs/pgn'; const games = parse(` [Event "Example"] [White "Player1"] [Black "Player2"] [Result "1-0"] 1. e4 e5 2. Nf3 Nc6 3. Bb5 1-0 `); console.log(games[0].moves[0]); // [1, { piece: 'P', to: 'e4' }, { piece: 'P', to: 'e5' }] ``` ## Usage ### `parse()` Takes a PGN string and returns an array of game objects — one per game in the file. ```typescript parse(input: string, options?: ParseOptions): PGN[] ``` ### `stream()` Takes any `AsyncIterable<string>` or Web Streams `ReadableStream<string>` and yields one `PGN` object per game. Memory usage stays proportional to one game at a time, making it suitable for large databases read from disk or a network stream. ```typescript stream(input: AsyncIterable<string> | ReadableStream<string>, options?: ParseOptions): AsyncGenerator<PGN> ``` **Node.js (file):** ```typescript import { createReadStream } from 'node:fs'; import { stream } from '@echecs/pgn'; const chunks = createReadStream('database.pgn', { encoding: 'utf8' }); for await (const game of stream(chunks)) { console.log(game.meta.White, 'vs', game.meta.Black); } ``` **Browser / edge (fetch):** ```typescript import { stream } from '@echecs/pgn'; const response = await fetch('database.pgn'); const text = response.body.pipeThrough(new TextDecoderStream()); for await (const game of stream(text)) { console.log(game.meta.White, 'vs', game.meta.Black); } ``` ### `stringify()` Converts one or more parsed `PGN` objects back into a valid PGN string, providing semantic round-trip fidelity. ```typescript stringify(input: PGN | PGN[], options?: ParseOptions): string ``` Reconstructs SAN from `Move` fields, re-serializes annotation commands (`[%cal]`, `[%csl]`, `[%clk]`, `[%eval]`) back into comment blocks, and preserves RAVs and NAGs. Pass `onWarning` to observe recoverable issues (e.g. invalid castling destination, negative clock). ```typescript import parse, { stringify } from '@echecs/pgn'; const games = parse(pgnString); const output = stringify(games); // valid PGN string ``` ### Error handling By default, `parse()` and `stream()` silently return `[]` / skip games on parse failure. Pass an `onError` callback to observe failures: ```typescript import parse, { type ParseError } from '@echecs/pgn'; const games = parse(input, { onError(err: ParseError) { console.error( `Parse failed at line ${err.line}:${err.column} — ${err.message}`, ); }, }); ``` The same option is accepted by `stream()`: ```typescript for await (const game of stream(chunks, { onError: console.error })) { // … } ``` `onError` receives a `ParseError` with: | Field | Type | Description | | --------- | -------- | ------------------------------------------ | | `message` | `string` | Human-readable description from the parser | | `offset` | `number` | Character offset in the input (0-based) | | `line` | `number` | 1-based line number | | `column` | `number` | 1-based column number | > **Note:** `onError` is not called when a stream ends without a result token > (truncated input). Incomplete input at end-of-stream is treated as expected > behaviour, not a parse error. ### Warnings Pass `onWarning` to observe spec-compliance issues that do not prevent parsing: ```typescript import parse, { type ParseWarning } from '@echecs/pgn'; const games = parse(input, { onWarning(warn: ParseWarning) { console.warn(warn.message); }, }); ``` `onWarning` receives a `ParseWarning` with the same fields as `ParseError`: `message`, `offset`, `line`, `column`. Currently fires for: - Missing STR tags (`Black`, `Date`, `Event`, `Result`, `Round`, `Site`, `White`) — emitted in alphabetical key order; position fields are nominal placeholders - Move number mismatch (declared move number in the PGN text doesn't match the move's actual position) — position fields are nominal placeholders - Result tag mismatch (`[Result "..."]` tag value differs from the game termination marker) — position fields are nominal placeholders - Duplicate tag names — `line` and `column` point to the opening `[` of the duplicate tag The same option is accepted by `stream()`. ### PGN object ```typescript { meta: Meta, // tag pairs (Event, Site, Date, White, Black, …) moves: MoveList, // paired move list result: 1 | 0 | 0.5 | '?' } ``` `meta` is an index of all tag pairs from the PGN header. The `Result` key is optional — games with no tag pairs return `meta: {}`. Use `game.result` (always present) as the authoritative game outcome. ### Move object ```typescript { piece: 'P' | 'R' | 'N' | 'B' | 'Q' | 'K', // always present to: string, // destination square, e.g. "e4" from?: string, // disambiguation: file "e", rank "2", or square "e2" capture?: true, castling?: true, check?: true, checkmate?: true, promotion?: 'R' | 'N' | 'B' | 'Q', annotations?: string[], // e.g. ["!", "$14"] comment?: string, arrows?: Arrow[], // from [%cal ...] command squares?: SquareAnnotation[], // from [%csl ...] command clock?: number, // from [%clk ...] — seconds remaining eval?: Eval, // from [%eval ...] — engine evaluation variants?: MoveList[], // recursive annotation variations } ``` Moves are grouped into tuples: `[moveNumber, whiteMove, blackMove]`. Both move slots can be `undefined``whiteMove` when a variation begins on black's turn, `blackMove` when the game or variation ends on white's move. ### Annotations and comments ```pgn 12. Nf3! $14 { White has a slight advantage } ``` ```typescript { piece: 'N', to: 'f3', annotations: ['!', '$14'], comment: 'White has a slight advantage' } ``` ### Comment annotations PGN files produced by GUIs and engines embed structured commands inside move comments using the `[%cmd ...]` syntax. This library parses the four most common commands and exposes them as dedicated fields on `Move`: | Field | Type | PGN command | Description | | --------- | -------------------- | ------------- | ------------------------------------------------ | | `arrows` | `Arrow[]` | `[%cal ...]` | Coloured arrows drawn on the board | | `squares` | `SquareAnnotation[]` | `[%csl ...]` | Coloured square highlights | | `clock` | `number` | `[%clk ...]` | Remaining time in seconds (sub-second preserved) | | `eval` | `Eval` | `[%eval ...]` | Engine evaluation (centipawns or mate-in-N) | Command strings are stripped from `move.comment`. Unknown `[%...]` commands are left in the comment string unchanged. #### Types ```typescript type AnnotationColor = 'R' | 'G' | 'B' | 'Y'; // Red, Green, Blue, Yellow interface Arrow { color: AnnotationColor; from: string; // e.g. "e2" to: string; // e.g. "e4" } interface SquareAnnotation { color: AnnotationColor; square: string; // e.g. "e4" } interface Eval { depth?: number; // search depth, if present mate?: number; // mate in N (positive = current side wins) value?: number; // centipawn score (positive = current side advantage) } ``` #### Example ```pgn 1. e4 { [%cal Ge2e4,Re4e5] [%clk 0:05:00] } e5 ``` ```typescript { piece: 'P', to: 'e4', comment: '', // command strings removed; only free text remains arrows: [ { color: 'G', from: 'e2', to: 'e4' }, { color: 'R', from: 'e4', to: 'e5' }, ], clock: 300, // 5 minutes in seconds } ``` ### Variations ```pgn 5... Ba5 (5... Be7 6. d4) 6. Qb3 ``` The alternative line appears as a `variants` array on the move where it branches: ```typescript { piece: 'B', to: 'a5', variants: [ [ [5, undefined, { piece: 'B', to: 'e7' }], [6, { piece: 'P', to: 'd4' }] ] ] } ``` ## Contributing Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to submit issues and pull requests.