@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
Markdown
# PGN
[](https://www.npmjs.com/package/@echecs/pgn)
[](https://github.com/mormubis/pgn/actions/workflows/test.yml)
[](https://codecov.io/gh/mormubis/pgn)
[](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.1–1.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.